diff --git a/Justfile b/Justfile index c81adb2381b..ebd8c29cec8 100644 --- a/Justfile +++ b/Justfile @@ -668,6 +668,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 diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 08028ddbe96..38d96b1dbec 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -176,18 +176,18 @@ pub fn build_media_fetch_client() -> reqwest::Result { } 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), diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index fe2bba5024b..e8bff2ab110 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -34,6 +34,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; @@ -159,6 +160,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); @@ -236,6 +241,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)?; diff --git a/desktop/src-tauri/src/onboarding_preview.rs b/desktop/src-tauri/src/onboarding_preview.rs new file mode 100644 index 00000000000..cee220027c6 --- /dev/null +++ b/desktop/src-tauri/src/onboarding_preview.rs @@ -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(identity: Option) -> Option { + 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(window: &tauri::Window) -> 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"))); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 05dc5553397..b3d154171bb 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -17,8 +17,8 @@ "windows": [ { "title": "", - "width": 800, - "height": 600, + "width": 900, + "height": 650, "maximized": true, "visible": false, "transparent": false, @@ -30,8 +30,8 @@ "y": 25 }, "backgroundThrottling": "disabled", - "minWidth": 800, - "minHeight": 500 + "minWidth": 900, + "minHeight": 650 } ], "macOSPrivateApi": true, diff --git a/desktop/src/features/agents/ui/AgentAvatarOnboardingTrigger.tsx b/desktop/src/features/agents/ui/AgentAvatarOnboardingTrigger.tsx new file mode 100644 index 00000000000..38241c95329 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentAvatarOnboardingTrigger.tsx @@ -0,0 +1,98 @@ +import { Plus } from "lucide-react"; + +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { EmojiAvatarDescriptor } from "@/features/profile/ui/ProfileAvatarEditor.utils"; +import { cn } from "@/shared/lib/cn"; +import { PopoverTrigger } from "@/shared/ui/popover"; +import { Spinner } from "@/shared/ui/spinner"; + +export function AgentAvatarOnboardingTrigger({ + assetLabel, + avatarUrl, + disabled, + emojiAvatarPreview, + hasAvatar, + isAnimatedPreviewActive, + isAvatarPending, + label, + onPreviewContainerChange, + squishKey, + testIdPrefix, +}: { + assetLabel: string; + avatarUrl: string | null; + disabled: boolean; + emojiAvatarPreview: EmojiAvatarDescriptor | null; + hasAvatar: boolean; + isAnimatedPreviewActive: boolean; + isAvatarPending: boolean; + label: string; + onPreviewContainerChange: (container: HTMLDivElement | null) => void; + squishKey: number; + testIdPrefix: string; +}) { + const actionLabel = hasAvatar ? `Change ${assetLabel}` : `Add ${assetLabel}`; + + return ( +
+
+ {isAnimatedPreviewActive ? null : isAvatarPending ? ( +
+ +
+ ) : emojiAvatarPreview ? ( +
+ 0 && "buzz-avatar-squish", + )} + key={squishKey} + > + {emojiAvatarPreview.emoji} + +
+ ) : hasAvatar ? ( + + ) : ( +
+
+ )} + +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentAvatarPickerTabs.tsx b/desktop/src/features/agents/ui/AgentAvatarPickerTabs.tsx new file mode 100644 index 00000000000..0f359c66475 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentAvatarPickerTabs.tsx @@ -0,0 +1,386 @@ +import emojiData from "@emoji-mart/data"; +import Picker from "@emoji-mart/react"; +import { Link2, UploadCloud } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import type * as React from "react"; + +import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorPanel"; +import { AnimatedAvatarCapture } from "@/features/profile/ui/AnimatedAvatarCapture"; +import type { AnimatedAvatarRecordingProcessor } from "@/features/profile/ui/AnimatedAvatarCapture.types"; +import { + AVATAR_COLORS, + AVATAR_COLOR_SWATCHES, + CUSTOM_AVATAR_COLOR_SWATCH, + EMOJI_MART_CATEGORIES, + type AvatarColorSwatch, + contrastColorForBackground, +} from "@/features/profile/ui/ProfileAvatarEditor.utils"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Spinner } from "@/shared/ui/spinner"; +import { + AVATAR_APPLY_MOTION_TRANSITION, + type EmojiMartEmoji, +} from "./AgentCreationPreview.utils"; + +function RemoveAvatarButton({ + actionButtonClassName, + assetLabel, + disabled, + onClick, +}: { + actionButtonClassName?: string; + assetLabel: string; + disabled: boolean; + onClick: () => void; +}) { + if (actionButtonClassName) { + return ( + + ); + } + + return ( + + ); +} + +export function AgentAvatarImageTab({ + actionButtonClassName, + assetLabel, + avatarUrlDraft, + disabled, + hasAvatar, + isUploading, + onApplyUrl, + onAvatarUrlDraftChange, + onClearAvatar, + onOpenUploadPicker, + uploadErrorMessage, +}: { + actionButtonClassName?: string; + assetLabel: string; + avatarUrlDraft: string; + disabled: boolean; + hasAvatar: boolean; + isUploading: boolean; + onApplyUrl: () => void; + onAvatarUrlDraftChange: (value: string) => void; + onClearAvatar?: () => void; + onOpenUploadPicker: () => void; + uploadErrorMessage: string | null; +}) { + const shouldReduceMotion = useReducedMotion(); + const applyButtonTransition = shouldReduceMotion + ? { duration: 0 } + : AVATAR_APPLY_MOTION_TRANSITION; + + return ( +
+ + +
+ + onAvatarUrlDraftChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onApplyUrl(); + } + }} + placeholder="Paste a URL" + spellCheck={false} + type="url" + value={avatarUrlDraft} + /> + + {avatarUrlDraft.trim().length > 0 ? ( + + + + ) : null} + +
+ + {uploadErrorMessage ? ( +

+ {uploadErrorMessage} +

+ ) : null} + + {hasAvatar && onClearAvatar ? ( + + ) : null} +
+ ); +} + +export function AgentAvatarEmojiTab({ + actionButtonClassName, + assetLabel, + colorDraft, + customHue, + customSaturation, + customValue, + disabled, + emojiMartThemeVars, + emojiPickerTheme = "auto", + emojiPickerContainerRef, + emojiSearchControlHeight, + hasAvatar, + isCustomColorPickerVisible, + onClearAvatar, + onColorSelect, + onCommitCustomColor, + onEmojiSelect, + onHueChange, + onSaturationValueChange, + selectedColor, + selectedEmoji, + showCategoryNavigation = true, + showSkinTonePicker = false, + testIdPrefix, +}: { + actionButtonClassName?: string; + assetLabel: string; + colorDraft: string; + customHue: number; + customSaturation: number; + customValue: number; + disabled: boolean; + emojiMartThemeVars: React.CSSProperties; + emojiPickerTheme?: "auto" | "light"; + emojiPickerContainerRef: React.RefObject; + emojiSearchControlHeight?: string; + hasAvatar: boolean; + isCustomColorPickerVisible: boolean; + onClearAvatar?: () => void; + onColorSelect: (swatch: AvatarColorSwatch) => void; + onCommitCustomColor: () => void; + onEmojiSelect: (emoji: EmojiMartEmoji, event?: MouseEvent) => void; + onHueChange: (value: number) => void; + onSaturationValueChange: (saturation: number, value: number) => void; + selectedColor: string; + selectedEmoji: string | null; + showCategoryNavigation?: boolean; + showSkinTonePicker?: boolean; + testIdPrefix: string; +}) { + return ( +
+
+ +
+ +
+ {AVATAR_COLOR_SWATCHES.map((swatch) => { + const isCustomSwatch = swatch === CUSTOM_AVATAR_COLOR_SWATCH; + const isSelected = isCustomSwatch + ? !AVATAR_COLORS.some( + (color) => color.toUpperCase() === selectedColor.toUpperCase(), + ) + : swatch.toUpperCase() === selectedColor.toUpperCase(); + + return ( + + ); + })} +
+ + + + {hasAvatar && onClearAvatar ? ( + + ) : null} +
+ ); +} + +export function AgentAvatarAnimatedTab({ + actionButtonClassName, + assetLabel, + disabled, + hasAvatar, + onApply, + onApplyPendingChange, + onClearAvatar, + onPreviewActiveChange, + previewContainer, + processRecording, + testIdPrefix, +}: { + actionButtonClassName?: string; + assetLabel: string; + disabled: boolean; + hasAvatar: boolean; + onApply: (avatarUrl: string) => void; + onApplyPendingChange: (isPending: boolean) => void; + onClearAvatar?: () => void; + onPreviewActiveChange?: (active: boolean) => void; + previewContainer?: HTMLElement | null; + processRecording?: AnimatedAvatarRecordingProcessor; + testIdPrefix: string; +}) { + return ( +
+ + {hasAvatar && onClearAvatar ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 2856f56e789..106954879e6 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -1,21 +1,17 @@ -import emojiData from "@emoji-mart/data"; -import Picker from "@emoji-mart/react"; import * as React from "react"; -import { Link2, Pencil, Plus, UploadCloud } from "lucide-react"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { Pencil, Plus } from "lucide-react"; + import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; +import type { AnimatedAvatarRecordingProcessor } from "@/features/profile/ui/AnimatedAvatarCapture.types"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { AVATAR_COLORS, - AVATAR_COLOR_SWATCHES, CUSTOM_AVATAR_COLOR_SWATCH, DEFAULT_CUSTOM_HUE, DEFAULT_CUSTOM_SATURATION, DEFAULT_CUSTOM_VALUE, DEFAULT_EMOJI_AVATAR_COLOR, - EMOJI_MART_CATEGORIES, type AvatarColorSwatch, - contrastColorForBackground, emojiAvatarDataUrl, hexToHsv, hsvToHex, @@ -24,10 +20,8 @@ import { useEmojiMartStyles, useEmojiMartThemeVars, } from "@/features/profile/ui/ProfileAvatarEditor.utils"; -import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorPanel"; import { useAvatarUpload } from "@/features/profile/useAvatarUpload"; import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; import { Popover, @@ -38,12 +32,27 @@ import { import { Spinner } from "@/shared/ui/spinner"; import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { - AVATAR_APPLY_MOTION_TRANSITION, type AvatarTab, type EmojiMartEmoji, isAvatarFileDrag, } from "./AgentCreationPreview.utils"; +import { + AgentAvatarAnimatedTab, + AgentAvatarEmojiTab, + AgentAvatarImageTab, +} from "./AgentAvatarPickerTabs"; +import { AgentAvatarOnboardingTrigger } from "./AgentAvatarOnboardingTrigger"; + +const ONBOARDING_EMOJI_MART_THEME_VARS = { + "--buzz-emoji-picker-rgb-background": + "var(--buzz-onboarding-emoji-picker-background)", + "--buzz-emoji-picker-rgb-color": "var(--buzz-onboarding-emoji-picker-color)", + "--buzz-emoji-picker-rgb-input": "var(--buzz-onboarding-emoji-picker-input)", +} as React.CSSProperties; + export function AgentCreationPreview({ + align = "center", + allowAnimated = false, assetLabel = "avatar", avatarUrl, disabled = false, @@ -53,11 +62,15 @@ export function AgentCreationPreview({ onCommitAvatar, onUploadPendingChange, onSelectAvatar, + presentation = "default", + processAnimatedAvatar, processImage, shape = "circle", testIdPrefix = "agent-avatar", variant = "default", }: { + align?: "center" | "start"; + allowAnimated?: boolean; assetLabel?: string; avatarUrl: string | null; disabled?: boolean; @@ -70,6 +83,8 @@ export function AgentCreationPreview({ onCommitAvatar?: (avatarUrl: string) => void; onUploadPendingChange?: (isPending: boolean) => void; onSelectAvatar: (avatarUrl: string) => void; + presentation?: "default" | "onboarding"; + processAnimatedAvatar?: AnimatedAvatarRecordingProcessor; processImage?: (file: File) => Promise; shape?: "circle" | "rounded-square"; testIdPrefix?: string; @@ -96,16 +111,25 @@ export function AgentCreationPreview({ const [isCustomColorPickerOpen, setIsCustomColorPickerOpen] = React.useState(false); const [isPopoverDragOver, setIsPopoverDragOver] = React.useState(false); + const [isAnimatedApplyPending, setIsAnimatedApplyPending] = + React.useState(false); + const [animatedPreviewContainer, setAnimatedPreviewContainer] = + React.useState(null); + const [isAnimatedPreviewActive, setIsAnimatedPreviewActive] = + React.useState(false); const [squishKey, setSquishKey] = React.useState(0); const avatarDragDepthRef = React.useRef(0); const popoverDragDepthRef = React.useRef(0); - const shouldReduceMotion = useReducedMotion(); const emojiPickerContainerRef = React.useRef(null); const emojiMartThemeVars = useEmojiMartThemeVars(); const { burstEmoji } = useEmojiBurst(); const assetLabelTitle = assetLabel.charAt(0).toUpperCase() + assetLabel.slice(1); const isRoundedSquare = shape === "rounded-square"; + const isOnboarding = presentation === "onboarding"; + const onboardingAvatarActionButtonClassName = isOnboarding + ? "h-10 rounded-full px-6 text-sm text-[var(--buzz-onboarding-cta-label)]" + : undefined; const isCompact = variant === "compact"; const emojiShape = isRoundedSquare ? "rounded-square" : "circle"; const { @@ -128,6 +152,9 @@ export function AgentCreationPreview({ useEmojiMartStyles( emojiPickerContainerRef, isAvatarMenuOpen && activeTab === "emoji", + false, + isOnboarding ? "light" : null, + isOnboarding, ); // Emoji Mart mounts its search input inside a shadow root. Wait for it // before focusing so the surrounding Radix popover cannot win the race. @@ -160,12 +187,14 @@ export function AgentCreationPreview({ [customHue, customSaturation, customValue], ); + const isAvatarPending = isUploading || isAnimatedApplyPending; + React.useEffect(() => { - onUploadPendingChange?.(isUploading); + onUploadPendingChange?.(isAvatarPending); return () => { onUploadPendingChange?.(false); }; - }, [isUploading, onUploadPendingChange]); + }, [isAvatarPending, onUploadPendingChange]); // Sync emoji state from avatarUrl when the popover opens React.useEffect(() => { @@ -179,7 +208,7 @@ export function AgentCreationPreview({ setSelectedEmoji(parsed.emoji); setSelectedColor(parsed.color); setHasChosenColor(true); - setActiveTab("emoji"); + setActiveTab("image"); } else { // Non-emoji avatar (image/URL or empty): clear any stale emoji // selection so a later color-swatch tap can't re-apply an old emoji @@ -187,7 +216,7 @@ export function AgentCreationPreview({ setSelectedEmoji(null); setSelectedColor(DEFAULT_EMOJI_AVATAR_COLOR); setHasChosenColor(false); - setActiveTab("image"); + setActiveTab("emoji"); } } }, [isAvatarMenuOpen, avatarUrl]); @@ -278,10 +307,6 @@ export function AgentCreationPreview({ () => parseEmojiAvatarDataUrl(avatarUrl ?? ""), [avatarUrl], ); - const applyButtonTransition = shouldReduceMotion - ? { duration: 0 } - : AVATAR_APPLY_MOTION_TRANSITION; - // Outer avatar drag — only active when popover is closed const handleAvatarDragEnter = React.useCallback( (event: React.DragEvent) => { @@ -425,7 +450,12 @@ export function AgentCreationPreview({ const avatarMenuContent = ( @@ -449,13 +479,20 @@ export function AgentCreationPreview({ }} value={activeTab} > - +