mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
Prototype Buzz onboarding V3 workshop flow
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
35
Justfile
35
Justfile
@@ -569,6 +569,41 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs
|
||||
echo "Starting standalone desktop on Vite port ${BUZZ_VITE_PORT}; no relay services were started"
|
||||
pnpm exec tauri dev --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
|
||||
|
||||
# Launch a development-only onboarding workshop. The frontend uses React-only
|
||||
# state and fixed mock data; native setup stops before migrations, identity
|
||||
# persistence, keychain access, relay sync, or agent restore.
|
||||
onboarding-preview *ARGS: _ensure-sidecar-stubs
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||
cd {{desktop_dir}}
|
||||
[[ -d node_modules ]] || pnpm install
|
||||
unset BUZZ_AUTH_TAG BUZZ_RESET_WEBVIEW_STATE BUZZ_SHARE_IDENTITY
|
||||
export BUZZ_ONBOARDING_PREVIEW=1
|
||||
export BUZZ_RELAY_URL="ws://127.0.0.1:9"
|
||||
# Fixed public test fixture: build_app_state never generates an identity,
|
||||
# while the preview UI never receives or exposes this value.
|
||||
export BUZZ_PRIVATE_KEY="3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03"
|
||||
source ../scripts/instance-env.sh
|
||||
export BUZZ_PREVIEW_VITE_PORT=$((20001 + (BUZZ_VITE_PORT % 40000)))
|
||||
BUZZ_TAURI_CONFIG=$(node -e '
|
||||
const config = JSON.parse(process.env.BUZZ_TAURI_CONFIG);
|
||||
const url = new URL(config.build.devUrl);
|
||||
url.port = process.env.BUZZ_PREVIEW_VITE_PORT;
|
||||
url.searchParams.set("onboardingPreview", "1");
|
||||
config.build.devUrl = url.toString();
|
||||
config.build.beforeDevCommand = `exec ./node_modules/.bin/vite --port ${process.env.BUZZ_PREVIEW_VITE_PORT} --strictPort`;
|
||||
config.identifier = `xyz.block.buzz.onboarding-preview.p${process.env.BUZZ_PREVIEW_VITE_PORT}`;
|
||||
config.productName = "Buzz Onboarding Preview";
|
||||
process.stdout.write(JSON.stringify(config));
|
||||
')
|
||||
export BUZZ_TAURI_CONFIG
|
||||
export BUZZ_DEV_KEYRING_SERVICE="buzz-desktop-onboarding-preview.${BUZZ_PREVIEW_VITE_PORT}"
|
||||
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
|
||||
trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT
|
||||
echo "Starting non-persistent onboarding preview on Vite port ${BUZZ_PREVIEW_VITE_PORT}; external relay access is disabled"
|
||||
pnpm exec tauri dev --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
|
||||
|
||||
# Run the desktop app against the internal staging relay (installs deps + builds agent tools automatically)
|
||||
staging *ARGS: bootstrap _ensure-sidecar-stubs
|
||||
#!/usr/bin/env bash
|
||||
|
||||
@@ -176,18 +176,18 @@ pub fn build_media_fetch_client() -> reqwest::Result<reqwest::Client> {
|
||||
}
|
||||
|
||||
pub fn build_app_state() -> AppState {
|
||||
// Env var takes precedence (dev/CI). If absent, resolve_persisted_identity()
|
||||
// in setup() will replace the ephemeral placeholder with a persisted key.
|
||||
let (keys, identity_storage) = match identity_from_env() {
|
||||
Some(keys) => {
|
||||
eprintln!(
|
||||
"buzz-desktop: configured identity pubkey {}",
|
||||
keys.public_key().to_hex()
|
||||
);
|
||||
(keys, IdentityStorage::Environment)
|
||||
}
|
||||
None => (Keys::generate(), IdentityStorage::Ephemeral),
|
||||
};
|
||||
// Env var takes precedence; setup() otherwise resolves a persisted identity.
|
||||
let (keys, identity_storage) =
|
||||
match crate::onboarding_preview::require_fixed_identity(identity_from_env()) {
|
||||
Some(keys) => {
|
||||
eprintln!(
|
||||
"buzz-desktop: configured identity pubkey {}",
|
||||
keys.public_key().to_hex()
|
||||
);
|
||||
(keys, IdentityStorage::Environment)
|
||||
}
|
||||
None => (Keys::generate(), IdentityStorage::Ephemeral),
|
||||
};
|
||||
|
||||
AppState {
|
||||
keys: Mutex::new(keys),
|
||||
|
||||
@@ -33,6 +33,7 @@ mod native_websocket_batch;
|
||||
mod nostr_bind;
|
||||
pub mod nostr_convert;
|
||||
mod observed_unread;
|
||||
mod onboarding_preview;
|
||||
mod persona_catalog;
|
||||
mod prevent_sleep;
|
||||
mod ptt_shortcut;
|
||||
@@ -161,6 +162,10 @@ pub fn run() {
|
||||
// commit the startup surface before revealing it.
|
||||
let window = webview.window();
|
||||
|
||||
if onboarding_preview::reveal_window(&window) {
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
set_initial_window_backing(&window);
|
||||
@@ -238,6 +243,9 @@ pub fn run() {
|
||||
.manage(channel_head_cache::ChannelHeadCacheStore::default())
|
||||
.setup(move |app| {
|
||||
let app_handle = app.handle().clone();
|
||||
if onboarding_preview::skip_native_setup() {
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
tray_menu::init(&app_handle)?;
|
||||
|
||||
55
desktop/src-tauri/src/onboarding_preview.rs
Normal file
55
desktop/src-tauri/src/onboarding_preview.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
const ONBOARDING_PREVIEW_ENV: &str = "BUZZ_ONBOARDING_PREVIEW";
|
||||
|
||||
fn enabled_for(debug_build: bool, value: Option<&str>) -> bool {
|
||||
debug_build && value == Some("1")
|
||||
}
|
||||
|
||||
/// Whether the development-only native onboarding workshop is active.
|
||||
pub(crate) fn enabled() -> bool {
|
||||
enabled_for(
|
||||
cfg!(debug_assertions),
|
||||
std::env::var(ONBOARDING_PREVIEW_ENV).ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Fail closed before app state could generate a placeholder identity.
|
||||
pub(crate) fn require_fixed_identity<T>(identity: Option<T>) -> Option<T> {
|
||||
assert!(
|
||||
identity.is_some() || !enabled(),
|
||||
"BUZZ_ONBOARDING_PREVIEW requires fixed mock identity data"
|
||||
);
|
||||
identity
|
||||
}
|
||||
|
||||
/// Stop native setup before migrations, persistence, networking, or agents.
|
||||
pub(crate) fn skip_native_setup() -> bool {
|
||||
if !enabled() {
|
||||
return false;
|
||||
}
|
||||
eprintln!(
|
||||
"buzz-desktop: onboarding preview safety mode; skipped migrations, identity persistence, relay sync, and agent restore"
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Preview does not wait for the normal app's first-render event.
|
||||
pub(crate) fn reveal_window<R: tauri::Runtime>(window: &tauri::Window<R>) -> bool {
|
||||
if !enabled() {
|
||||
return false;
|
||||
}
|
||||
crate::initial_window::reveal_initial_window(window);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::enabled_for;
|
||||
|
||||
#[test]
|
||||
fn preview_is_debug_only_and_requires_exact_opt_in() {
|
||||
assert!(enabled_for(true, Some("1")));
|
||||
assert!(!enabled_for(true, Some("true")));
|
||||
assert!(!enabled_for(true, None));
|
||||
assert!(!enabled_for(false, Some("1")));
|
||||
}
|
||||
}
|
||||
45
desktop/src/features/onboarding/onboardingPreview.test.mjs
Normal file
45
desktop/src/features/onboarding/onboardingPreview.test.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveOnboardingPreviewMode } from "./onboardingPreview.ts";
|
||||
|
||||
test("preview requires an exact development opt-in", () => {
|
||||
assert.equal(
|
||||
resolveOnboardingPreviewMode({
|
||||
dev: true,
|
||||
mode: "development",
|
||||
search: "?onboardingPreview=1",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
resolveOnboardingPreviewMode({
|
||||
dev: true,
|
||||
mode: "development",
|
||||
search: "?onboardingPreview=true",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("preview is unavailable in production", () => {
|
||||
assert.equal(
|
||||
resolveOnboardingPreviewMode({
|
||||
dev: false,
|
||||
mode: "production",
|
||||
search: "?onboardingPreview=1",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("preview is available to the explicit E2E build", () => {
|
||||
assert.equal(
|
||||
resolveOnboardingPreviewMode({
|
||||
dev: false,
|
||||
mode: "e2e",
|
||||
search: "?onboardingPreview=1",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
31
desktop/src/features/onboarding/onboardingPreview.ts
Normal file
31
desktop/src/features/onboarding/onboardingPreview.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
const ONBOARDING_PREVIEW_PARAM = "onboardingPreview";
|
||||
const ONBOARDING_PREVIEW_VALUE = "1";
|
||||
|
||||
type OnboardingPreviewEnvironment = {
|
||||
dev: boolean;
|
||||
mode: string;
|
||||
search: string;
|
||||
};
|
||||
|
||||
/** Resolve the explicit, non-production onboarding workshop route. */
|
||||
export function resolveOnboardingPreviewMode({
|
||||
dev,
|
||||
mode,
|
||||
search,
|
||||
}: OnboardingPreviewEnvironment) {
|
||||
if (!dev && mode !== "e2e") return false;
|
||||
return (
|
||||
new URLSearchParams(search).get(ONBOARDING_PREVIEW_PARAM) ===
|
||||
ONBOARDING_PREVIEW_VALUE
|
||||
);
|
||||
}
|
||||
|
||||
/** True only for a development or explicit E2E preview boot. */
|
||||
export function onboardingPreviewRequested() {
|
||||
if (typeof window === "undefined") return false;
|
||||
return resolveOnboardingPreviewMode({
|
||||
dev: import.meta.env.DEV,
|
||||
mode: import.meta.env.MODE,
|
||||
search: window.location.search,
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay";
|
||||
* pause sells the creation moment.
|
||||
*/
|
||||
const INTRO_HOLD_MS = 1400;
|
||||
const PREVIEW_KEY_TEXT = `nsec1${"q".repeat(58)}`;
|
||||
|
||||
/**
|
||||
* The creation moment should only be sold once per app session. Module-level
|
||||
@@ -53,6 +54,7 @@ type BackupStepProps = {
|
||||
onOpenPasswordBackup: () => void;
|
||||
onShowOptions: () => void;
|
||||
optionsExpanded: boolean;
|
||||
previewMode?: boolean;
|
||||
returningFromSecurity: boolean;
|
||||
};
|
||||
|
||||
@@ -69,6 +71,7 @@ export function BackupStep({
|
||||
onOpenPasswordBackup,
|
||||
onShowOptions,
|
||||
optionsExpanded,
|
||||
previewMode = false,
|
||||
returningFromSecurity,
|
||||
}: BackupStepProps) {
|
||||
const reduceMotion = useReducedMotion() ?? false;
|
||||
@@ -112,7 +115,7 @@ export function BackupStep({
|
||||
setCopyState("copying");
|
||||
setCopyError(null);
|
||||
try {
|
||||
const value = nsec ?? (await getNsec());
|
||||
const value = nsec ?? (previewMode ? PREVIEW_KEY_TEXT : await getNsec());
|
||||
await writeTextToClipboard(value);
|
||||
if (cancelledRef.current) return;
|
||||
setCopyState("copied");
|
||||
@@ -128,7 +131,7 @@ export function BackupStep({
|
||||
err instanceof Error ? err.message : "Failed to retrieve private key.",
|
||||
);
|
||||
}
|
||||
}, [nsec]);
|
||||
}, [nsec, previewMode]);
|
||||
|
||||
const toggleReveal = React.useCallback(async () => {
|
||||
if (isRevealed) {
|
||||
@@ -138,7 +141,7 @@ export function BackupStep({
|
||||
setCopyError(null);
|
||||
try {
|
||||
// The raw key enters the DOM only after this explicit reveal action.
|
||||
const value = nsec ?? (await getNsec());
|
||||
const value = nsec ?? (previewMode ? PREVIEW_KEY_TEXT : await getNsec());
|
||||
if (cancelledRef.current) return;
|
||||
setNsec(value);
|
||||
setIsRevealed(true);
|
||||
@@ -148,7 +151,7 @@ export function BackupStep({
|
||||
err instanceof Error ? err.message : "Failed to retrieve private key.",
|
||||
);
|
||||
}
|
||||
}, [isRevealed, nsec]);
|
||||
}, [isRevealed, nsec, previewMode]);
|
||||
|
||||
// Fixed-length decorative mask (nsec keys are 63 chars) so no key material
|
||||
// is fetched just to render the blurred row. Bullets are joined with a
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
|
||||
import { joinPolicyDocumentUrl, type JoinPolicy } from "@/shared/api/invites";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
|
||||
@@ -13,25 +14,31 @@ type JoinPolicyNoticeProps = {
|
||||
policy: JoinPolicy;
|
||||
/** Relay hosting the policy documents the links below point at. */
|
||||
relayWsUrl: string;
|
||||
/** Optional safe host for surfaces that must not open external documents. */
|
||||
onOpenDocument?: (document: "terms" | "privacy") => void;
|
||||
/** Use primary page text where a surface needs stronger consent contrast. */
|
||||
textTone?: "muted" | "foreground";
|
||||
};
|
||||
|
||||
/**
|
||||
* Join-policy consent block shown on every join surface.
|
||||
*
|
||||
* The Terms/Privacy links open the relay-hosted document pages
|
||||
* (`/api/join-policy/terms|privacy`) in the system browser via the OS
|
||||
* opener. They must NOT navigate or render in-app: these surfaces exist
|
||||
* before onboarding completes, where the router (required by the message
|
||||
* Markdown component) is not mounted — an in-app render tears down the
|
||||
* whole React tree.
|
||||
* The Terms/Privacy links normally open the relay-hosted document pages
|
||||
* (`/api/join-policy/terms|privacy`) in the system browser via the OS opener.
|
||||
* A non-networked host may intercept those actions. They must NOT navigate or
|
||||
* render in-app: these surfaces exist before onboarding completes, where the
|
||||
* router (required by the message Markdown component) is not mounted — an
|
||||
* in-app render tears down the whole React tree.
|
||||
*/
|
||||
export function JoinPolicyNotice({
|
||||
ageConfirmed,
|
||||
agreementConfirmed,
|
||||
onAgeConfirmedChange,
|
||||
onAgreementConfirmedChange,
|
||||
onOpenDocument,
|
||||
policy,
|
||||
relayWsUrl,
|
||||
textTone = "muted",
|
||||
}: JoinPolicyNoticeProps) {
|
||||
const ageConfirmationId = React.useId();
|
||||
const agreementConfirmationId = React.useId();
|
||||
@@ -49,7 +56,12 @@ export function JoinPolicyNotice({
|
||||
}
|
||||
/>
|
||||
<label
|
||||
className="cursor-pointer text-xs leading-5 text-muted-foreground"
|
||||
className={cn(
|
||||
"cursor-pointer text-xs leading-5",
|
||||
textTone === "foreground"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
htmlFor={ageConfirmationId}
|
||||
>
|
||||
I am 18 years of age or older.
|
||||
@@ -68,16 +80,30 @@ export function JoinPolicyNotice({
|
||||
}
|
||||
/>
|
||||
<label
|
||||
className="cursor-pointer text-xs leading-5 text-muted-foreground"
|
||||
className={cn(
|
||||
"cursor-pointer text-xs leading-5",
|
||||
textTone === "foreground"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
htmlFor={agreementConfirmationId}
|
||||
>
|
||||
I agree to the Buzz{" "}
|
||||
{policy.termsMarkdown ? (
|
||||
<Button
|
||||
className="h-auto p-0 align-baseline text-xs no-underline hover:underline focus-visible:no-underline"
|
||||
className={cn(
|
||||
"h-auto p-0 align-baseline text-xs",
|
||||
textTone === "foreground"
|
||||
? "font-medium text-foreground underline hover:underline focus-visible:underline"
|
||||
: "no-underline hover:underline focus-visible:no-underline",
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void openUrl(joinPolicyDocumentUrl(relayWsUrl, "terms"));
|
||||
if (onOpenDocument) {
|
||||
onOpenDocument("terms");
|
||||
} else {
|
||||
void openUrl(joinPolicyDocumentUrl(relayWsUrl, "terms"));
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
variant="link"
|
||||
@@ -88,10 +114,19 @@ export function JoinPolicyNotice({
|
||||
{policy.termsMarkdown && policy.privacyMarkdown ? " and " : null}
|
||||
{policy.privacyMarkdown ? (
|
||||
<Button
|
||||
className="h-auto p-0 align-baseline text-xs no-underline hover:underline focus-visible:no-underline"
|
||||
className={cn(
|
||||
"h-auto p-0 align-baseline text-xs",
|
||||
textTone === "foreground"
|
||||
? "font-medium text-foreground underline hover:underline focus-visible:underline"
|
||||
: "no-underline hover:underline focus-visible:no-underline",
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void openUrl(joinPolicyDocumentUrl(relayWsUrl, "privacy"));
|
||||
if (onOpenDocument) {
|
||||
onOpenDocument("privacy");
|
||||
} else {
|
||||
void openUrl(joinPolicyDocumentUrl(relayWsUrl, "privacy"));
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
variant="link"
|
||||
|
||||
@@ -45,6 +45,8 @@ type NostrKeyImportFormProps = {
|
||||
footerMode?: "onboarding" | "inline";
|
||||
/** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */
|
||||
variant?: "default" | "spotlight";
|
||||
/** Use fixed in-memory presentation data and bypass credential parsing. */
|
||||
previewMode?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -67,6 +69,7 @@ export function NostrKeyImportForm({
|
||||
mode = "key",
|
||||
footerMode = "onboarding",
|
||||
variant = "default",
|
||||
previewMode = false,
|
||||
}: NostrKeyImportFormProps) {
|
||||
const [nsecInput, setNsecInput] = React.useState("");
|
||||
const [passphrase, setPassphrase] = React.useState("");
|
||||
@@ -101,7 +104,7 @@ export function NostrKeyImportForm({
|
||||
setPassphrase("");
|
||||
}
|
||||
}, [isPasswordStage]);
|
||||
const isValid = keyImportSubmitEnabled(nsecInput, passphrase);
|
||||
const isValid = previewMode || keyImportSubmitEnabled(nsecInput, passphrase);
|
||||
const isInteractionDisabled = disabled || isImporting;
|
||||
const showInvalidHint =
|
||||
hasInput &&
|
||||
@@ -217,7 +220,10 @@ export function NostrKeyImportForm({
|
||||
setImportError(null);
|
||||
|
||||
try {
|
||||
await onImport(trimmedInput, isPasswordStage ? passphrase : undefined);
|
||||
await onImport(
|
||||
previewMode ? "" : trimmedInput,
|
||||
isPasswordStage ? passphrase : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
setImportError(
|
||||
error instanceof Error ? error.message : "Couldn't import this key.",
|
||||
@@ -235,6 +241,7 @@ export function NostrKeyImportForm({
|
||||
onImport,
|
||||
onImportingChange,
|
||||
passphrase,
|
||||
previewMode,
|
||||
trimmedInput,
|
||||
]);
|
||||
|
||||
@@ -302,14 +309,16 @@ export function NostrKeyImportForm({
|
||||
data-testid="nostr-import-nsec-input"
|
||||
id="nostr-private-key"
|
||||
onChange={(event) => {
|
||||
if (previewMode) return;
|
||||
setNsecInput(event.target.value);
|
||||
setImportError(null);
|
||||
}}
|
||||
placeholder="Enter your key here"
|
||||
readOnly={previewMode}
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
type={isRevealed ? "text" : "password"}
|
||||
value={nsecInput}
|
||||
value={previewMode ? `nsec1${"•".repeat(24)}` : nsecInput}
|
||||
/>
|
||||
{/* Absolutely positioned so appearing/disappearing never resizes
|
||||
the input or shifts its centered text; fades with hasInput. */}
|
||||
|
||||
839
desktop/src/features/onboarding/ui/OnboardingPreviewApp.tsx
Normal file
839
desktop/src/features/onboarding/ui/OnboardingPreviewApp.tsx
Normal file
@@ -0,0 +1,839 @@
|
||||
import { Check, RotateCcw, ShieldCheck } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { JoinPolicy } from "@/shared/api/invites";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
|
||||
import { BackupStep } from "./BackupStep";
|
||||
import { JoinPolicyNotice } from "./JoinPolicyNotice";
|
||||
import { LandingBees } from "./LandingBees";
|
||||
import { NostrKeyImportForm } from "./NostrKeyImportForm";
|
||||
import {
|
||||
ONBOARDING_LANDING_CTA_CLASS,
|
||||
ONBOARDING_PRIMARY_CTA_CLASS,
|
||||
ONBOARDING_SECONDARY_CTA_CLASS,
|
||||
OnboardingChrome,
|
||||
} from "./OnboardingChrome";
|
||||
import { OnboardingFooter, OnboardingFooterProvider } from "./OnboardingFooter";
|
||||
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
|
||||
import { SetupStepPreview } from "./SetupStepPreview";
|
||||
|
||||
type PreviewPage =
|
||||
| "landing"
|
||||
| "email"
|
||||
| "identity-key"
|
||||
| "sign-in"
|
||||
| "sign-in-key"
|
||||
| "forgot-password"
|
||||
| "setup";
|
||||
|
||||
const EMAIL_SIGNUP_POLICY: JoinPolicy = {
|
||||
ageAttestationRequired: true,
|
||||
privacyMarkdown: "available",
|
||||
termsMarkdown: "available",
|
||||
version: "email-signup-workshop",
|
||||
};
|
||||
|
||||
function PreviewBanner({ onRestart }: { onRestart: () => void }) {
|
||||
return (
|
||||
<aside
|
||||
className="fixed right-4 top-4 z-[100] flex max-w-sm items-center gap-3 rounded-2xl border border-foreground/15 bg-background/95 px-4 py-3 text-left shadow-lg backdrop-blur"
|
||||
data-testid="onboarding-preview-banner"
|
||||
>
|
||||
<ShieldCheck className="h-5 w-5 shrink-0 text-primary" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">Workshop preview</p>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
Changes in this workshop aren’t saved.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Restart onboarding preview"
|
||||
className="h-8 w-8 shrink-0"
|
||||
data-testid="onboarding-preview-restart"
|
||||
onClick={onRestart}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Landing({ onNavigate }: { onNavigate: (page: PreviewPage) => void }) {
|
||||
return (
|
||||
<div
|
||||
className="buzz-onboarding-neutral-theme buzz-startup-shell buzz-onboarding-welcome flex max-h-dvh items-start justify-center overflow-hidden px-4 py-8 text-foreground"
|
||||
data-testid="onboarding-preview-landing"
|
||||
>
|
||||
<StartupWindowDragRegion />
|
||||
<LandingBees />
|
||||
<OnboardingFooterProvider>
|
||||
<div className="relative my-auto flex w-full max-w-[720px] flex-col items-center text-center">
|
||||
<OnboardingSlideTransition
|
||||
className="flex w-full max-w-[720px] flex-col items-center text-center"
|
||||
transitionKey="preview-landing"
|
||||
>
|
||||
<img
|
||||
alt="Buzz"
|
||||
className="w-full max-w-[600px]"
|
||||
src="/landing/buzz-wordmark.png"
|
||||
/>
|
||||
<p className="mt-2 max-w-[560px] text-center text-2xl font-normal leading-none text-foreground">
|
||||
Your people, your agents, your projects —<br />
|
||||
all in one place.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col items-center gap-3">
|
||||
<Button
|
||||
className={ONBOARDING_LANDING_CTA_CLASS}
|
||||
onClick={() => onNavigate("email")}
|
||||
type="button"
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
<Button
|
||||
className={`${ONBOARDING_SECONDARY_CTA_CLASS} px-5`}
|
||||
onClick={() => onNavigate("sign-in")}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
<OnboardingFooter className="max-w-none">
|
||||
<Button
|
||||
className="text-foreground/70 hover:text-foreground"
|
||||
data-testid="onboarding-preview-without-email"
|
||||
onClick={() => onNavigate("identity-key")}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
Sign up without email
|
||||
</Button>
|
||||
</OnboardingFooter>
|
||||
</OnboardingSlideTransition>
|
||||
</div>
|
||||
</OnboardingFooterProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewStep({
|
||||
children,
|
||||
current = 2,
|
||||
onBack,
|
||||
testId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
current?: number;
|
||||
onBack: () => void;
|
||||
testId: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-x-hidden overflow-y-auto px-4 pb-28 pt-[106px] text-foreground"
|
||||
data-testid={testId}
|
||||
>
|
||||
<StartupWindowDragRegion />
|
||||
<OnboardingChrome current={current} />
|
||||
<OnboardingFooterProvider backAction={{ onClick: onBack }}>
|
||||
<div className="buzz-onboarding-step-frame relative flex w-full max-w-[1040px] flex-col items-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</OnboardingFooterProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PreviewPasswordStrength = "empty" | "weak" | "moderate" | "strong";
|
||||
|
||||
const strengthLabelPhraseVariants = {
|
||||
animate: { transition: { staggerChildren: 0.008 } },
|
||||
exit: { transition: { staggerChildren: 0.005 } },
|
||||
initial: {},
|
||||
};
|
||||
|
||||
const strengthLabelCharacterVariants = {
|
||||
animate: {
|
||||
filter: "blur(0)",
|
||||
opacity: 1,
|
||||
transition: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const },
|
||||
y: 0,
|
||||
},
|
||||
exit: {
|
||||
filter: "blur(0.25rem)",
|
||||
opacity: 0,
|
||||
transition: { duration: 0.16, ease: [0.64, 0, 0.78, 0] as const },
|
||||
y: "-0.5rem",
|
||||
},
|
||||
initial: { filter: "blur(0.25rem)", opacity: 0, y: "0.5rem" },
|
||||
};
|
||||
|
||||
function previewPasswordStrength(password: string): PreviewPasswordStrength {
|
||||
if (password.length === 0) return "empty";
|
||||
if (password.length < 8) return "weak";
|
||||
if (password.length < 15) return "moderate";
|
||||
return "strong";
|
||||
}
|
||||
|
||||
function PasswordStrengthMeter({
|
||||
descriptionId,
|
||||
password,
|
||||
}: {
|
||||
descriptionId: string;
|
||||
password: string;
|
||||
}) {
|
||||
const strength = previewPasswordStrength(password);
|
||||
const reduceMotion = useReducedMotion() ?? false;
|
||||
const activeCount =
|
||||
strength === "strong"
|
||||
? 3
|
||||
: strength === "moderate"
|
||||
? 2
|
||||
: strength === "weak"
|
||||
? 1
|
||||
: 0;
|
||||
const label =
|
||||
strength === "empty"
|
||||
? "Empty"
|
||||
: strength === "weak"
|
||||
? "Weak"
|
||||
: strength === "moderate"
|
||||
? "Moderate"
|
||||
: "Strong";
|
||||
const labelCharacters = React.useMemo(() => {
|
||||
const occurrences = new Map<string, number>();
|
||||
return [...label].map((character) => {
|
||||
const occurrence = occurrences.get(character) ?? 0;
|
||||
occurrences.set(character, occurrence + 1);
|
||||
return { character, key: `${character}-${occurrence}` };
|
||||
});
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-4 top-1/2 flex -translate-y-1/2 items-center gap-2 text-xs font-semibold transition-colors duration-150 motion-reduce:transition-none",
|
||||
strength === "weak" && "text-red-700",
|
||||
strength === "moderate" && "text-amber-700",
|
||||
strength === "strong" && "text-green-700",
|
||||
strength === "empty" && "text-transparent",
|
||||
)}
|
||||
data-strength={strength}
|
||||
data-testid="onboarding-preview-password-strength"
|
||||
>
|
||||
<span className="relative flex h-5 w-[4.75rem] items-center justify-end overflow-visible text-right leading-none">
|
||||
{reduceMotion ? (
|
||||
<span>{strength === "empty" ? "" : label}</span>
|
||||
) : (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{strength === "empty" ? null : (
|
||||
<motion.span
|
||||
animate="animate"
|
||||
className="absolute inset-x-0 top-1/2 -translate-y-1/2 whitespace-nowrap"
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
key={label}
|
||||
variants={strengthLabelPhraseVariants}
|
||||
>
|
||||
{labelCharacters.map(({ character, key }) => (
|
||||
<motion.span
|
||||
className="inline-block will-change-[transform,opacity,filter]"
|
||||
key={`${label}-${key}`}
|
||||
variants={strengthLabelCharacterVariants}
|
||||
>
|
||||
{character}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"relative flex h-8 w-5 flex-col items-center justify-center gap-0.5",
|
||||
strength === "empty" && "opacity-0",
|
||||
)}
|
||||
data-testid="onboarding-preview-password-strength-indicator"
|
||||
>
|
||||
{[3, 2, 1].map((level) => {
|
||||
const isTopDot = level === 3;
|
||||
const positionClass =
|
||||
level === 3
|
||||
? strength === "strong"
|
||||
? "top-1/2 -translate-y-1/2"
|
||||
: "top-1"
|
||||
: level === 2
|
||||
? "top-[13px]"
|
||||
: "top-[22px]";
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-1/2 flex -translate-x-1/2 items-center justify-center overflow-hidden transition-[top,width,height,border-radius,background-color,opacity,transform] duration-150 ease-in-out motion-reduce:transition-none",
|
||||
positionClass,
|
||||
isTopDot && strength === "strong"
|
||||
? "h-5 w-5 rounded-full bg-green-600"
|
||||
: "h-1.5 w-1.5 rounded-full",
|
||||
!isTopDot && strength === "strong"
|
||||
? level === 2
|
||||
? "translate-y-1 scale-0 opacity-0"
|
||||
: "-translate-y-1.5 scale-0 opacity-0"
|
||||
: "scale-100 opacity-100",
|
||||
strength !== "strong" &&
|
||||
strength !== "empty" &&
|
||||
activeCount >= level &&
|
||||
"bg-amber-500",
|
||||
strength === "weak" && activeCount >= level && "bg-red-500",
|
||||
strength !== "strong" &&
|
||||
activeCount < level &&
|
||||
"bg-foreground/20",
|
||||
)}
|
||||
data-level={level}
|
||||
key={level}
|
||||
>
|
||||
{isTopDot ? (
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 text-white transition-[opacity,transform] duration-120 ease-in-out motion-reduce:transition-none",
|
||||
strength === "strong"
|
||||
? "scale-100 opacity-100"
|
||||
: "scale-75 opacity-0",
|
||||
)}
|
||||
data-testid="onboarding-preview-password-strength-check"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
aria-atomic="true"
|
||||
aria-live="polite"
|
||||
className="sr-only"
|
||||
id={descriptionId}
|
||||
role="status"
|
||||
>
|
||||
Password strength: {label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewCredentialsFields({
|
||||
email,
|
||||
emailId,
|
||||
onEmailChange,
|
||||
onPasswordChange,
|
||||
onConfirmPasswordChange,
|
||||
confirmPassword,
|
||||
password,
|
||||
passwordAutoComplete,
|
||||
passwordHelp,
|
||||
passwordId,
|
||||
passwordPlaceholder,
|
||||
showPasswordStrength = false,
|
||||
}: {
|
||||
email: string;
|
||||
emailId: string;
|
||||
onEmailChange: (value: string) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onConfirmPasswordChange?: (value: string) => void;
|
||||
confirmPassword?: string;
|
||||
password: string;
|
||||
passwordAutoComplete: "current-password" | "new-password";
|
||||
passwordHelp?: React.ReactNode;
|
||||
passwordId: string;
|
||||
passwordPlaceholder: string;
|
||||
showPasswordStrength?: boolean;
|
||||
}) {
|
||||
const passwordStrengthDescriptionId = React.useId();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
className="mb-2 block text-sm font-medium text-foreground"
|
||||
htmlFor={emailId}
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
className="h-12 rounded-2xl border-foreground/15 bg-white px-4"
|
||||
id={emailId}
|
||||
onChange={(event) => onEmailChange(event.target.value)}
|
||||
placeholder="Enter your email address"
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className="mb-2 block text-sm font-medium text-foreground"
|
||||
htmlFor={passwordId}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
aria-describedby={
|
||||
showPasswordStrength ? passwordStrengthDescriptionId : undefined
|
||||
}
|
||||
autoComplete={passwordAutoComplete}
|
||||
className={cn(
|
||||
"h-12 rounded-2xl border-foreground/15 bg-white px-4",
|
||||
showPasswordStrength ? "pr-28" : "pr-4",
|
||||
)}
|
||||
id={passwordId}
|
||||
onChange={(event) => onPasswordChange(event.target.value)}
|
||||
placeholder={passwordPlaceholder}
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
{showPasswordStrength ? (
|
||||
<PasswordStrengthMeter
|
||||
descriptionId={passwordStrengthDescriptionId}
|
||||
password={password}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{passwordHelp ? (
|
||||
<div className="mt-2 text-left">{passwordHelp}</div>
|
||||
) : null}
|
||||
{onConfirmPasswordChange ? (
|
||||
<div className="mt-5">
|
||||
<label
|
||||
className="mb-2 block text-sm font-medium text-foreground"
|
||||
htmlFor={`${passwordId}-confirmation`}
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
className="h-12 rounded-2xl border-foreground/15 bg-white px-4"
|
||||
id={`${passwordId}-confirmation`}
|
||||
onChange={(event) => onConfirmPasswordChange(event.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
type="password"
|
||||
value={confirmPassword ?? ""}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailSignup({
|
||||
password,
|
||||
onBack,
|
||||
onContinue,
|
||||
onPasswordChange,
|
||||
}: {
|
||||
password: string;
|
||||
onBack: () => void;
|
||||
onContinue: (email: string) => void;
|
||||
onPasswordChange: (password: string) => void;
|
||||
}) {
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("");
|
||||
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
|
||||
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
|
||||
// This workshop never creates an account, so it deliberately permits the
|
||||
// next preview step without collecting real credentials or consent.
|
||||
const canContinue = true;
|
||||
|
||||
return (
|
||||
<PreviewStep onBack={onBack} testId="onboarding-preview-email">
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
|
||||
transitionKey="preview-email"
|
||||
>
|
||||
<h1 className="text-title font-normal text-foreground">
|
||||
Create an account
|
||||
</h1>
|
||||
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
|
||||
Enter your email address to get started.
|
||||
</p>
|
||||
<form
|
||||
className="mt-8 w-full space-y-5 text-left"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (canContinue) onContinue(email);
|
||||
}}
|
||||
>
|
||||
<PreviewCredentialsFields
|
||||
email={email}
|
||||
emailId="onboarding-preview-email-address"
|
||||
onEmailChange={setEmail}
|
||||
onPasswordChange={onPasswordChange}
|
||||
confirmPassword={confirmPassword}
|
||||
onConfirmPasswordChange={setConfirmPassword}
|
||||
password={password}
|
||||
passwordAutoComplete="new-password"
|
||||
passwordId="onboarding-preview-password"
|
||||
passwordPlaceholder="Choose your password"
|
||||
showPasswordStrength
|
||||
/>
|
||||
<JoinPolicyNotice
|
||||
ageConfirmed={ageConfirmed}
|
||||
agreementConfirmed={agreementConfirmed}
|
||||
onAgeConfirmedChange={setAgeConfirmed}
|
||||
onAgreementConfirmedChange={setAgreementConfirmed}
|
||||
onOpenDocument={() => undefined}
|
||||
policy={EMAIL_SIGNUP_POLICY}
|
||||
relayWsUrl="wss://preview.invalid"
|
||||
textTone="foreground"
|
||||
/>
|
||||
<OnboardingFooter>
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-preview-email-continue"
|
||||
disabled={!canContinue}
|
||||
onClick={() => onContinue(email)}
|
||||
type="button"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</OnboardingFooter>
|
||||
</form>
|
||||
</OnboardingSlideTransition>
|
||||
</PreviewStep>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentHarnessStep({
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PreviewStep current={3} onBack={onBack} testId="onboarding-preview-setup">
|
||||
<SetupStepPreview onBack={onBack} onNext={onNext} />
|
||||
</PreviewStep>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailSignIn({
|
||||
email,
|
||||
onBack,
|
||||
onContinue,
|
||||
onEmailChange,
|
||||
onForgotPassword,
|
||||
onSignInWithKey,
|
||||
}: {
|
||||
email: string;
|
||||
onBack: () => void;
|
||||
onContinue: () => void;
|
||||
onEmailChange: (email: string) => void;
|
||||
onForgotPassword: () => void;
|
||||
onSignInWithKey: () => void;
|
||||
}) {
|
||||
const [password, setPassword] = React.useState("");
|
||||
const canContinue = email.trim().length > 0 && password.length > 0;
|
||||
|
||||
return (
|
||||
<PreviewStep onBack={onBack} testId="onboarding-preview-sign-in">
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
|
||||
transitionKey="preview-sign-in"
|
||||
>
|
||||
<h1 className="text-title font-normal text-foreground">Sign in</h1>
|
||||
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
|
||||
Enter your email address and password to sign in.
|
||||
</p>
|
||||
<form
|
||||
className="mt-8 w-full space-y-5 text-left"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (canContinue) onContinue();
|
||||
}}
|
||||
>
|
||||
<PreviewCredentialsFields
|
||||
email={email}
|
||||
emailId="onboarding-preview-sign-in-email"
|
||||
onEmailChange={onEmailChange}
|
||||
onPasswordChange={setPassword}
|
||||
password={password}
|
||||
passwordAutoComplete="current-password"
|
||||
passwordHelp={
|
||||
<Button
|
||||
className="h-auto justify-start p-0 text-xs font-medium text-foreground/70 no-underline hover:text-foreground hover:underline focus-visible:text-foreground focus-visible:underline"
|
||||
onClick={onForgotPassword}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
}
|
||||
passwordId="onboarding-preview-sign-in-password"
|
||||
passwordPlaceholder="Enter your password"
|
||||
/>
|
||||
<div className="flex w-full flex-col items-center gap-3 pt-1">
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-preview-sign-in-submit"
|
||||
disabled={!canContinue}
|
||||
onClick={onContinue}
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex w-full max-w-64 items-center gap-3 text-xs text-foreground/70"
|
||||
>
|
||||
<span className="h-px flex-1 bg-foreground/20" />
|
||||
<span>or</span>
|
||||
<span className="h-px flex-1 bg-foreground/20" />
|
||||
</div>
|
||||
<Button
|
||||
className={ONBOARDING_SECONDARY_CTA_CLASS}
|
||||
onClick={onSignInWithKey}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Sign in with a key
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</OnboardingSlideTransition>
|
||||
</PreviewStep>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordReset({
|
||||
initialEmail,
|
||||
onBack,
|
||||
}: {
|
||||
initialEmail: string;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [email, setEmail] = React.useState(initialEmail);
|
||||
const [submitted, setSubmitted] = React.useState(false);
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<PreviewStep
|
||||
onBack={onBack}
|
||||
testId="onboarding-preview-password-reset-sent"
|
||||
>
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
|
||||
transitionKey="preview-password-reset-sent"
|
||||
>
|
||||
<h1 className="text-title font-normal text-foreground">
|
||||
Check your email
|
||||
</h1>
|
||||
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
|
||||
Use the link we sent to {email.trim()} to reset your password.
|
||||
</p>
|
||||
</OnboardingSlideTransition>
|
||||
</PreviewStep>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewStep onBack={onBack} testId="onboarding-preview-password-reset">
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
|
||||
transitionKey="preview-password-reset"
|
||||
>
|
||||
<h1 className="text-title font-normal text-foreground">
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
|
||||
Enter your email address and we'll send you a link to reset your
|
||||
password.
|
||||
</p>
|
||||
<form
|
||||
className="mt-8 w-full text-left"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (email.trim()) setSubmitted(true);
|
||||
}}
|
||||
>
|
||||
<label
|
||||
className="mb-2 block text-sm font-medium text-foreground"
|
||||
htmlFor="onboarding-preview-password-reset-email"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
className="h-12 rounded-2xl border-foreground/15 bg-white px-4"
|
||||
id="onboarding-preview-password-reset-email"
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="Enter your email address"
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
<OnboardingFooter>
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
disabled={!email.trim()}
|
||||
onClick={() => setSubmitted(true)}
|
||||
type="button"
|
||||
>
|
||||
Send reset link
|
||||
</Button>
|
||||
</OnboardingFooter>
|
||||
</form>
|
||||
</OnboardingSlideTransition>
|
||||
</PreviewStep>
|
||||
);
|
||||
}
|
||||
|
||||
function KeySignIn({
|
||||
onBack,
|
||||
onContinue,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onContinue: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PreviewStep onBack={onBack} testId="onboarding-preview-sign-in-key">
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-[calc(100dvh-13.25rem)] w-full max-w-[560px] flex-col items-center justify-center"
|
||||
transitionKey="preview-sign-in-key"
|
||||
>
|
||||
<h1 className="text-title font-normal text-foreground">
|
||||
Enter your private key
|
||||
</h1>
|
||||
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
|
||||
Paste your private key to sign in to Buzz. You can also use a backup
|
||||
file, or recover from your phone.
|
||||
</p>
|
||||
<div className="buzz-onboarding-key-import-position w-full">
|
||||
<NostrKeyImportForm
|
||||
onBack={onBack}
|
||||
onImport={async () => onContinue()}
|
||||
previewMode
|
||||
showBack={false}
|
||||
variant="spotlight"
|
||||
/>
|
||||
</div>
|
||||
</OnboardingSlideTransition>
|
||||
</PreviewStep>
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingPreviewApp() {
|
||||
const [run, setRun] = React.useState(0);
|
||||
const [page, setPage] = React.useState<PreviewPage>("landing");
|
||||
const [signInEmail, setSignInEmail] = React.useState("");
|
||||
const [signupPassword, setSignupPassword] = React.useState("");
|
||||
const [passwordResetEmail, setPasswordResetEmail] = React.useState("");
|
||||
const [setupBackPage, setSetupBackPage] =
|
||||
React.useState<PreviewPage>("landing");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (page === "landing") {
|
||||
setPasswordResetEmail("");
|
||||
setSignInEmail("");
|
||||
setSignupPassword("");
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
const restart = React.useCallback(() => {
|
||||
setPage("landing");
|
||||
setPasswordResetEmail("");
|
||||
setRun((current) => current + 1);
|
||||
setSignInEmail("");
|
||||
setSignupPassword("");
|
||||
}, []);
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (page === "landing") {
|
||||
content = <Landing key={run} onNavigate={setPage} />;
|
||||
} else if (page === "email") {
|
||||
content = (
|
||||
<EmailSignup
|
||||
password={signupPassword}
|
||||
onBack={() => setPage("landing")}
|
||||
onContinue={() => {
|
||||
setSetupBackPage("email");
|
||||
setPage("setup");
|
||||
}}
|
||||
onPasswordChange={setSignupPassword}
|
||||
/>
|
||||
);
|
||||
} else if (page === "identity-key") {
|
||||
content = (
|
||||
<PreviewStep
|
||||
onBack={() => setPage("landing")}
|
||||
testId="onboarding-preview-identity-key"
|
||||
>
|
||||
<BackupStep
|
||||
direction="forward"
|
||||
onNext={() => {
|
||||
setSetupBackPage("identity-key");
|
||||
setPage("setup");
|
||||
}}
|
||||
onOpenPasswordBackup={() => undefined}
|
||||
onShowOptions={() => undefined}
|
||||
optionsExpanded={false}
|
||||
previewMode
|
||||
returningFromSecurity={false}
|
||||
/>
|
||||
</PreviewStep>
|
||||
);
|
||||
} else if (page === "sign-in") {
|
||||
content = (
|
||||
<EmailSignIn
|
||||
email={signInEmail}
|
||||
onBack={() => setPage("landing")}
|
||||
onContinue={() => {
|
||||
setSetupBackPage("sign-in");
|
||||
setPage("setup");
|
||||
}}
|
||||
onEmailChange={setSignInEmail}
|
||||
onForgotPassword={() => {
|
||||
setPasswordResetEmail(signInEmail);
|
||||
setPage("forgot-password");
|
||||
}}
|
||||
onSignInWithKey={() => setPage("sign-in-key")}
|
||||
/>
|
||||
);
|
||||
} else if (page === "forgot-password") {
|
||||
content = (
|
||||
<PasswordReset
|
||||
initialEmail={passwordResetEmail}
|
||||
onBack={() => setPage("sign-in")}
|
||||
/>
|
||||
);
|
||||
} else if (page === "sign-in-key") {
|
||||
content = (
|
||||
<KeySignIn
|
||||
onBack={() => setPage("sign-in")}
|
||||
onContinue={() => {
|
||||
setSetupBackPage("sign-in-key");
|
||||
setPage("setup");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<AgentHarnessStep
|
||||
onBack={() => setPage(setupBackPage)}
|
||||
onNext={() => undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PreviewBanner onRestart={restart} />
|
||||
{content}
|
||||
</>
|
||||
);
|
||||
}
|
||||
301
desktop/src/features/onboarding/ui/SetupRuntimeCard.tsx
Normal file
301
desktop/src/features/onboarding/ui/SetupRuntimeCard.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { Check } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
|
||||
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { runtimeIsReadyForOnboarding } from "./onboardingRuntimeSelection";
|
||||
import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon";
|
||||
|
||||
function RuntimeReadinessIndicator({
|
||||
runtime,
|
||||
ready,
|
||||
}: {
|
||||
runtime: AcpRuntimeCatalogEntry;
|
||||
ready: boolean;
|
||||
}) {
|
||||
// Checkmark temporarily hidden; flip to true to restore it.
|
||||
const showReadinessCheckmark = false;
|
||||
if (!ready || !showReadinessCheckmark) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-8 top-8 flex h-8 w-8 items-center justify-center rounded-full border border-[var(--buzz-welcome-chartreuse)] bg-[var(--buzz-welcome-chartreuse)]"
|
||||
data-testid={`onboarding-runtime-check-${runtime.id}`}
|
||||
>
|
||||
<Check
|
||||
className="h-4 w-4 text-foreground"
|
||||
data-testid={`onboarding-runtime-checkmark-${runtime.id}`}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
|
||||
if (
|
||||
runtime.availability === "available" &&
|
||||
runtime.command &&
|
||||
runtime.binaryPath
|
||||
) {
|
||||
const description = describeResolvedCommand(
|
||||
runtime.command,
|
||||
runtime.binaryPath,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs leading-4 text-white">
|
||||
{description.charAt(0).toUpperCase() + description.slice(1)}
|
||||
</p>
|
||||
{runtime.defaultArgs.length > 0 ? (
|
||||
<p className="mt-1 text-xs leading-4 text-white">
|
||||
Args:{" "}
|
||||
<code className="font-mono">{runtime.defaultArgs.join(", ")}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtime.availability === "adapter_missing") {
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs leading-4 text-white">
|
||||
CLI detected; ACP adapter missing.
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-white">
|
||||
{runtime.installHint}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtime.availability === "adapter_outdated") {
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs leading-4 text-white">
|
||||
ACP adapter detected but outdated — reinstall required.
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-white">
|
||||
This updates the machine-global{" "}
|
||||
<code className="rounded bg-white/10 px-0.5 font-mono text-xs text-white">
|
||||
codex-acp
|
||||
</code>{" "}
|
||||
adapter. Older Buzz releases using the legacy adapter contract may
|
||||
lose community access until{" "}
|
||||
<code className="rounded bg-white/10 px-0.5 font-mono text-xs text-white">
|
||||
@zed-industries/codex-acp@0.16.0
|
||||
</code>{" "}
|
||||
is restored.
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-white">
|
||||
{runtime.installHint}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtime.availability === "cli_missing") {
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs leading-4 text-white">
|
||||
ACP adapter detected; CLI missing.
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-white">
|
||||
{runtime.installHint}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs leading-4 text-white">Not installed yet.</p>
|
||||
<p className="mt-1 text-xs leading-4 text-white">{runtime.installHint}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string {
|
||||
if (
|
||||
runtime.availability === "available" &&
|
||||
runtime.command &&
|
||||
runtime.binaryPath
|
||||
) {
|
||||
const description = describeResolvedCommand(
|
||||
runtime.command,
|
||||
runtime.binaryPath,
|
||||
);
|
||||
return description.charAt(0).toUpperCase() + description.slice(1);
|
||||
}
|
||||
if (runtime.availability === "adapter_missing") {
|
||||
return "CLI detected; ACP adapter missing.";
|
||||
}
|
||||
if (runtime.availability === "adapter_outdated") {
|
||||
return "ACP adapter detected but outdated — reinstall required.";
|
||||
}
|
||||
if (
|
||||
runtime.availability === "cli_missing" ||
|
||||
runtime.availability === "not_installed"
|
||||
) {
|
||||
return "CLI not detected.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function SetupRuntimeAction({
|
||||
ariaLabel,
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
testId,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={ariaLabel}
|
||||
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupRuntimeInstallingStatus({
|
||||
runtime,
|
||||
}: {
|
||||
runtime: AcpRuntimeCatalogEntry;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-label={`Installing ${runtime.label}`}
|
||||
className="flex h-5 items-center gap-2 rounded-full bg-white/60 px-2.5 font-mono text-badge font-normal uppercase text-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Spinner className="h-3 w-3 border-2 text-foreground" />
|
||||
INSTALLING
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupRuntimeCheckingStatus({
|
||||
runtime,
|
||||
}: {
|
||||
runtime: AcpRuntimeCatalogEntry;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-label={`Rechecking ${runtime.label}`}
|
||||
className="flex h-5 items-center gap-2 rounded-full bg-[#EBEFEF] px-2.5 font-mono text-badge font-normal uppercase text-foreground"
|
||||
data-testid={`onboarding-runtime-rechecking-${runtime.id}`}
|
||||
role="status"
|
||||
>
|
||||
<Spinner className="h-3 w-3 border-2 text-foreground" />
|
||||
CHECKING…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupRuntimeReadyStatus({
|
||||
runtime,
|
||||
}: {
|
||||
runtime: AcpRuntimeCatalogEntry;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex h-5 cursor-default items-center rounded-full bg-[#EBEFEF] px-2.5 font-mono text-badge font-normal uppercase text-foreground"
|
||||
data-testid={`onboarding-runtime-ready-${runtime.id}`}
|
||||
>
|
||||
READY
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="max-w-80 bg-black text-left text-xs text-white shadow-sm"
|
||||
side="top"
|
||||
>
|
||||
<RuntimeDetails runtime={runtime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupRuntimeCardPresentation({
|
||||
footer,
|
||||
installError,
|
||||
installOutputLine,
|
||||
isInstalling,
|
||||
runtime,
|
||||
status,
|
||||
}: {
|
||||
footer?: React.ReactNode;
|
||||
installError: string | null;
|
||||
installOutputLine?: string | null;
|
||||
isInstalling: boolean;
|
||||
runtime: AcpRuntimeCatalogEntry;
|
||||
status: React.ReactNode;
|
||||
}) {
|
||||
const isAvailable = runtime.availability === "available";
|
||||
const isReady = runtimeIsReadyForOnboarding(runtime);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"group h-[224px] w-full max-w-[288px] select-none items-center px-3 py-1.5 text-center",
|
||||
installError && "ring-1 ring-destructive/40",
|
||||
isReady && "brightness-[0.98]",
|
||||
)}
|
||||
data-ready={isReady ? "true" : "false"}
|
||||
data-testid={`onboarding-runtime-${runtime.id}`}
|
||||
variant="textured"
|
||||
>
|
||||
<RuntimeReadinessIndicator ready={isReady} runtime={runtime} />
|
||||
|
||||
<div className="flex min-w-0 flex-col items-center gap-2.5">
|
||||
<div className="flex min-w-0 items-center justify-center gap-3">
|
||||
<RuntimeIcon className="h-7 w-7" runtime={runtime} />
|
||||
<h2 className="truncate text-sm font-normal leading-5 text-foreground">
|
||||
{getRuntimeDisplayLabel(runtime)}
|
||||
</h2>
|
||||
</div>
|
||||
{status}
|
||||
{isInstalling && installOutputLine ? (
|
||||
<p
|
||||
aria-live="polite"
|
||||
className="max-w-[13rem] truncate font-mono text-2xs leading-4 text-muted-foreground"
|
||||
data-testid={`onboarding-runtime-install-output-${runtime.id}`}
|
||||
>
|
||||
{installOutputLine}
|
||||
</p>
|
||||
) : !isAvailable && runtimeDetailText(runtime) ? (
|
||||
<p
|
||||
aria-hidden={installError ? "true" : undefined}
|
||||
className={cn(
|
||||
"max-w-[13rem] text-2xs leading-4 text-muted-foreground",
|
||||
installError && "invisible",
|
||||
)}
|
||||
>
|
||||
{runtimeDetailText(runtime)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{footer}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { Check, Info } from "lucide-react";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import {
|
||||
useAcpAuthMethodsQuery,
|
||||
@@ -9,15 +9,10 @@ import {
|
||||
useInstallAcpRuntimeMutation,
|
||||
} from "@/features/agents/hooks";
|
||||
import { useInstallOutputLine } from "@/features/agents/lib/useInstallOutputLine";
|
||||
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
|
||||
import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types";
|
||||
import { getInstallErrorMessage } from "@/shared/lib/installError";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { FlappingBee } from "@/shared/ui/buzz-logo/FlappingBee";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import {
|
||||
getReadyOnboardingRuntimes,
|
||||
getVisibleOnboardingRuntimes,
|
||||
@@ -26,7 +21,13 @@ import {
|
||||
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
|
||||
import { RuntimeErrorTooltip } from "./RuntimeErrorTooltip";
|
||||
import { OnboardingFooter } from "./OnboardingFooter";
|
||||
import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon";
|
||||
import {
|
||||
SetupRuntimeAction,
|
||||
SetupRuntimeCardPresentation,
|
||||
SetupRuntimeCheckingStatus,
|
||||
SetupRuntimeInstallingStatus,
|
||||
SetupRuntimeReadyStatus,
|
||||
} from "./SetupRuntimeCard";
|
||||
import {
|
||||
type OnboardingTransitionDirection,
|
||||
OnboardingSlideTransition,
|
||||
@@ -40,6 +41,7 @@ type SetupStepProps = {
|
||||
};
|
||||
|
||||
type SetupStepContentProps = SetupStepProps & {
|
||||
renderRuntimeCard?: (runtime: AcpRuntimeCatalogEntry) => React.ReactNode;
|
||||
state: SetupStepState;
|
||||
};
|
||||
|
||||
@@ -66,32 +68,6 @@ function useSetupStepState(): SetupStepState {
|
||||
};
|
||||
}
|
||||
|
||||
function RuntimeReadinessIndicator({
|
||||
runtime,
|
||||
ready,
|
||||
}: {
|
||||
runtime: AcpRuntimeCatalogEntry;
|
||||
ready: boolean;
|
||||
}) {
|
||||
// Checkmark temporarily hidden; flip to true to restore it.
|
||||
const showReadinessCheckmark = false;
|
||||
if (!ready || !showReadinessCheckmark) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-8 top-8 flex h-8 w-8 items-center justify-center rounded-full border border-[var(--buzz-welcome-chartreuse)] bg-[var(--buzz-welcome-chartreuse)]"
|
||||
data-testid={`onboarding-runtime-check-${runtime.id}`}
|
||||
>
|
||||
<Check
|
||||
className="h-4 w-4 text-foreground"
|
||||
data-testid={`onboarding-runtime-checkmark-${runtime.id}`}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeStatus({
|
||||
installError,
|
||||
isInstalling,
|
||||
@@ -153,10 +129,8 @@ function RuntimeStatus({
|
||||
if (shouldSignIn) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<Button
|
||||
aria-label={`Sign in to ${runtime.label}`}
|
||||
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
|
||||
data-testid={`onboarding-runtime-instructions-${runtime.id}`}
|
||||
<SetupRuntimeAction
|
||||
ariaLabel={`Sign in to ${runtime.label}`}
|
||||
onClick={() => {
|
||||
if (didSignInCheckTimeOut) {
|
||||
setDidSignInCheckTimeOut(false);
|
||||
@@ -178,15 +152,14 @@ function RuntimeStatus({
|
||||
},
|
||||
);
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
testId={`onboarding-runtime-instructions-${runtime.id}`}
|
||||
>
|
||||
{isWaitingForSignIn
|
||||
? "CHECKING…"
|
||||
: didSignInCheckTimeOut
|
||||
? "CHECK AGAIN"
|
||||
: "SIGN IN"}
|
||||
</Button>
|
||||
</SetupRuntimeAction>
|
||||
{methodsQuery.error instanceof Error ? (
|
||||
<RuntimeErrorTooltip
|
||||
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
|
||||
@@ -206,16 +179,7 @@ function RuntimeStatus({
|
||||
}
|
||||
|
||||
if (isInstalling) {
|
||||
return (
|
||||
<div
|
||||
aria-label={`Installing ${runtime.label}`}
|
||||
className="flex h-5 items-center gap-2 rounded-full bg-white/60 px-2.5 font-mono text-badge font-normal uppercase text-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Spinner className="h-3 w-3 border-2 text-foreground" />
|
||||
INSTALLING
|
||||
</div>
|
||||
);
|
||||
return <SetupRuntimeInstallingStatus runtime={runtime} />;
|
||||
}
|
||||
|
||||
if (runtimeIsReadyForOnboarding(runtime)) {
|
||||
@@ -227,50 +191,20 @@ function RuntimeStatus({
|
||||
// both clear and READY returns. Next stays gated by isChecking/errorMessage
|
||||
// in SetupStepContent, so this only governs the per-card claim.
|
||||
if (runtimesQuery.isFetching) {
|
||||
return (
|
||||
<div
|
||||
aria-label={`Rechecking ${runtime.label}`}
|
||||
className="flex h-5 items-center gap-2 rounded-full bg-[#EBEFEF] px-2.5 font-mono text-badge font-normal uppercase text-foreground"
|
||||
data-testid={`onboarding-runtime-rechecking-${runtime.id}`}
|
||||
role="status"
|
||||
>
|
||||
<Spinner className="h-3 w-3 border-2 text-foreground" />
|
||||
CHECKING…
|
||||
</div>
|
||||
);
|
||||
return <SetupRuntimeCheckingStatus runtime={runtime} />;
|
||||
}
|
||||
if (runtimesQuery.isError) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={`Check ${runtime.label} again`}
|
||||
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
|
||||
data-testid={`onboarding-runtime-recheck-${runtime.id}`}
|
||||
<SetupRuntimeAction
|
||||
ariaLabel={`Check ${runtime.label} again`}
|
||||
onClick={() => void runtimesQuery.forceRefresh()}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
testId={`onboarding-runtime-recheck-${runtime.id}`}
|
||||
>
|
||||
CHECK AGAIN
|
||||
</Button>
|
||||
</SetupRuntimeAction>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex h-5 cursor-default items-center rounded-full bg-[#EBEFEF] px-2.5 font-mono text-badge font-normal uppercase text-foreground"
|
||||
data-testid={`onboarding-runtime-ready-${runtime.id}`}
|
||||
>
|
||||
READY
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="max-w-80 bg-black text-left text-xs text-white shadow-sm"
|
||||
side="top"
|
||||
>
|
||||
<RuntimeDetails runtime={runtime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
return <SetupRuntimeReadyStatus runtime={runtime} />;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -278,160 +212,40 @@ function RuntimeStatus({
|
||||
runtime.authStatus.status === "unknown"
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={`Check ${runtime.label} again`}
|
||||
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
|
||||
<SetupRuntimeAction
|
||||
ariaLabel={`Check ${runtime.label} again`}
|
||||
disabled={runtimesQuery.isFetching}
|
||||
onClick={() => void runtimesQuery.forceRefresh()}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{runtimesQuery.isFetching ? "CHECKING…" : "CHECK AGAIN"}
|
||||
</Button>
|
||||
</SetupRuntimeAction>
|
||||
);
|
||||
}
|
||||
|
||||
const installLabel = installError ? "RETRY INSTALL" : "INSTALL";
|
||||
if (runtime.canAutoInstall) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={`${installError ? "Retry installing" : "Install"} ${runtime.label}`}
|
||||
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
|
||||
data-testid={`onboarding-runtime-install-${runtime.id}`}
|
||||
<SetupRuntimeAction
|
||||
ariaLabel={`${installError ? "Retry installing" : "Install"} ${runtime.label}`}
|
||||
onClick={onInstall}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
testId={`onboarding-runtime-install-${runtime.id}`}
|
||||
>
|
||||
{installLabel}
|
||||
</Button>
|
||||
</SetupRuntimeAction>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={`View ${runtime.label} install instructions`}
|
||||
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
|
||||
data-testid={`onboarding-runtime-instructions-${runtime.id}`}
|
||||
<SetupRuntimeAction
|
||||
ariaLabel={`View ${runtime.label} install instructions`}
|
||||
onClick={() => void openUrl(runtime.installInstructionsUrl)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
testId={`onboarding-runtime-instructions-${runtime.id}`}
|
||||
>
|
||||
INSTALL
|
||||
</Button>
|
||||
</SetupRuntimeAction>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
|
||||
if (
|
||||
runtime.availability === "available" &&
|
||||
runtime.command &&
|
||||
runtime.binaryPath
|
||||
) {
|
||||
const description = describeResolvedCommand(
|
||||
runtime.command,
|
||||
runtime.binaryPath,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs leading-4 text-white">
|
||||
{description.charAt(0).toUpperCase() + description.slice(1)}
|
||||
</p>
|
||||
{runtime.defaultArgs.length > 0 ? (
|
||||
<p className="mt-1 text-xs leading-4 text-white">
|
||||
Args:{" "}
|
||||
<code className="font-mono">{runtime.defaultArgs.join(", ")}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtime.availability === "adapter_missing") {
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs leading-4 text-white">
|
||||
CLI detected; ACP adapter missing.
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-white">
|
||||
{runtime.installHint}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtime.availability === "adapter_outdated") {
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs leading-4 text-white">
|
||||
ACP adapter detected but outdated — reinstall required.
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-white">
|
||||
This updates the machine-global{" "}
|
||||
<code className="rounded bg-white/10 px-0.5 font-mono text-xs text-white">
|
||||
codex-acp
|
||||
</code>{" "}
|
||||
adapter. Older Buzz releases using the legacy adapter contract may
|
||||
lose community access until{" "}
|
||||
<code className="rounded bg-white/10 px-0.5 font-mono text-xs text-white">
|
||||
@zed-industries/codex-acp@0.16.0
|
||||
</code>{" "}
|
||||
is restored.
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-white">
|
||||
{runtime.installHint}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtime.availability === "cli_missing") {
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs leading-4 text-white">
|
||||
ACP adapter detected; CLI missing.
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-white">
|
||||
{runtime.installHint}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs leading-4 text-white">Not installed yet.</p>
|
||||
<p className="mt-1 text-xs leading-4 text-white">{runtime.installHint}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string {
|
||||
if (
|
||||
runtime.availability === "available" &&
|
||||
runtime.command &&
|
||||
runtime.binaryPath
|
||||
) {
|
||||
const description = describeResolvedCommand(
|
||||
runtime.command,
|
||||
runtime.binaryPath,
|
||||
);
|
||||
return description.charAt(0).toUpperCase() + description.slice(1);
|
||||
}
|
||||
if (runtime.availability === "adapter_missing") {
|
||||
return "CLI detected; ACP adapter missing.";
|
||||
}
|
||||
if (runtime.availability === "adapter_outdated") {
|
||||
return "ACP adapter detected but outdated — reinstall required.";
|
||||
}
|
||||
if (
|
||||
runtime.availability === "cli_missing" ||
|
||||
runtime.availability === "not_installed"
|
||||
) {
|
||||
return "CLI not detected.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function isSupportedOnboardingAuthMethod(
|
||||
runtime: AcpRuntimeCatalogEntry,
|
||||
method: AcpAuthMethod,
|
||||
@@ -523,8 +337,6 @@ function RuntimeCard({
|
||||
const installError = installResults[runtime.id]?.error ?? null;
|
||||
const isInstalling = installMutation.isPending;
|
||||
const installOutputLine = useInstallOutputLine(runtime.id, isInstalling);
|
||||
const isAvailable = runtime.availability === "available";
|
||||
const isReady = runtimeIsReadyForOnboarding(runtime);
|
||||
|
||||
function handleInstall() {
|
||||
onInstallResultsChange((current) => ({
|
||||
@@ -557,66 +369,33 @@ function RuntimeCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"group h-[224px] w-full max-w-[288px] select-none items-center px-3 py-1.5 text-center",
|
||||
installError && "ring-1 ring-destructive/40",
|
||||
isReady && "brightness-[0.98]",
|
||||
)}
|
||||
data-ready={isReady ? "true" : "false"}
|
||||
data-testid={`onboarding-runtime-${runtime.id}`}
|
||||
variant="textured"
|
||||
>
|
||||
<RuntimeReadinessIndicator ready={isReady} runtime={runtime} />
|
||||
|
||||
<div className="flex min-w-0 flex-col items-center gap-2.5">
|
||||
<div className="flex min-w-0 items-center justify-center gap-3">
|
||||
<RuntimeIcon className="h-7 w-7" runtime={runtime} />
|
||||
<h2 className="truncate text-sm font-normal leading-5 text-foreground">
|
||||
{getRuntimeDisplayLabel(runtime)}
|
||||
</h2>
|
||||
</div>
|
||||
<SetupRuntimeCardPresentation
|
||||
footer={
|
||||
installError ? (
|
||||
<RuntimeErrorTooltip
|
||||
className="absolute inset-x-3 bottom-2 flex min-w-0 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-xs leading-4 text-destructive"
|
||||
detail={installError}
|
||||
label="Installation failed"
|
||||
showIcon
|
||||
testId={`onboarding-runtime-error-${runtime.id}`}
|
||||
/>
|
||||
) : (
|
||||
<RuntimeAuthError runtime={runtime} />
|
||||
)
|
||||
}
|
||||
installError={installError}
|
||||
installOutputLine={installOutputLine}
|
||||
isInstalling={isInstalling}
|
||||
runtime={runtime}
|
||||
status={
|
||||
<RuntimeStatus
|
||||
installError={installError}
|
||||
isInstalling={isInstalling}
|
||||
onInstall={handleInstall}
|
||||
runtime={runtime}
|
||||
/>
|
||||
{isInstalling && installOutputLine ? (
|
||||
// Takes the detail text's slot rather than adding a row: the card is
|
||||
// fixed-height, and during an install the live line is the more
|
||||
// useful of the two.
|
||||
<p
|
||||
aria-live="polite"
|
||||
className="max-w-[13rem] truncate font-mono text-2xs leading-4 text-muted-foreground"
|
||||
data-testid={`onboarding-runtime-install-output-${runtime.id}`}
|
||||
>
|
||||
{installOutputLine}
|
||||
</p>
|
||||
) : !isAvailable && runtimeDetailText(runtime) ? (
|
||||
<p
|
||||
aria-hidden={installError ? "true" : undefined}
|
||||
className={cn(
|
||||
"max-w-[13rem] text-2xs leading-4 text-muted-foreground",
|
||||
installError && "invisible",
|
||||
)}
|
||||
>
|
||||
{runtimeDetailText(runtime)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{installError ? (
|
||||
<RuntimeErrorTooltip
|
||||
className="absolute inset-x-3 bottom-2 flex min-w-0 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-xs leading-4 text-destructive"
|
||||
detail={installError}
|
||||
label="Installation failed"
|
||||
showIcon
|
||||
testId={`onboarding-runtime-error-${runtime.id}`}
|
||||
/>
|
||||
) : (
|
||||
<RuntimeAuthError runtime={runtime} />
|
||||
)}
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -642,6 +421,7 @@ function RuntimeProvidersSection({
|
||||
installResults,
|
||||
navigateToAgentSettings,
|
||||
onInstallResultsChange,
|
||||
renderRuntimeCard,
|
||||
runtimeProviders,
|
||||
}: {
|
||||
installResults: InstallResultsState;
|
||||
@@ -649,6 +429,7 @@ function RuntimeProvidersSection({
|
||||
onInstallResultsChange: React.Dispatch<
|
||||
React.SetStateAction<InstallResultsState>
|
||||
>;
|
||||
renderRuntimeCard?: (runtime: AcpRuntimeCatalogEntry) => React.ReactNode;
|
||||
runtimeProviders: SetupStepState["runtimeProviders"];
|
||||
}) {
|
||||
const { errorMessage, isChecking, items } = runtimeProviders;
|
||||
@@ -669,14 +450,20 @@ function RuntimeProvidersSection({
|
||||
<div className="flex w-full flex-1 flex-col items-center justify-center gap-8 py-10">
|
||||
{orderedItems.length > 0 ? (
|
||||
<div className="grid min-w-0 w-full max-w-[1200px] grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{orderedItems.map((runtime) => (
|
||||
<RuntimeCard
|
||||
installResults={installResults}
|
||||
key={runtime.id}
|
||||
onInstallResultsChange={onInstallResultsChange}
|
||||
runtime={runtime}
|
||||
/>
|
||||
))}
|
||||
{orderedItems.map((runtime) =>
|
||||
renderRuntimeCard ? (
|
||||
<React.Fragment key={runtime.id}>
|
||||
{renderRuntimeCard(runtime)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<RuntimeCard
|
||||
installResults={installResults}
|
||||
key={runtime.id}
|
||||
onInstallResultsChange={onInstallResultsChange}
|
||||
runtime={runtime}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : isChecking ? (
|
||||
<RuntimeProvidersLoadingState />
|
||||
@@ -723,10 +510,11 @@ function RuntimeProvidersSection({
|
||||
);
|
||||
}
|
||||
|
||||
function SetupStepContent({
|
||||
export function SetupStepContent({
|
||||
actions,
|
||||
direction,
|
||||
onReadyRuntimeIdsChange,
|
||||
renderRuntimeCard,
|
||||
state,
|
||||
}: SetupStepContentProps) {
|
||||
const { runtimeProviders } = state;
|
||||
@@ -758,6 +546,7 @@ function SetupStepContent({
|
||||
installResults={installResults}
|
||||
navigateToAgentSettings={actions.navigateToAgentSettings}
|
||||
onInstallResultsChange={setInstallResults}
|
||||
renderRuntimeCard={renderRuntimeCard}
|
||||
runtimeProviders={runtimeProviders}
|
||||
/>
|
||||
|
||||
|
||||
272
desktop/src/features/onboarding/ui/SetupStepPreview.tsx
Normal file
272
desktop/src/features/onboarding/ui/SetupStepPreview.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import {
|
||||
SetupRuntimeAction,
|
||||
SetupRuntimeCardPresentation,
|
||||
SetupRuntimeInstallingStatus,
|
||||
SetupRuntimeReadyStatus,
|
||||
} from "./SetupRuntimeCard";
|
||||
import { SetupStepContent } from "./SetupStep";
|
||||
|
||||
const PREVIEW_RUNTIMES: AcpRuntimeCatalogEntry[] = [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
avatarUrl: "",
|
||||
availability: "available",
|
||||
command: "claude-agent-acp",
|
||||
binaryPath: "/usr/local/bin/claude-agent-acp",
|
||||
defaultArgs: [],
|
||||
mcpCommand: null,
|
||||
modelEnvVar: null,
|
||||
providerEnvVar: null,
|
||||
thinkingEnvVar: null,
|
||||
maxTokensEnvVar: null,
|
||||
contextLimitEnvVar: null,
|
||||
maxRoundsEnvVar: null,
|
||||
installHint: "Install the Claude Code ACP adapter via npm.",
|
||||
installInstructionsUrl:
|
||||
"https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp",
|
||||
canAutoInstall: true,
|
||||
requiresExternalCli: true,
|
||||
underlyingCliPath: "/usr/local/bin/claude",
|
||||
nodeRequired: false,
|
||||
authStatus: { status: "logged_out" },
|
||||
loginHint: "Sign in with your Claude subscription.",
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
avatarUrl: "",
|
||||
availability: "adapter_missing",
|
||||
command: null,
|
||||
binaryPath: null,
|
||||
defaultArgs: [],
|
||||
mcpCommand: null,
|
||||
modelEnvVar: null,
|
||||
providerEnvVar: null,
|
||||
thinkingEnvVar: null,
|
||||
maxTokensEnvVar: null,
|
||||
contextLimitEnvVar: null,
|
||||
maxRoundsEnvVar: null,
|
||||
installHint: "Install the Codex ACP adapter.",
|
||||
installInstructionsUrl: "https://github.com/openai/codex",
|
||||
canAutoInstall: true,
|
||||
requiresExternalCli: true,
|
||||
underlyingCliPath: "/usr/local/bin/codex",
|
||||
nodeRequired: false,
|
||||
authStatus: { status: "unknown" },
|
||||
loginHint: "Sign in with your ChatGPT subscription.",
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "goose",
|
||||
label: "Goose",
|
||||
avatarUrl: "",
|
||||
availability: "available",
|
||||
command: "goose",
|
||||
binaryPath: "/usr/local/bin/goose",
|
||||
defaultArgs: ["acp"],
|
||||
mcpCommand: null,
|
||||
modelEnvVar: null,
|
||||
providerEnvVar: null,
|
||||
thinkingEnvVar: null,
|
||||
maxTokensEnvVar: null,
|
||||
contextLimitEnvVar: null,
|
||||
maxRoundsEnvVar: null,
|
||||
installHint: "Install Goose via the official install script.",
|
||||
installInstructionsUrl: "https://block.github.io/goose/",
|
||||
canAutoInstall: true,
|
||||
requiresExternalCli: true,
|
||||
underlyingCliPath: null,
|
||||
nodeRequired: false,
|
||||
authStatus: { status: "not_applicable" },
|
||||
loginHint: null,
|
||||
source: "builtin",
|
||||
},
|
||||
{
|
||||
id: "buzz-agent",
|
||||
label: "Buzz Agent",
|
||||
avatarUrl: "",
|
||||
availability: "available",
|
||||
command: "buzz-agent",
|
||||
binaryPath: "/Applications/Buzz.app/Contents/MacOS/buzz-agent",
|
||||
defaultArgs: [],
|
||||
mcpCommand: "buzz-dev-mcp",
|
||||
modelEnvVar: null,
|
||||
providerEnvVar: null,
|
||||
thinkingEnvVar: null,
|
||||
maxTokensEnvVar: null,
|
||||
contextLimitEnvVar: null,
|
||||
maxRoundsEnvVar: null,
|
||||
installHint: "Ships with the Buzz desktop app.",
|
||||
installInstructionsUrl: "https://github.com/block/buzz",
|
||||
canAutoInstall: false,
|
||||
requiresExternalCli: false,
|
||||
underlyingCliPath: null,
|
||||
nodeRequired: false,
|
||||
authStatus: { status: "not_applicable" },
|
||||
loginHint: null,
|
||||
source: "builtin",
|
||||
},
|
||||
];
|
||||
|
||||
type PreviewActivity = "checking" | "idle" | "installing";
|
||||
|
||||
function PreviewRuntimeCard({
|
||||
onRuntimeChange,
|
||||
runtime,
|
||||
}: {
|
||||
onRuntimeChange: (
|
||||
runtimeId: string,
|
||||
update: (runtime: AcpRuntimeCatalogEntry) => AcpRuntimeCatalogEntry,
|
||||
) => void;
|
||||
runtime: AcpRuntimeCatalogEntry;
|
||||
}) {
|
||||
const [activity, setActivity] = React.useState<PreviewActivity>("idle");
|
||||
const timeoutRef = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
function finishAfter(
|
||||
activityValue: Exclude<PreviewActivity, "idle">,
|
||||
update: (current: AcpRuntimeCatalogEntry) => AcpRuntimeCatalogEntry,
|
||||
) {
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setActivity(activityValue);
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
onRuntimeChange(runtime.id, update);
|
||||
setActivity("idle");
|
||||
timeoutRef.current = null;
|
||||
}, 700);
|
||||
}
|
||||
|
||||
let status: React.ReactNode;
|
||||
if (activity === "installing") {
|
||||
status = <SetupRuntimeInstallingStatus runtime={runtime} />;
|
||||
} else if (activity === "checking") {
|
||||
status = (
|
||||
<SetupRuntimeAction
|
||||
ariaLabel={`Sign in to ${runtime.label}`}
|
||||
disabled
|
||||
onClick={() => undefined}
|
||||
testId={`onboarding-runtime-instructions-${runtime.id}`}
|
||||
>
|
||||
CHECKING…
|
||||
</SetupRuntimeAction>
|
||||
);
|
||||
} else if (
|
||||
runtime.availability === "available" &&
|
||||
(runtime.authStatus.status === "logged_in" ||
|
||||
runtime.authStatus.status === "not_applicable")
|
||||
) {
|
||||
status = <SetupRuntimeReadyStatus runtime={runtime} />;
|
||||
} else if (
|
||||
runtime.availability === "available" &&
|
||||
runtime.authStatus.status === "logged_out"
|
||||
) {
|
||||
status = (
|
||||
<SetupRuntimeAction
|
||||
ariaLabel={`Sign in to ${runtime.label}`}
|
||||
onClick={() =>
|
||||
finishAfter("checking", (current) => ({
|
||||
...current,
|
||||
authStatus: { status: "logged_in" },
|
||||
}))
|
||||
}
|
||||
testId={`onboarding-runtime-instructions-${runtime.id}`}
|
||||
>
|
||||
SIGN IN
|
||||
</SetupRuntimeAction>
|
||||
);
|
||||
} else {
|
||||
status = (
|
||||
<SetupRuntimeAction
|
||||
ariaLabel={`Install ${runtime.label}`}
|
||||
onClick={() =>
|
||||
finishAfter("installing", (current) => ({
|
||||
...current,
|
||||
availability: "available",
|
||||
authStatus: { status: "logged_out" },
|
||||
binaryPath: `/usr/local/bin/${current.id}-acp`,
|
||||
command: `${current.id}-acp`,
|
||||
}))
|
||||
}
|
||||
testId={`onboarding-runtime-install-${runtime.id}`}
|
||||
>
|
||||
INSTALL
|
||||
</SetupRuntimeAction>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupRuntimeCardPresentation
|
||||
installError={null}
|
||||
isInstalling={activity === "installing"}
|
||||
runtime={runtime}
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupStepPreview({
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const [runtimes, setRuntimes] = React.useState(PREVIEW_RUNTIMES);
|
||||
const updateRuntime = React.useCallback(
|
||||
(
|
||||
runtimeId: string,
|
||||
update: (runtime: AcpRuntimeCatalogEntry) => AcpRuntimeCatalogEntry,
|
||||
) => {
|
||||
setRuntimes((current) =>
|
||||
current.map((runtime) =>
|
||||
runtime.id === runtimeId ? update(runtime) : runtime,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SetupStepContent
|
||||
actions={{
|
||||
back: onBack,
|
||||
next: onNext,
|
||||
navigateToAgentSettings: onNext,
|
||||
}}
|
||||
direction="forward"
|
||||
onReadyRuntimeIdsChange={() => undefined}
|
||||
renderRuntimeCard={(runtime) => (
|
||||
<PreviewRuntimeCard
|
||||
onRuntimeChange={updateRuntime}
|
||||
runtime={runtime}
|
||||
/>
|
||||
)}
|
||||
state={{
|
||||
runtimeProviders: {
|
||||
errorMessage: null,
|
||||
isChecking: false,
|
||||
items: runtimes,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communitie
|
||||
import { CommunitiesProvider } from "@/features/communities/useCommunities";
|
||||
import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow";
|
||||
import { CommunityOnboardingProvider } from "@/features/onboarding/communityOnboarding";
|
||||
import { onboardingPreviewRequested } from "@/features/onboarding/onboardingPreview";
|
||||
import { OnboardingPreviewApp } from "@/features/onboarding/ui/OnboardingPreviewApp";
|
||||
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
||||
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
|
||||
import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider";
|
||||
@@ -108,6 +110,16 @@ function renderApp() {
|
||||
);
|
||||
}
|
||||
|
||||
function renderOnboardingPreview() {
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<RootErrorBoundary>
|
||||
<OnboardingPreviewApp />
|
||||
</RootErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
async function installE2eBridgeIfConfigured() {
|
||||
// The mock bridge is compiled only into dev and explicit E2E builds. A
|
||||
// pre-bootstrap global alone must never activate mock IPC in production.
|
||||
@@ -123,6 +135,13 @@ async function installE2eBridgeIfConfigured() {
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (onboardingPreviewRequested()) {
|
||||
// Preview mounts before storage migration, community state, the normal app,
|
||||
// or any native-backed onboarding action. Its state lives only in React.
|
||||
renderOnboardingPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
resetDevWebviewStateFromUrl();
|
||||
configureDevE2eBridgeFromUrl();
|
||||
recoverLocalStorageQuotaOnStartup();
|
||||
|
||||
@@ -4,12 +4,56 @@ import { motion, useReducedMotion } from "motion/react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type AnimatedCheckmarkProps = React.ComponentPropsWithoutRef<"svg"> & {
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
function AnimatedCheckmark({
|
||||
className,
|
||||
visible = true,
|
||||
...props
|
||||
}: AnimatedCheckmarkProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn("h-4 w-4", className)}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<motion.path
|
||||
animate={
|
||||
visible
|
||||
? { opacity: 1, pathLength: 1 }
|
||||
: { opacity: 0, pathLength: 0 }
|
||||
}
|
||||
d="m5 12 4 4L19 6"
|
||||
initial={
|
||||
shouldReduceMotion || !visible ? false : { opacity: 0, pathLength: 0 }
|
||||
}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 0.18,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
}
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
@@ -20,34 +64,11 @@ const Checkbox = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<motion.path
|
||||
animate={{ opacity: 1, pathLength: 1 }}
|
||||
d="m5 12 4 4L19 6"
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, pathLength: 0 }}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 0.18,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
}
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
<AnimatedCheckmark />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
});
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
export { AnimatedCheckmark, Checkbox };
|
||||
|
||||
Reference in New Issue
Block a user