Send your public key to a community owner. Keep Buzz open; once
they add you, their invite link will continue setup here.
@@ -211,9 +209,7 @@ export function WelcomeSetup({
transitionKey={`invite-${transitionDirection}`}
>
-
- I have an invite link
-
+
I have an invite link
Keep this page open, then click the invite link you received.
Buzz will continue automatically. You can also paste it below.
diff --git a/desktop/src/features/onboarding/ui/AvatarStep.tsx b/desktop/src/features/onboarding/ui/AvatarStep.tsx
index 20fa8b4586..7333bea5c9 100644
--- a/desktop/src/features/onboarding/ui/AvatarStep.tsx
+++ b/desktop/src/features/onboarding/ui/AvatarStep.tsx
@@ -8,7 +8,8 @@ import { Plus } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
-import { StepProgress } from "@/shared/ui/step-progress";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import {
@@ -26,8 +27,6 @@ type AvatarStepProps = {
submit: ProfileStepActions["submit"];
updateAvatarUrl: ProfileStepActions["updateAvatarUrl"];
};
- /** Step number for the compact progress bar (mobile layout). */
- currentStep: number;
direction: OnboardingTransitionDirection;
/** When true, a ghost "Skip for now" button is always visible (not just on error). */
showAlwaysSkip?: boolean;
@@ -35,8 +34,6 @@ type AvatarStepProps = {
ProfileStepState,
"avatar" | "isSaving" | "isUploadingAvatar" | "name" | "saveRecovery"
>;
- /** Total steps for the compact progress bar (mobile layout). */
- totalSteps?: number;
};
function ErrorBanner({ message }: { message: string | null }) {
@@ -125,7 +122,6 @@ function AvatarPreview({
function AvatarStepActions({
canSubmit,
- currentStep,
hidden,
isSaving,
isUploadingAvatar,
@@ -135,10 +131,8 @@ function AvatarStepActions({
onSubmit,
saveRecovery,
showAlwaysSkip,
- totalSteps,
}: {
canSubmit: boolean;
- currentStep: number;
hidden: boolean;
isSaving: boolean;
isUploadingAvatar: boolean;
@@ -148,118 +142,108 @@ function AvatarStepActions({
onSubmit: () => void;
saveRecovery: ProfileStepState["saveRecovery"];
showAlwaysSkip: boolean;
- totalSteps?: number;
}) {
const areNavigationActionsDisabled = isSaving || isUploadingAvatar;
return (
-
- {hidden ? null : (
-
-
+
+ {hidden ? null : (
+
- {isSaving || isUploadingAvatar ? (
-
- ) : (
- "Next"
- )}
-
-
- {saveRecovery.canSkipForNow ? (
- // Error-recovery path: exits onboarding entirely when there is no
- // saved display name to fall back on.
+ {isSaving || isUploadingAvatar ? (
+
+ ) : (
+ "Next"
+ )}
+
+
+ {saveRecovery.canSkipForNow ? (
+ // Error-recovery path: exits onboarding entirely when there is no
+ // saved display name to fall back on.
+
+ Skip for now
+
+ ) : showAlwaysSkip && !saveRecovery.errorMessage ? (
+ // Normal path: advances to the theme step without saving an avatar.
+
+ Skip for now
+
+ ) : null}
+
+ {saveRecovery.canAdvanceWithoutSaving ? (
+
+ Continue without saving
+
+ ) : null}
+
+
- Skip for now
+ Back
- ) : showAlwaysSkip && !saveRecovery.errorMessage ? (
- // Normal path: advances to the theme step without saving an avatar.
-
- Skip for now
-
- ) : null}
-
- {saveRecovery.canAdvanceWithoutSaving ? (
-
- Continue without saving
-
- ) : null}
-
-
- Back
-
-
-
-
- )}
-
+
+ )}
+
+
);
}
export function AvatarStep({
actions,
- currentStep,
direction,
showAlwaysSkip = false,
state,
- totalSteps,
}: AvatarStepProps) {
const {
advanceWithoutSaving,
@@ -328,7 +312,11 @@ export function AvatarStep({
return (
-
+
Next, add a display image
-
+
Choose an image or emoji as your avatar
@@ -411,7 +399,6 @@ export function AvatarStep({
diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx
index 9fca6a8c84..d88e151379 100644
--- a/desktop/src/features/onboarding/ui/BackupStep.tsx
+++ b/desktop/src/features/onboarding/ui/BackupStep.tsx
@@ -4,6 +4,8 @@ import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
@@ -79,16 +81,16 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
transitionKey={`backup-${direction}`}
>
-
+
Your unique identity has been created
-
+
This key is stored in your system keychain, but save it some place
safe in case you ever need to restore your account.
-
+
{isLoading ? (
@@ -120,7 +122,8 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
) : nsec ? (
-
+ // Translucent white card frames the key with equal padding.
+
) : (
@@ -130,7 +133,7 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
)}
{nsec ? (
-
+
Never share your private key. Anyone can impersonate you and
@@ -140,9 +143,9 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
) : null}
-
+
Back
-
+
);
}
diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
index a6fe5f84a0..a7b21334e7 100644
--- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
@@ -21,7 +21,11 @@ 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 { OnboardingStepDots } from "./OnboardingStepDots";
+import {
+ ONBOARDING_PRIMARY_CTA_CLASS,
+ OnboardingChrome,
+} from "./OnboardingChrome";
+import { OnboardingFooter, OnboardingFooterProvider } from "./OnboardingFooter";
const NEUTRAL_EMOJI_PICKER_THEME_VARS = {
"--buzz-emoji-picker-rgb-background":
@@ -163,176 +167,178 @@ export function CommunityOnboardingFlow({
return (
{isProfileStage || isTeamStage ? (
-
+
) : null}
-
- {transaction.stage === "claiming" ||
- transaction.stage === "connecting" ? (
- <>
-
-
- Joining {transaction.communityName}
-
-
- {transaction.error ??
- (transaction.stage === "claiming"
- ? "Accepting your invite…"
- : "Connecting securely…")}
-
-
- {transaction.error ? (
-
- Retry
+
+
+ {transaction.stage === "claiming" ||
+ transaction.stage === "connecting" ? (
+ <>
+
+
+ Joining {transaction.communityName}
+
+
+ {transaction.error ??
+ (transaction.stage === "claiming"
+ ? "Accepting your invite…"
+ : "Connecting securely…")}
+
+
+ {transaction.error ? (
+
+ Retry
+
+ ) : null}
+
+ Cancel
- ) : null}
-
- Cancel
-
-
- >
- ) : isProfileStage ? (
- isAvatarEditorOpen ? (
-
-
setIsAvatarEditorOpen(false)}
- size="icon"
- type="button"
- >
-
-
-
setIsAvatarEditorOpen(false)}
- onUploadingChange={setIsUploadingAvatar}
- onUrlChange={setAvatarUrl}
- previewName={displayName.trim() || "Your profile"}
- testIdPrefix="community-avatar"
- />
-
+
+ >
+ ) : isProfileStage ? (
+ isAvatarEditorOpen ? (
+
+
setIsAvatarEditorOpen(false)}
+ size="icon"
+ type="button"
+ >
+
+
+
setIsAvatarEditorOpen(false)}
+ onUploadingChange={setIsUploadingAvatar}
+ onUrlChange={setAvatarUrl}
+ previewName={displayName.trim() || "Your profile"}
+ testIdPrefix="community-avatar"
+ />
+
+ ) : (
+ <>
+ Build your profile
+
+ Add a name and avatar. They’ll show up on your messages,
+ reactions, and agent handoffs.
+
+
+
setIsAvatarEditorOpen(true)}
+ previewName={displayName.trim() || "Your profile"}
+ />
+
+
+
+ Your name
+
+ setDisplayName(event.target.value)}
+ placeholder="First and last name"
+ value={displayName}
+ />
+
+ {transaction.error ? (
+
+ {transaction.error}
+
+ ) : null}
+
+ void saveProfile()}
+ >
+ Continue
+
+
+ >
+ )
) : (
<>
- Build your profile
-
- Add a name and avatar. They’ll show up on your messages,
- reactions, and agent handoffs.
+
Meet your starter team
+
+ Buzz lets you bring multiple agents into the same workspace.
+ This team will help you get started using Buzz.
-
-
setIsAvatarEditorOpen(true)}
- previewName={displayName.trim() || "Your profile"}
- />
-
-
-
- Your name
-
- setDisplayName(event.target.value)}
- placeholder="First and last name"
- value={displayName}
- />
-
+ {starterPersonas.length > 0 ? (
+
+ {starterPersonas.map((persona) => (
+
+
+
+ {persona.displayName}
+
+
+ ))}
+
+ ) : null}
{transaction.error ? (
{transaction.error}
) : null}
-
+
void saveProfile()}
- >
- Continue
-
-
- >
- )
- ) : (
- <>
- Meet your starter team
-
- Buzz lets you bring multiple agents into the same workspace. This
- team will help you get started using Buzz.
-
- {starterPersonas.length > 0 ? (
-
- {starterPersonas.map((persona) => (
-
-
-
- {persona.displayName}
-
-
- ))}
-
- ) : null}
- {transaction.error ? (
-
- {transaction.error}
-
- ) : null}
-
- void finalize()}
- >
- {transaction.stage === "finalizing"
- ? "Preparing Welcome…"
- : `Enter ${transaction.communityName}`}
-
- {transaction.error ? (
- void finish()}
- variant="ghost"
+ onClick={() => void finalize()}
>
- Skip for now
+ {transaction.stage === "finalizing"
+ ? "Preparing Welcome…"
+ : `Enter ${transaction.communityName}`}
- ) : null}
-
- >
- )}
-
+ {transaction.error ? (
+
void finish()}
+ variant="ghost"
+ >
+ Skip for now
+
+ ) : null}
+
+ >
+ )}
+
+
);
}
diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx
index eb6c1c335d..31a0f357ab 100644
--- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx
+++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx
@@ -14,6 +14,8 @@ import {
import type { GlobalAgentConfig } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
@@ -133,13 +135,15 @@ export function DefaultConfigStep({
}: DefaultConfigStepProps) {
return (
-
+
Configure your default model settings
@@ -152,9 +156,9 @@ export function DefaultConfigStep({
-
+
Back
-
+
);
}
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index 8bfec109bb..52b1cb291c 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -12,8 +12,12 @@ import { BackupStep } from "./BackupStep";
import { DefaultConfigStep } from "./DefaultConfigStep";
import { LandingBees } from "./LandingBees";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
+import {
+ ONBOARDING_LANDING_CTA_CLASS,
+ OnboardingChrome,
+} from "./OnboardingChrome";
+import { OnboardingFooterProvider } from "./OnboardingFooter";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
-import { OnboardingStepDots } from "./OnboardingStepDots";
import { SetupStep } from "./SetupStep";
type MachinePage = "identity" | "key-import" | "backup" | "setup" | "config";
@@ -90,106 +94,115 @@ export function MachineOnboardingFlow({
return (
{page === "identity" ?
: null}
{page !== "identity" ? (
-
) : null}
-
- {page === "identity" ? (
-
-
+
+ {page === "identity" ? (
+
+
+
+ Your people, your agents, your projects —
+ all in one place.
+
+ {error ? (
+ {error}
+ ) : null}
+
+ void loadFreshIdentity()}
+ type="button"
+ >
+ {isPending ? "Saving identity…" : "Get started"}
+
+ setPage("key-import")}
+ type="button"
+ variant="ghost"
+ >
+ Enter a key
+
+
+
+ ) : page === "key-import" ? (
+
+
+ {identityLost ? "Re-import your key" : "Enter your private key"}
+
+
+ {identityLost
+ ? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
+ : "If you already have a Nostr account, enter your private key below to get started."}
+
+ void replaceLostIdentity()
+ : () => setPage("identity")
+ }
+ onImport={importExistingIdentity}
+ variant="spotlight"
+ />
+
+ ) : page === "backup" ? (
+
setPage("identity")}
+ onNext={() => setPage("setup")}
/>
-
- Your people, your agents, your projects — all in one place.
-
- {error ? (
- {error}
- ) : null}
-
- void loadFreshIdentity()}
- type="button"
- >
- {isPending ? "Saving identity…" : "Get started"}
-
- setPage("key-import")}
- type="button"
- variant="ghost"
- >
- Enter a key
-
-
-
- ) : page === "key-import" ? (
-
-
- {identityLost ? "Re-import your key" : "Enter your private key"}
-
-
- {identityLost
- ? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
- : "If you already have a Nostr account, enter your private key below to get started."}
-
- void replaceLostIdentity()
- : () => setPage("identity")
- }
- onImport={importExistingIdentity}
- variant="spotlight"
+ ) : page === "setup" ? (
+
+ setPage(identityWasImported ? "key-import" : "backup"),
+ next: () => setPage("config"),
+ }}
+ direction="forward"
/>
-
- ) : page === "backup" ? (
- setPage("identity")}
- onNext={() => setPage("setup")}
- />
- ) : page === "setup" ? (
-
- setPage(identityWasImported ? "key-import" : "backup"),
- next: () => setPage("config"),
- }}
- direction="forward"
- />
- ) : (
- setPage("setup"),
- complete: () => complete(selectedPubkey ?? undefined),
- }}
- direction="forward"
- />
- )}
-
+ ) : (
+ setPage("setup"),
+ complete: () => complete(selectedPubkey ?? undefined),
+ }}
+ direction="forward"
+ />
+ )}
+
+
);
}
diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
index f8cd81f820..b4007e92fd 100644
--- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
+++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
@@ -6,6 +6,8 @@ import { nsecToNpub } from "@/shared/lib/nostrUtils";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
const NOSTR_KEY_FILE_MAX_BYTES = 1024;
@@ -87,6 +89,14 @@ export function NostrKeyImportForm({
}, []);
const handleSubmit = React.useCallback(async () => {
+ // Guard here, not just on the submit button: the button now lives in the
+ // portaled footer as type="button", so the single-field form still submits
+ // on Enter. Without this, pressing Enter during an in-flight import fires a
+ // second concurrent onImport (double keyring write).
+ if (isInteractionDisabled) {
+ return;
+ }
+
if (!previewNpub) {
setImportError(
"That doesn't look like a valid nsec. Paste an nsec1 key.",
@@ -106,7 +116,7 @@ export function NostrKeyImportForm({
} finally {
setIsImporting(false);
}
- }, [onImport, previewNpub, trimmedInput]);
+ }, [isInteractionDisabled, onImport, previewNpub, trimmedInput]);
return (
);
}
diff --git a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx
index d7aa4826d8..e6f06f158a 100644
--- a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx
+++ b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx
@@ -44,31 +44,55 @@ export function NsecMaskedDisplay({
copyTimerRef.current = setTimeout(() => setIsCopied(false), 2000);
}
+ const isBare = variant === "bare";
+ // Mask every character (no plaintext prefix leak), matching the real key's
+ // length so toggling reveal never reflows the monospace text (no layout shift).
+ // Bullets are joined with a zero-width space: WebKit (WKWebView) will not
+ // line-break a run of U+2022 even with break-all/overflow-wrap, so without an
+ // explicit break opportunity the masked key overflows its container. The mask
+ // is decorative and non-selectable, and copy uses the raw nsec, so the
+ // injected ZWSP is never surfaced.
+ const maskedNsec = React.useMemo(
+ () => Array.from(nsec, () => "•").join("\u200b"),
+ [nsec],
+ );
+ const iconSize = isBare ? "h-6 w-6" : "h-4 w-4";
+
return (
-
-
- {isRevealed ? nsec : nsec.slice(0, 6) + "•".repeat(52)}
-
-
+
+ {/* Wrapping element is a block inside the flex item, not the flex item
+ itself: WebKit (WKWebView) does not wrap a long unbroken string when
+ the text node is the flex child directly, even with break-all /
+ overflow-wrap. A plain block wraps reliably in every engine. */}
+
+
+ {isRevealed ? nsec : maskedNsec}
+
+
+
{isRevealed ? (
-
+
) : (
-
+
)}
void handleCopy()}
size="icon"
type="button"
variant="ghost"
>
{isCopied ? (
-
+
) : (
-
+
)}
diff --git a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
new file mode 100644
index 0000000000..75badd74a8
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
@@ -0,0 +1,65 @@
+import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
+
+/**
+ * Positions in the first-launch flow: landing, identity/key, harness setup,
+ * default config, community choice, community profile, meet the team. Used as
+ * the default pagination length when a flow doesn't pass an explicit total.
+ */
+export const TOTAL_ONBOARDING_PAGES = 7;
+
+/** Shared pill shape (38px tall) for every onboarding primary CTA. */
+const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6";
+
+/**
+ * Primary-CTA styling for the in-step onboarding pages: the shared pill with a
+ * light-blue label (`--buzz-onboarding-cta-label`, available on any
+ * `.buzz-onboarding-neutral-theme` subtree).
+ */
+export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-onboarding-cta-label)]`;
+
+/**
+ * Primary-CTA styling for the landing screen only: the shared pill with the
+ * chartreuse label (`--buzz-welcome-chartreuse`). The blue label is reserved
+ * for the CTAs inside the steps.
+ */
+export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-welcome-chartreuse)]`;
+
+/**
+ * Shared onboarding chrome shown on every page after the landing screen: a
+ * static Buzz mark pinned to the top-left, and a centered pagination track that
+ * sits above the page title. The active page reads as a longer bar; inactive
+ * pages are dots.
+ */
+export function OnboardingChrome({
+ current,
+ total = TOTAL_ONBOARDING_PAGES,
+}: {
+ current: number;
+ total?: number;
+}) {
+ return (
+
+
+
+
+
+ {Array.from({ length: total }, (_, i) => i + 1).map((position) => (
+
+ ))}
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
index e507f4b614..85e20b7046 100644
--- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
@@ -15,8 +15,9 @@ import {
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
-import { StepProgress } from "@/shared/ui/step-progress";
import { AvatarStep } from "./AvatarStep";
+import { OnboardingChrome } from "./OnboardingChrome";
+import { OnboardingFooterProvider } from "./OnboardingFooter";
import { MembershipDenied } from "./MembershipDenied";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
import { useCommunities } from "@/features/communities/useCommunities";
@@ -376,7 +377,6 @@ export function OnboardingFlow({
const pageIndex = activeSteps.indexOf(normalizedPage);
const currentStep = pageIndex >= 0 ? pageIndex + STEP_OFFSET : STEP_OFFSET;
const totalOnboardingSteps = activeSteps.length;
- const hideFixedProgressOnCompact = currentPage === "avatar";
// Swapping the identity changes the pubkey, which remounts this flow
// (keyed on pubkey in App.tsx) and re-runs the onboarding gate: the new
@@ -447,154 +447,136 @@ export function OnboardingFlow({
return (
<>
-
-
+
+
-
-
-
- {membershipError &&
- (currentPage === "profile" || currentPage === "avatar") ? (
-
- {membershipError.kind === "unreachable" ? (
- <>
-
- Can't reach this relay
-
-
- Check your connection or change your community.
-
-
setIsCommunityChangeOpen(true)}
- size="sm"
- variant="outline"
- >
- Change community
-
- >
- ) : (
- <>
-
- {membershipError.message ?? "Something went wrong"}
-
-
- The relay returned an error. Try again.
-
- >
- )}
-
- ) : null}
-
- {currentPage === "profile" ? (
-
{
- setMembershipError(null);
- setIsCommunityChangeOpen(true);
- },
- clearAvatarDraft: resetAvatarDraft,
- importExistingKey: showKeyImportPage,
- onUploadingChange: setIsUploadingAvatar,
- skipForNow,
- submit: () => {
- void saveProfileAndContinue("avatar");
- },
- updateAvatarUrl: updateAvatarUrlDraft,
- updateDisplayName: updateDisplayNameDraft,
- }}
- direction={transitionDirection}
- state={profileStepState}
- />
- ) : currentPage === "key-import" ? (
-
-
- {identityLost ? (
+ {membershipError &&
+ (currentPage === "profile" || currentPage === "avatar") ? (
+
+ {membershipError.kind === "unreachable" ? (
<>
-
- Re-import your key
-
-
- Your identity is no longer in the system keyring.
- Re-import your nsec to restore it — Buzz will restart to
- finish recovery. Or go back to start a new identity with a
- fresh key.
+
+ Can't reach this relay
+
+ Check your connection or change your community.
+
+
setIsCommunityChangeOpen(true)}
+ size="sm"
+ variant="outline"
+ >
+ Change community
+
>
) : (
<>
-
- Use your existing key
-
-
- Import your Nostr private key to use that identity with
- Buzz. If this key already has a profile on the relay, your
- name and avatar are restored automatically.
+
+ {membershipError.message ?? "Something went wrong"}
+
+
+ The relay returned an error. Try again.
>
)}
+ ) : null}
- {persistError ? (
-
- {persistError}
-
- ) : null}
-
-
{
+ setMembershipError(null);
+ setIsCommunityChangeOpen(true);
+ },
+ clearAvatarDraft: resetAvatarDraft,
+ importExistingKey: showKeyImportPage,
+ onUploadingChange: setIsUploadingAvatar,
+ skipForNow,
+ submit: () => {
+ void saveProfileAndContinue("avatar");
+ },
+ updateAvatarUrl: updateAvatarUrlDraft,
+ updateDisplayName: updateDisplayNameDraft,
+ }}
+ direction={transitionDirection}
+ state={profileStepState}
/>
-
- ) : (
- {
- void saveProfileAndContinue("complete");
- },
- updateAvatarUrl: updateAvatarUrlDraft,
- }}
- currentStep={currentStep}
- direction={transitionDirection}
- showAlwaysSkip={true}
- state={avatarStepState}
- totalSteps={totalOnboardingSteps}
- />
- )}
-
+ ) : currentPage === "key-import" ? (
+
+
+ {identityLost ? (
+ <>
+
+ Re-import your key
+
+
+ Your identity is no longer in the system keyring.
+ Re-import your nsec to restore it — Buzz will restart to
+ finish recovery. Or go back to start a new identity with
+ a fresh key.
+
+ >
+ ) : (
+ <>
+
+ Use your existing key
+
+
+ Import your Nostr private key to use that identity with
+ Buzz. If this key already has a profile on the relay,
+ your name and avatar are restored automatically.
+
+ >
+ )}
+
+
+ {persistError ? (
+
+ {persistError}
+
+ ) : null}
+
+
+
+ ) : (
+ {
+ void saveProfileAndContinue("complete");
+ },
+ updateAvatarUrl: updateAvatarUrlDraft,
+ }}
+ direction={transitionDirection}
+ showAlwaysSkip={true}
+ state={avatarStepState}
+ />
+ )}
+
+
{isCommunityChangeOpen ? (
(
+ null,
+);
+
+/**
+ * Renders the shared, bottom-docked CTA slot for an onboarding shell and
+ * exposes it to descendant steps via context.
+ *
+ * The slot is a direct child of the shell — a sibling of the animated step
+ * content — so CTAs portaled into it through `OnboardingFooter` escape
+ * `OnboardingSlideTransition`'s transform. A transformed ancestor establishes a
+ * containing block that would otherwise trap `position: fixed`, which is why
+ * the CTAs can't simply live inside the step and use `fixed` themselves. The
+ * slot stays inside the `.buzz-onboarding-neutral-theme` subtree so
+ * `--buzz-welcome-chartreuse` and the theme color tokens still resolve for the
+ * docked buttons.
+ */
+export function OnboardingFooterProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [target, setTarget] = React.useState(null);
+
+ return (
+
+ {children}
+ {/* Scrim: on pages taller than the viewport, content scrolls under the
+ docked CTA. This bottom-anchored fade to the shell's bottom color
+ (invisible on short pages and on the flat chartreuse landing) gives
+ the CTA a floor to sit on instead of colliding with form fields. */}
+
+
+
+ );
+}
+
+/**
+ * Portals a step's primary CTA group into the shell's bottom-docked footer slot
+ * (see `OnboardingFooterProvider`). When no slot exists in context — screens
+ * that render onboarding steps outside a provider, e.g. `KeyringLockedScreen` —
+ * it falls back to rendering the CTA group inline so the buttons never vanish.
+ */
+export function OnboardingFooter({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ const target = React.useContext(OnboardingFooterTargetContext);
+ const group = (
+
+ {children}
+
+ );
+
+ if (!target) {
+ return group;
+ }
+
+ return createPortal(group, target);
+}
diff --git a/desktop/src/features/onboarding/ui/OnboardingStepDots.tsx b/desktop/src/features/onboarding/ui/OnboardingStepDots.tsx
deleted file mode 100644
index 5e3a8551b9..0000000000
--- a/desktop/src/features/onboarding/ui/OnboardingStepDots.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
-
-/**
- * Positions in the first-launch flow: landing, identity/key, harness setup,
- * default config, community choice, community profile, meet the team.
- */
-export const TOTAL_ONBOARDING_PAGES = 7;
-
-/**
- * Top-left paging indicator for the first-launch flow. The bee marks the
- * current page on a dot track; shown on every page after the landing screen.
- */
-export function OnboardingStepDots({
- current,
- total = TOTAL_ONBOARDING_PAGES,
-}: {
- current: number;
- total?: number;
-}) {
- return (
-
- {Array.from({ length: total }, (_, i) => i + 1).map((position) =>
- position === current ? (
-
-
-
- ) : (
-
- ),
- )}
-
- );
-}
diff --git a/desktop/src/features/onboarding/ui/ProfileStep.tsx b/desktop/src/features/onboarding/ui/ProfileStep.tsx
index 8286cf1a53..4aadaf525b 100644
--- a/desktop/src/features/onboarding/ui/ProfileStep.tsx
+++ b/desktop/src/features/onboarding/ui/ProfileStep.tsx
@@ -8,6 +8,8 @@ import { cn } from "@/shared/lib/cn";
import { isRelayUnreachableError } from "@/shared/lib/relayError";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
type OnboardingTransitionEffect,
@@ -216,10 +218,10 @@ export function ProfileStep({
transitionKey={`profile-${direction}`}
>
-
+
What should we call you?
-
+
Pick the name people and agents will see in Buzz. You can change it
anytime.
@@ -275,9 +277,9 @@ export function ProfileStep({
) : null}
-
+
-
+
);
}
diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx
index ebd08816a1..e1eaf7203f 100644
--- a/desktop/src/features/onboarding/ui/SetupStep.tsx
+++ b/desktop/src/features/onboarding/ui/SetupStep.tsx
@@ -23,6 +23,8 @@ import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Spinner } from "@/shared/ui/spinner";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
@@ -457,7 +459,7 @@ function RuntimeProvidersSection({
return (
-
+
Use the models that fit the task
@@ -516,16 +518,18 @@ function SetupStepContent({
return (
-
+
Back
-
+
);
}
diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css
index 86b44855c7..fa6dd786b8 100644
--- a/desktop/src/shared/styles/globals/components.css
+++ b/desktop/src/shared/styles/globals/components.css
@@ -215,15 +215,46 @@
}
.buzz-onboarding-neutral-theme.buzz-startup-shell {
- background-image: linear-gradient(to bottom, #d7d72e, #d7e7f6);
+ /* Dot grid over the chartreuse→light-blue gradient (dots layer first so
+ they sit on top). The welcome page overrides this with dots over a flat
+ chartreuse fill below. */
+ background-image:
+ radial-gradient(circle, rgb(0 0 0 / 0.08) 1px, transparent 1px),
+ linear-gradient(
+ to bottom,
+ var(--buzz-welcome-chartreuse),
+ var(--buzz-onboarding-shell-bottom)
+ );
+ background-size:
+ 24px 24px,
+ auto;
}
.buzz-onboarding-neutral-theme.buzz-startup-shell.buzz-onboarding-welcome {
- background-color: #d7d72e;
- background-image: none;
+ background-color: var(--buzz-welcome-chartreuse);
+ /* Landing is a flat chartreuse field, so the docked-footer scrim must match
+ it (not the gradient's light-blue bottom) to stay invisible. */
+ --buzz-onboarding-shell-bottom: var(--buzz-welcome-chartreuse);
+ /* Subtle dot grid: 2px dots spaced on a 24px lattice. */
+ background-image: radial-gradient(
+ circle,
+ rgb(0 0 0 / 0.08) 1px,
+ transparent 1px
+ );
+ background-size: 24px 24px;
}
.buzz-onboarding-neutral-theme {
+ /* Landing "chartreuse" — the welcome background; reused for the CTA label. */
+ --buzz-welcome-chartreuse: #d7d72e;
+ /* Bottom stop of the onboarding shell gradient; also the docked-footer
+ scrim color so content scrolling under the CTA fades into the page. */
+ --buzz-onboarding-shell-bottom: #d7e7f6;
+ /* Label color for the primary onboarding CTA (light blue on the dark pill). */
+ --buzz-onboarding-cta-label: #d7e6f0;
+ /* Olive ink for the backup step's private key and its "never share" caption,
+ reading against the fuzzy-white key frame. */
+ --buzz-onboarding-backup-ink: #717106;
--background: 0 0% 100%;
--foreground: 0 0% 9%;
--primary: 0 0% 9%;
@@ -328,8 +359,13 @@
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"]
> .buzz-onboarding-transition-content {
+ /* `backwards` (not `both`): the reveal ends on an identity transform, so
+ holding it forward is visually a no-op but leaves a lingering transform
+ that establishes a containing block and traps `position: fixed`
+ descendants (the bottom-docked onboarding footer). Reverting to no
+ transform at rest looks identical and frees fixed positioning. */
animation: buzz-onboarding-mask-reveal-up 760ms
- cubic-bezier(0.22, 1, 0.36, 1) both;
+ cubic-bezier(0.22, 1, 0.36, 1) backwards;
animation-delay: var(--buzz-onboarding-transition-delay, 0ms);
transform-origin: 50% 70%;
}
diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js
index a1e17bd2b5..2d12c42112 100644
--- a/desktop/tailwind.config.js
+++ b/desktop/tailwind.config.js
@@ -10,6 +10,10 @@ export default {
fontSize: {
"2xs": "0.6875rem", // 11px — meta-text workhorse (timestamps, badges)
"3xs": "0.5rem", // 8px — tiny glyphs / micro labels
+ // 40px — onboarding page titles (tightened tracking for large display type)
+ title: ["2.5rem", { lineHeight: "1.15", letterSpacing: "-0.02em" }],
+ // 36px — the backup-step private key, shown large in monospace
+ "nsec-key": ["2.25rem", { lineHeight: "1.3" }],
},
borderRadius: {
lg: "var(--radius)",
diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts
index dbeb84143f..2ba35c1cdf 100644
--- a/desktop/tests/e2e/identity-lost.spec.ts
+++ b/desktop/tests/e2e/identity-lost.spec.ts
@@ -17,7 +17,8 @@ test("normal first launch uses the already-persisted identity", async ({
const gate = page.getByTestId("machine-onboarding-gate");
await expect(gate).toBeVisible();
await expect(gate).toHaveCSS("background-color", "rgb(215, 215, 46)");
- await expect(gate).toHaveCSS("background-image", "none");
+ // Landing carries a subtle dot-grid pattern over the chartreuse fill.
+ await expect(gate).toHaveCSS("background-image", /radial-gradient/);
await expect(gate).toHaveCSS("color", "rgb(23, 23, 23)");
await expect(page.getByRole("button", { name: "Get started" })).toHaveCSS(
"background-color",
@@ -30,9 +31,10 @@ test("normal first launch uses the already-persisted identity", async ({
name: "Your unique identity has been created",
}),
).toBeVisible();
+ // Non-landing pages layer the dot grid over the chartreuse→light-blue gradient.
await expect(gate).toHaveCSS(
"background-image",
- "linear-gradient(rgb(215, 215, 46), rgb(215, 231, 246))",
+ /radial-gradient\(.*\), linear-gradient\(.*rgb\(215, 215, 46\).*rgb\(215, 231, 246\)\)/s,
);
await expect(gate).toHaveCSS("color", "rgb(23, 23, 23)");
const commands = await page.evaluate(
diff --git a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts
index 6ee08154ad..2a6942f518 100644
--- a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts
+++ b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts
@@ -31,10 +31,11 @@ test("avatar step always shows Skip for now button without an error", async ({
await expect(skipBtn).toBeEnabled();
await expect(skipBtn).toHaveText("Skip for now");
- // Capture a screenshot showing the avatar step with the skip button.
+ // Capture the whole viewport: the Skip/Next/Back CTAs are portaled into the
+ // docked footer (a sibling of the step subtree), so a section-scoped shot
+ // would omit the very buttons this artifact is meant to show.
await waitForAnimations(page);
- const avatarSection = page.locator('[data-testid="onboarding-page-avatar"]');
- await avatarSection.screenshot({
+ await page.screenshot({
path: `${SHOTS}/01-avatar-skip-button.png`,
});
});
diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts
index 83d125fbb2..a52f8870b6 100644
--- a/desktop/tests/e2e/onboarding-backup.spec.ts
+++ b/desktop/tests/e2e/onboarding-backup.spec.ts
@@ -38,10 +38,10 @@ test("backup step shows masked nsec from mock bridge", async ({ page }) => {
await expect(revealBtn).toBeVisible();
await expect(nsecDisplay).toHaveCSS("filter", /blur/);
- // Take a screenshot of the masked state.
+ // Take a screenshot of the masked state. Capture the whole viewport: the CTAs
+ // are portaled into the docked footer outside the step subtree.
await waitForAnimations(page);
- const backupSection = page.locator('[data-testid="onboarding-page-backup"]');
- await backupSection.screenshot({
+ await page.screenshot({
path: `${SHOTS}/02-backup-step-masked.png`,
});
@@ -52,7 +52,7 @@ test("backup step shows masked nsec from mock bridge", async ({ page }) => {
// Take a screenshot of the revealed state.
await waitForAnimations(page);
- await backupSection.screenshot({
+ await page.screenshot({
path: `${SHOTS}/03-backup-step-revealed.png`,
});
});
diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
new file mode 100644
index 0000000000..61bbb8e25f
--- /dev/null
+++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
@@ -0,0 +1,70 @@
+import { expect, test } from "@playwright/test";
+
+import { waitForAnimations } from "../helpers/animations";
+import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
+import { seedActiveIdentity } from "../helpers/onboarding";
+
+const BLANK_TYLER_IDENTITY = {
+ ...TEST_IDENTITIES.tyler,
+ username: "",
+};
+
+const SHOT_DIR = "test-results/onboarding-docked-cta";
+
+test.use({ viewport: { width: 1280, height: 800 } });
+
+test("machine onboarding: landing, backup, setup docked CTAs", async ({
+ page,
+}) => {
+ await installMockBridge(page, undefined, {
+ skipCommunitySeed: true,
+ skipOnboardingSeed: true,
+ });
+ await page.goto("/");
+
+ const gate = page.getByTestId("machine-onboarding-gate");
+ await expect(gate).toBeVisible();
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/01-landing.png` });
+
+ await page.getByRole("button", { name: "Get started" }).click();
+ await expect(
+ page.getByRole("heading", {
+ name: "Your unique identity has been created",
+ }),
+ ).toBeVisible();
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/02-backup.png` });
+
+ // Reveal the key: box must not reflow (same-length monospace mask).
+ await page.getByTestId("nsec-reveal-toggle").click();
+ await expect(page.getByTestId("nsec-value")).toHaveClass(/select-text/);
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });
+
+ await page.getByTestId("onboarding-next").click();
+ await expect(
+ page.getByRole("heading", { name: "Use the models that fit the task" }),
+ ).toBeVisible();
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/03-setup.png` });
+});
+
+test("relay onboarding: profile and avatar docked CTAs", async ({ page }) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(page, undefined, { skipOnboardingSeed: true });
+ await page.goto("/");
+
+ await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
+ await page.getByTestId("onboarding-display-name").fill("Ada Lovelace");
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/04-profile.png` });
+
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
+ await page
+ .getByTestId("onboarding-avatar-url")
+ .fill("https://example.com/onboarding-avatar.png");
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/05-avatar.png` });
+});
From 219e12d745ae03eb9b24ea839dd2186b4a62c505 Mon Sep 17 00:00:00 2001
From: Tom Brow
Date: Thu, 16 Jul 2026 22:02:35 -0700
Subject: [PATCH 08/12] feat(desktop): show owners in agent hover cards (#2015)
Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e
Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e
---
.../profile/ui/UserProfilePopover.tsx | 25 ++-
desktop/tests/e2e/mentions.spec.ts | 148 ++++++++++++++++++
2 files changed, 172 insertions(+), 1 deletion(-)
diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index eee4c60524..6619b5b5b4 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -23,7 +23,10 @@ import {
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
-import { ownsAuthorAgent } from "@/features/profile/lib/identity";
+import {
+ formatOwnerLabel,
+ ownsAuthorAgent,
+} from "@/features/profile/lib/identity";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useUserStatusQuery } from "@/features/user-status/hooks";
@@ -205,6 +208,11 @@ export function UserProfilePopover({
(a) => a.pubkey === pubkey,
);
const profile = profileQuery.data;
+ const ownerPubkey = profile?.ownerPubkey ?? null;
+ const ownerProfileQuery = useUsersBatchQuery(
+ ownerPubkey ? [ownerPubkey] : [],
+ { enabled: open && Boolean(ownerPubkey) },
+ );
const normalizedPubkey = normalizePubkey(pubkey);
const isAgentByOaOwner = Boolean(
usersBatchQuery.data?.profiles[normalizedPubkey]?.isAgent,
@@ -232,6 +240,13 @@ export function UserProfilePopover({
const isOwner = useIsManagedAgent(isBotProfile ? pubkey : null);
const identityQuery = useIdentityQuery();
const currentPubkey = identityQuery.data?.pubkey;
+ const ownerLabel = isBotProfile
+ ? formatOwnerLabel(
+ ownerPubkey,
+ currentPubkey,
+ ownerProfileQuery.data?.profiles,
+ )
+ : null;
const isSelf =
currentPubkey !== undefined &&
currentPubkey.toLowerCase() === pubkey.toLowerCase();
@@ -511,6 +526,14 @@ export function UserProfilePopover({
/>
) : null}
+ {isBotProfile && ownerLabel ? (
+
+ owned by {ownerLabel}
+
+ ) : null}
{profileSubheader ? (
{
await installMockBridge(page);
});
@@ -26,6 +28,8 @@ const CASEY_PROFILE_PUBKEY =
"1111111111111111111111111111111111111111111111111111111111111111";
const PROFILE_ONLY_AGENT_PUBKEY =
"8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67";
+const OWNED_AGENT_PROFILE_PUBKEY =
+ "1212121212121212121212121212121212121212121212121212121212121212";
const SYSTEM_MESSAGE_KIND = 40099;
const DM_THREAD_AGENT_MENTION_ERROR_TEXT =
"Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent.";
@@ -1699,6 +1703,150 @@ test("clicking a mention chip in a forum post opens the profile panel", async ({
);
});
+test("agent profile popover shows its owner", async ({ page }) => {
+ await installMockBridge(page, {
+ searchProfiles: [
+ {
+ pubkey: OWNED_AGENT_PROFILE_PUBKEY,
+ displayName: "Bumble",
+ ownerPubkey: TEST_IDENTITIES.bob.pubkey,
+ isAgent: true,
+ },
+ ],
+ });
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await waitForMockLiveSubscription(page, "general");
+
+ await emitMockMessage(page, "general", "Bumble checking in.", {
+ pubkey: OWNED_AGENT_PROFILE_PUBKEY,
+ });
+ await waitForTimelineSettled(page);
+
+ const bumbleMessage = page
+ .getByTestId("message-row")
+ .filter({ hasText: "Bumble checking in." })
+ .first();
+ await bumbleMessage.locator("button").first().hover();
+
+ const profilePopover = page.locator(
+ '[data-testid="user-profile-popover"][data-state="open"]',
+ );
+ await expect(profilePopover).toBeVisible();
+ await expect(
+ profilePopover.getByTestId(
+ `user-profile-popover-owner-${OWNED_AGENT_PROFILE_PUBKEY}`,
+ ),
+ ).toHaveText("owned by bob");
+});
+
+test("agent profile popover labels an agent owned by the viewer as you", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ searchProfiles: [
+ {
+ pubkey: OWNED_AGENT_PROFILE_PUBKEY,
+ displayName: "Bumble",
+ ownerPubkey: MOCK_VIEWER_PUBKEY,
+ isAgent: true,
+ },
+ ],
+ });
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await waitForMockLiveSubscription(page, "general");
+
+ await emitMockMessage(page, "general", "Bumble checking in.", {
+ pubkey: OWNED_AGENT_PROFILE_PUBKEY,
+ });
+ await waitForTimelineSettled(page);
+
+ const bumbleMessage = page
+ .getByTestId("message-row")
+ .filter({ hasText: "Bumble checking in." })
+ .first();
+ await bumbleMessage.locator("button").first().hover();
+
+ const profilePopover = page.locator(
+ '[data-testid="user-profile-popover"][data-state="open"]',
+ );
+ await expect(profilePopover).toBeVisible();
+ await expect(
+ profilePopover.getByTestId(
+ `user-profile-popover-owner-${OWNED_AGENT_PROFILE_PUBKEY}`,
+ ),
+ ).toHaveText("owned by you");
+});
+
+test("agent profile popover falls back to the owner's pubkey", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ searchProfiles: [
+ {
+ pubkey: OWNED_AGENT_PROFILE_PUBKEY,
+ displayName: "Bumble",
+ ownerPubkey: CASEY_PROFILE_PUBKEY,
+ isAgent: true,
+ },
+ ],
+ });
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await waitForMockLiveSubscription(page, "general");
+
+ await emitMockMessage(page, "general", "Bumble checking in.", {
+ pubkey: OWNED_AGENT_PROFILE_PUBKEY,
+ });
+ await waitForTimelineSettled(page);
+
+ const bumbleMessage = page
+ .getByTestId("message-row")
+ .filter({ hasText: "Bumble checking in." })
+ .first();
+ await bumbleMessage.locator("button").first().hover();
+
+ const profilePopover = page.locator(
+ '[data-testid="user-profile-popover"][data-state="open"]',
+ );
+ await expect(profilePopover).toBeVisible();
+ await expect(
+ profilePopover.getByTestId(
+ `user-profile-popover-owner-${OWNED_AGENT_PROFILE_PUBKEY}`,
+ ),
+ ).toHaveText("owned by 11111111…1111");
+});
+
+test("human profile popover does not show an owner", async ({ page }) => {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await waitForMockLiveSubscription(page, "general");
+
+ await emitMockMessage(page, "general", "Bob checking in.", {
+ pubkey: TEST_IDENTITIES.bob.pubkey,
+ });
+ await waitForTimelineSettled(page);
+
+ const bobMessage = page
+ .getByTestId("message-row")
+ .filter({ hasText: "Bob checking in." })
+ .first();
+ await bobMessage.locator("button").first().hover();
+
+ const profilePopover = page.locator(
+ '[data-testid="user-profile-popover"][data-state="open"]',
+ );
+ await expect(profilePopover).toBeVisible();
+ await expect(
+ profilePopover.locator('[data-testid^="user-profile-popover-owner-"]'),
+ ).toHaveCount(0);
+});
+
test("owned bot profile only exposes message action", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
From 5973e28995077404c1463b1c86c6bab8730af562 Mon Sep 17 00:00:00 2001
From: Bradley Axen
Date: Thu, 16 Jul 2026 22:25:38 -0700
Subject: [PATCH 09/12] fix(desktop): case-insensitive relay media origin
match, release 0.4.13 (#2023)
Signed-off-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1zurdm6fx3kksz8f8d8y4js3mvjyu3rshlpqejr8wa8hta5tsqqeshmes7w <1706dde9268dad011d2769c959423b6489c88e17f841990ceee9eebed1700033@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1zurdm6fx3kksz8f8d8y4js3mvjyu3rshlpqejr8wa8hta5tsqqeshmes7w <1706dde9268dad011d2769c959423b6489c88e17f841990ceee9eebed1700033@sprout-oss.stage.blox.sqprod.co>
---
CHANGELOG.md | 5 ++
desktop/package.json | 2 +-
desktop/src-tauri/Cargo.lock | 2 +-
desktop/src-tauri/Cargo.toml | 2 +-
desktop/src-tauri/tauri.conf.json | 2 +-
desktop/src/shared/lib/mediaUrl.test.mjs | 110 +++++++++++++++++++++++
desktop/src/shared/lib/mediaUrl.ts | 52 +++++++++--
7 files changed, 166 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8c7d0033b..ae835ea9dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## v0.4.13
+
+- fix(desktop): compare relay media origins case-insensitively so uploads render when the saved community URL has uppercase characters
+- fix(desktop): invalidate stale media lookups so an in-flight relay-origin fetch cannot repopulate caches after a community reset
+
## v0.4.12
- fix(desktop): proxy authenticated relay media after cold-start community initialization
diff --git a/desktop/package.json b/desktop/package.json
index 6ea9fdfe96..f07b282d4f 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "buzz",
"private": true,
- "version": "0.4.12",
+ "version": "0.4.13",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 8b9b413e1b..2db15a9b54 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -972,7 +972,7 @@ dependencies = [
[[package]]
name = "buzz-desktop"
-version = "0.4.12"
+version = "0.4.13"
dependencies = [
"anyhow",
"arboard",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 3a2738271d..68005f41d3 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -2,7 +2,7 @@
[package]
name = "buzz-desktop"
-version = "0.4.12"
+version = "0.4.13"
description = "Buzz desktop app"
authors = ["you"]
edition = "2021"
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index 7cf708d14b..0e611556ed 100644
--- a/desktop/src-tauri/tauri.conf.json
+++ b/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Buzz",
- "version": "0.4.12",
+ "version": "0.4.13",
"identifier": "xyz.block.buzz.app",
"build": {
"beforeDevCommand": {
diff --git a/desktop/src/shared/lib/mediaUrl.test.mjs b/desktop/src/shared/lib/mediaUrl.test.mjs
index e436f96850..f087d3db19 100644
--- a/desktop/src/shared/lib/mediaUrl.test.mjs
+++ b/desktop/src/shared/lib/mediaUrl.test.mjs
@@ -5,9 +5,119 @@ import { mediaProxyUrl } from "./mediaUrl.ts";
const HASH = "a".repeat(64);
+function deferred() {
+ let resolve;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
test("mediaProxyUrl: uses the IPv4 loopback literal for the localhost proxy", () => {
assert.equal(
mediaProxyUrl(54321, `${HASH}.png`),
`http://127.0.0.1:54321/media/${HASH}.png`,
);
});
+
+test("resetMediaCaches: ignores relay origin lookups from the previous generation", async () => {
+ const previousWindow = globalThis.window;
+ const staleOrigin = deferred();
+ let relayOriginCalls = 0;
+
+ globalThis.window = {
+ __TAURI_INTERNALS__: {
+ invoke(command) {
+ if (command === "get_media_proxy_port") return Promise.resolve(54321);
+ if (command === "get_relay_http_url") {
+ relayOriginCalls += 1;
+ return relayOriginCalls === 1
+ ? staleOrigin.promise
+ : Promise.resolve("https://active.example");
+ }
+ return Promise.reject(new Error(`Unexpected command: ${command}`));
+ },
+ },
+ };
+
+ try {
+ // A unique URL triggers module-load fetching with the stale relay lookup
+ // still unresolved, matching a cold launch before applyCommunity finishes.
+ const mediaUrl = await import(`./mediaUrl.ts?race=${Date.now()}`);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ mediaUrl.resetMediaCaches();
+ const activeUrl = `https://active.example/media/${HASH}.png`;
+ mediaUrl.rewriteRelayUrl(activeUrl);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ // Complete the old lookup after reset. It must not overwrite the active
+ // community origin fetched by the new generation.
+ staleOrigin.resolve("https://stale.example");
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ assert.equal(
+ mediaUrl.rewriteRelayUrl(activeUrl),
+ `http://127.0.0.1:54321/media/${HASH}.png`,
+ );
+ } finally {
+ globalThis.window = previousWindow;
+ }
+});
+
+test("rewriteRelayUrl: matches relay origin case-insensitively (uppercase saved community URL)", async () => {
+ const previousWindow = globalThis.window;
+
+ globalThis.window = {
+ __TAURI_INTERNALS__: {
+ invoke(command) {
+ if (command === "get_media_proxy_port") return Promise.resolve(54321);
+ if (command === "get_relay_http_url") {
+ // Saved community URLs keep the user's casing; the relay always
+ // emits lowercased media URLs (normalize_host in buzz-core).
+ return Promise.resolve("https://PENDING-SEED.communities.buzz.xyz");
+ }
+ return Promise.reject(new Error(`Unexpected command: ${command}`));
+ },
+ },
+ };
+
+ try {
+ const mediaUrl = await import(`./mediaUrl.ts?case=${Date.now()}`);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ const relayMediaUrl = `https://pending-seed.communities.buzz.xyz/media/${HASH}.png`;
+ assert.equal(
+ mediaUrl.rewriteRelayUrl(relayMediaUrl),
+ `http://127.0.0.1:54321/media/${HASH}.png`,
+ );
+ } finally {
+ globalThis.window = previousWindow;
+ }
+});
+
+test("rewriteRelayUrl: still passes external Blossom URLs through unchanged", async () => {
+ const previousWindow = globalThis.window;
+
+ globalThis.window = {
+ __TAURI_INTERNALS__: {
+ invoke(command) {
+ if (command === "get_media_proxy_port") return Promise.resolve(54321);
+ if (command === "get_relay_http_url") {
+ return Promise.resolve("https://relay.example");
+ }
+ return Promise.reject(new Error(`Unexpected command: ${command}`));
+ },
+ },
+ };
+
+ try {
+ const mediaUrl = await import(`./mediaUrl.ts?external=${Date.now()}`);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ const externalUrl = `https://nostr.build/media/${HASH}.png`;
+ assert.equal(mediaUrl.rewriteRelayUrl(externalUrl), externalUrl);
+ } finally {
+ globalThis.window = previousWindow;
+ }
+});
diff --git a/desktop/src/shared/lib/mediaUrl.ts b/desktop/src/shared/lib/mediaUrl.ts
index 94e4cc41e5..075575a314 100644
--- a/desktop/src/shared/lib/mediaUrl.ts
+++ b/desktop/src/shared/lib/mediaUrl.ts
@@ -26,9 +26,39 @@ const RELAY_MEDIA_RE =
let cachedPort: number | null = null;
let portPromise: Promise | null = null;
-/** Cached relay origin (e.g. "https://buzz-oss.stage.blox.sqprod.co"). */
+/**
+ * Cached relay origin (e.g. "https://buzz-oss.stage.blox.sqprod.co"),
+ * canonicalized via {@link canonicalOrigin} so comparisons are stable.
+ */
let cachedRelayOrigin: string | null = null;
+/**
+ * Canonicalize a URL to its origin with a lowercased scheme/host.
+ *
+ * The relay always emits media URLs with a lowercased tenant host
+ * (`normalize_host` in buzz-core), but the saved community relay URL keeps
+ * whatever casing the user typed (DNS is case-insensitive, so an uppercase
+ * host connects fine). A raw string comparison between the two misclassifies
+ * the relay's own media URLs as external and skips the authenticated proxy.
+ * `new URL().origin` lowercases scheme + host and drops default ports.
+ *
+ * Returns null for unparseable input.
+ */
+function canonicalOrigin(url: string): string | null {
+ try {
+ return new URL(url).origin;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Monotonic cache generation. Async lookups capture the current generation and
+ * may only publish results while it is still current. This prevents a lookup
+ * started for the previous community from repopulating caches after reset.
+ */
+let cacheGeneration = 0;
+
const POLL_INTERVAL_MS = 100;
const POLL_TIMEOUT_MS = 5000;
@@ -38,20 +68,25 @@ const POLL_TIMEOUT_MS = 5000;
* Returns the port, or null if the proxy never came up.
*/
async function fetchProxyPort(): Promise {
+ const generation = cacheGeneration;
+
// Fetch relay origin in parallel — fire-and-forget, no retry needed.
if (!cachedRelayOrigin) {
invoke("get_relay_http_url")
.then((url) => {
- cachedRelayOrigin = url.replace(/\/+$/, "");
+ if (generation === cacheGeneration) {
+ cachedRelayOrigin = canonicalOrigin(url);
+ }
})
.catch(() => {});
}
const deadline = Date.now() + POLL_TIMEOUT_MS;
- while (Date.now() < deadline) {
+ while (Date.now() < deadline && generation === cacheGeneration) {
try {
const port = await invoke("get_media_proxy_port");
if (port > 0) {
+ if (generation !== cacheGeneration) return null;
cachedPort = port;
return port;
}
@@ -75,6 +110,7 @@ if (typeof window !== "undefined") {
* and relay origin for the new community.
*/
export function resetMediaCaches(): void {
+ cacheGeneration += 1;
cachedPort = null;
portPromise = null;
cachedRelayOrigin = null;
@@ -104,8 +140,14 @@ export function rewriteRelayUrl(url: string): string {
// (different origin) pass through unchanged — they work fine via WKWebView.
// If the relay origin isn't cached yet, fall through to the rewrite path
// as a safe default (relay URLs need the proxy to avoid Cloudflare 403s).
- if (cachedRelayOrigin && !url.startsWith(`${cachedRelayOrigin}/`)) {
- return url;
+ // Compare canonicalized origins: hosts are case-insensitive, and the relay
+ // always returns lowercased media URLs even when the saved community URL
+ // was typed with uppercase (e.g. wss://PENDING-SEED.communities.buzz.xyz).
+ if (cachedRelayOrigin) {
+ const urlOrigin = canonicalOrigin(url);
+ if (urlOrigin !== cachedRelayOrigin) {
+ return url;
+ }
}
if (cachedPort && cachedPort > 0) {
From 85fc64835e79a0310990e253877abe1b3d6a654c Mon Sep 17 00:00:00 2001
From: morgmart <98432065+morgmart@users.noreply.github.com>
Date: Fri, 17 Jul 2026 04:39:03 -0700
Subject: [PATCH 10/12] Welcome new users with a live agent team kickoff
(#1998)
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
---
.env.example | 5 +
crates/buzz-acp/src/lib.rs | 16 +-
desktop/src-tauri/src/commands/canvas.rs | 14 +-
desktop/src-tauri/src/commands/messages.rs | 247 ++----
.../src-tauri/src/commands/messages_tests.rs | 193 +++++
desktop/src-tauri/src/lib.rs | 1 +
.../src-tauri/src/managed_agents/runtime.rs | 1 +
desktop/src-tauri/src/managed_agents/teams.rs | 7 +-
.../src/managed_agents/teams_tests.rs | 62 +-
desktop/src-tauri/src/managed_agents/types.rs | 1 +
.../src/features/agents/knownAgentPubkeys.ts | 20 +
.../src/features/channels/ui/ChannelPane.tsx | 102 +--
.../features/channels/ui/ChannelPane.types.ts | 3 +
.../features/channels/ui/ChannelScreen.tsx | 32 +-
.../channels/ui/WelcomeComposerBanner.tsx | 4 +-
.../features/channels/ui/useChannelIntro.tsx | 127 +++
.../messages/lib/formatTimelineMessages.ts | 12 +
.../messages/lib/timelineSnapshot.test.mjs | 14 +
.../features/messages/lib/timelineSnapshot.ts | 7 +-
.../messages/ui/ChannelIntroBlock.tsx | 125 +++
.../src/features/messages/ui/MessageRow.tsx | 19 +
.../features/messages/ui/MessageTimeline.tsx | 208 +----
.../messages/ui/TimelineMessageList.tsx | 16 +
.../features/onboarding/devFreshOnboarding.ts | 16 +
desktop/src/features/onboarding/hooks.ts | 189 ++++-
.../features/onboarding/ui/OnboardingFlow.tsx | 2 +
.../features/onboarding/ui/ProfileStep.tsx | 26 +-
.../onboarding/useWelcomeKickoffEntrance.ts | 43 ++
.../src/features/onboarding/welcome.test.mjs | 22 +
desktop/src/features/onboarding/welcome.ts | 17 +
.../onboarding/welcomeCanvas.test.mjs | 62 ++
.../src/features/onboarding/welcomeCanvas.ts | 41 +
.../features/onboarding/welcomeGuide.test.mjs | 114 +++
.../src/features/onboarding/welcomeGuide.ts | 242 ++++--
.../onboarding/welcomeKickoff.test.mjs | 297 +++++++
.../src/features/onboarding/welcomeKickoff.ts | 727 ++++++++++++++++++
desktop/src/shared/api/tauri.ts | 10 +-
.../api/tauriManagedAgentMessageMarkers.ts | 15 +
.../shared/api/tauriManagedAgentMessages.ts | 6 +
desktop/src/shared/api/types.ts | 1 +
desktop/src/shared/styles/globals.css | 1 +
desktop/src/shared/styles/globals/motion.css | 57 ++
.../src/shared/styles/globals/motion.test.mjs | 24 +
desktop/src/testing/e2eBridge.ts | 56 +-
desktop/tests/e2e/onboarding.spec.ts | 256 ++----
45 files changed, 2698 insertions(+), 762 deletions(-)
create mode 100644 desktop/src-tauri/src/commands/messages_tests.rs
create mode 100644 desktop/src/features/channels/ui/useChannelIntro.tsx
create mode 100644 desktop/src/features/messages/ui/ChannelIntroBlock.tsx
create mode 100644 desktop/src/features/onboarding/devFreshOnboarding.ts
create mode 100644 desktop/src/features/onboarding/useWelcomeKickoffEntrance.ts
create mode 100644 desktop/src/features/onboarding/welcomeCanvas.test.mjs
create mode 100644 desktop/src/features/onboarding/welcomeCanvas.ts
create mode 100644 desktop/src/features/onboarding/welcomeKickoff.test.mjs
create mode 100644 desktop/src/features/onboarding/welcomeKickoff.ts
create mode 100644 desktop/src/shared/api/tauriManagedAgentMessageMarkers.ts
create mode 100644 desktop/src/shared/styles/globals/motion.css
create mode 100644 desktop/src/shared/styles/globals/motion.test.mjs
diff --git a/.env.example b/.env.example
index fb1a3c2aa0..9e35203524 100644
--- a/.env.example
+++ b/.env.example
@@ -159,6 +159,11 @@ RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_
# Path to a file containing the heartbeat prompt.
# BUZZ_ACP_HEARTBEAT_PROMPT_FILE=
+# ── Desktop development ──────────────────────────────────────────────────────
+# DEV-only: replay first-run onboarding and the Welcome Team kickoff on each
+# app launch while keeping the current identity and relay data.
+# VITE_BUZZ_FORCE_FRESH_ONBOARDING=true
+
# ── Subscription & filtering ─────────────────────────────────────────────────
# Subscribe mode: "mentions" (default), "all", or "config" (rule-based).
# BUZZ_ACP_SUBSCRIBE=mentions
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 2b6e8d45d1..3d86125298 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -1286,12 +1286,6 @@ async fn tokio_main() -> Result<()> {
let presence_publisher = relay.event_publisher();
let presence_keys = config.keys.clone();
- if config.presence_enabled {
- match publish_presence(&presence_publisher, &presence_keys, "online").await {
- Ok(_) => tracing::info!("presence set to online"),
- Err(e) => tracing::warn!("failed to set initial presence: {e}"),
- }
- }
// Priority: BUZZ_AUTH_TAG (NIP-OA attestation) → --agent-owner flag.
let startup_owner: Option = resolve_agent_owner(&config);
@@ -1413,6 +1407,16 @@ async fn tokio_main() -> Result<()> {
}
}
+ // Online means the harness can receive work, not merely that its socket is
+ // connected. Publishing after channel subscriptions gives desktop callers
+ // a durable readiness boundary before they send a startup mention.
+ if config.presence_enabled {
+ match publish_presence(&presence_publisher, &presence_keys, "online").await {
+ Ok(_) => tracing::info!("presence set to online"),
+ Err(e) => tracing::warn!("failed to set initial presence: {e}"),
+ }
+ }
+
let dedup_mode = config.dedup_mode;
let mut queue =
EventQueue::new(dedup_mode).with_in_flight_deadline(config.max_turn_duration_secs);
diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs
index c40e5d4e9a..191fafe60d 100644
--- a/desktop/src-tauri/src/commands/canvas.rs
+++ b/desktop/src-tauri/src/commands/canvas.rs
@@ -23,14 +23,22 @@ pub async fn get_canvas(
.await?;
let Some(event) = events.first() else {
- return Ok(serde_json::json!({ "content": "" }));
+ // Explicit nulls: the TS caller distinguishes "no canvas yet" from
+ // "canvas exists" via `updated_at`/`author`, so these keys must be
+ // present (absent keys deserialize as `undefined`, not `null`).
+ return Ok(serde_json::json!({
+ "content": "",
+ "event_id": null,
+ "updated_at": null,
+ "author": null,
+ }));
};
Ok(serde_json::json!({
"content": event.content,
"event_id": event.id.to_hex(),
- "created_at": event.created_at.as_secs(),
- "pubkey": event.pubkey.to_hex(),
+ "updated_at": event.created_at.as_secs(),
+ "author": event.pubkey.to_hex(),
}))
}
diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs
index d2c4367429..c1d81db8e0 100644
--- a/desktop/src-tauri/src/commands/messages.rs
+++ b/desktop/src-tauri/src/commands/messages.rs
@@ -622,14 +622,42 @@ async fn find_managed_agent_channel_message_by_marker(
fn marker_author_for_scope<'a>(
marker_scope: Option<&str>,
- agent_pubkey: &'a str,
-) -> Option<&'a str> {
+ agent_pubkey: Option<&'a str>,
+) -> Result, String> {
match marker_scope {
- Some("channel") => None,
- _ => Some(agent_pubkey),
+ Some("channel") => Ok(None),
+ Some("agent") | None => agent_pubkey
+ .map(Some)
+ .ok_or_else(|| "agent pubkey is required for agent-scoped markers".to_string()),
+ Some(scope) => Err(format!("unsupported marker scope: {scope}")),
}
}
+#[tauri::command]
+pub async fn has_managed_agent_channel_message_marker(
+ channel_id: String,
+ marker: String,
+ agent_pubkey: Option,
+ marker_scope: Option,
+ state: State<'_, AppState>,
+) -> Result {
+ uuid::Uuid::parse_str(&channel_id)
+ .map_err(|_| format!("invalid channel UUID: {channel_id}"))?;
+ let marker = marker.trim();
+ if marker.is_empty() {
+ return Err("message marker is required".into());
+ }
+ let agent_pubkey = agent_pubkey
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty());
+
+ let marker_author = marker_author_for_scope(marker_scope.as_deref(), agent_pubkey)?;
+ find_managed_agent_channel_message_by_marker(&state, marker_author, &channel_id, marker)
+ .await
+ .map(|event| event.is_some())
+}
+
fn stored_managed_agent_auth_tag(auth_tag: Option<&str>) -> Option {
auth_tag
.map(str::trim)
@@ -663,6 +691,27 @@ fn managed_agent_submission_auth_tag(
legacy_managed_agent_auth_tag(&owner_keys, agent_pubkey)
}
+fn build_managed_agent_channel_message(
+ channel_id: uuid::Uuid,
+ content: &str,
+ thread_ref: Option<&events::ThreadRef>,
+ mention_pubkeys: &[String],
+ client_tags: &[Vec],
+) -> Result {
+ let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect();
+ events::build_message_with_client_tags(
+ channel_id,
+ content,
+ thread_ref,
+ &mention_refs,
+ &[],
+ &[],
+ &[],
+ client_tags,
+ )
+}
+
+#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn send_managed_agent_channel_message(
agent_pubkey: String,
@@ -670,6 +719,9 @@ pub async fn send_managed_agent_channel_message(
content: String,
marker: Option,
marker_scope: Option,
+ mention_pubkeys: Option>,
+ parent_event_id: Option,
+ additional_markers: Option>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result {
@@ -706,11 +758,15 @@ pub async fn send_managed_agent_channel_message(
}
let submission_auth_tag =
managed_agent_submission_auth_tag(&record, &state, &keys.public_key())?;
+ let thread_ref = match parent_event_id.as_deref() {
+ Some(parent_id) => Some(resolve_thread_ref(parent_id, &state).await?),
+ None => None,
+ };
if let Some(marker) = marker.as_deref() {
if let Some(existing) = find_managed_agent_channel_message_by_marker(
&state,
- marker_author_for_scope(marker_scope.as_deref(), &record.pubkey),
+ marker_author_for_scope(marker_scope.as_deref(), Some(&record.pubkey))?,
&channel_id,
marker,
)
@@ -718,26 +774,34 @@ pub async fn send_managed_agent_channel_message(
{
return Ok(SendChannelMessageResponse {
event_id: existing.id.to_hex(),
- parent_event_id: None,
- root_event_id: None,
- depth: 0,
+ parent_event_id: thread_ref
+ .as_ref()
+ .map(|reference| reference.parent_event_id.to_hex()),
+ root_event_id: thread_ref
+ .as_ref()
+ .map(|reference| reference.root_event_id.to_hex()),
+ depth: if thread_ref.is_some() { 1 } else { 0 },
created_at: existing.created_at.as_secs() as i64,
});
}
}
- let client_tags = marker
+ let mut client_tags = marker
.as_deref()
.map(|marker| vec![vec!["client".to_string(), marker.to_string()]])
.unwrap_or_default();
- let builder = events::build_message_with_client_tags(
+ for marker in additional_markers.unwrap_or_default() {
+ let marker = marker.trim();
+ if !marker.is_empty() {
+ client_tags.push(vec!["client".to_string(), marker.to_string()]);
+ }
+ }
+ let mentions = mention_pubkeys.unwrap_or_default();
+ let builder = build_managed_agent_channel_message(
channel_uuid,
trimmed,
- None,
- &[],
- &[],
- &[],
- &[],
+ thread_ref.as_ref(),
+ &mentions,
&client_tags,
)?;
let result =
@@ -745,157 +809,13 @@ pub async fn send_managed_agent_channel_message(
Ok(SendChannelMessageResponse {
event_id: result.event_id,
- parent_event_id: None,
- root_event_id: None,
- depth: 0,
+ parent_event_id: parent_event_id.clone(),
+ root_event_id: thread_ref.map(|reference| reference.root_event_id.to_hex()),
+ depth: if parent_event_id.is_some() { 1 } else { 0 },
created_at: chrono::Utc::now().timestamp(),
})
}
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn marker_author_scope_defaults_to_agent() {
- assert_eq!(
- marker_author_for_scope(None, "agent-pubkey"),
- Some("agent-pubkey")
- );
- assert_eq!(
- marker_author_for_scope(Some("agent"), "agent-pubkey"),
- Some("agent-pubkey")
- );
- assert_eq!(
- marker_author_for_scope(Some("unknown"), "agent-pubkey"),
- Some("agent-pubkey")
- );
- }
-
- #[test]
- fn marker_author_scope_can_dedupe_across_channel() {
- assert_eq!(
- marker_author_for_scope(Some("channel"), "agent-pubkey"),
- None
- );
- }
-
- #[test]
- fn search_messages_filter_requests_prefix_mode_for_topbar_typeahead() {
- let filter = build_search_messages_filter(" pro ", 12, Some("channel-1"));
-
- assert_eq!(filter["search"], serde_json::json!("pro"));
- assert_eq!(filter["search_mode"], serde_json::json!("prefix"));
- assert_eq!(filter["limit"], serde_json::json!(12));
- assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
- }
-
- #[test]
- fn channel_messages_before_filter_sends_before_id_the_relay_reads() {
- // The relay bridge's `extract_before_id` reads the composite tiebreak
- // from `before_id`. If this filter sent the id under any other key (an
- // earlier cut used `n`), the relay would silently drop the tiebreak and
- // the dense-second keyset would degrade to a bare inclusive `until` —
- // re-returning the same page forever. Pin the field name here so the
- // client/relay contract can't drift without a red test (the Playwright
- // mock reimplements the keyset in JS and cannot catch this).
- let filter =
- build_channel_messages_before_filter("channel-1", 1_700_000_000, Some("ab"), 200);
-
- assert_eq!(filter["until"], serde_json::json!(1_700_000_000));
- assert_eq!(filter["before_id"], serde_json::json!("ab"));
- assert_eq!(filter["limit"], serde_json::json!(200));
- assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
- assert!(
- !filter.contains_key("n"),
- "tiebreak must be `before_id`, not the `n` alias the relay ignores"
- );
- }
-
- #[test]
- fn thread_replies_filter_carries_non_p_gated_kinds_to_clear_the_gate() {
- // The relay bridge p-gates EVERY filter before routing
- // (`p_gated_filters_authorized`): a kindless filter "could match" a
- // p-gated kind, so it demands a `#p` tag we don't send -> HTTP 403,
- // before the thread-subtree query runs. The headline Lane-1 fix
- // (`useThreadReplies` closing the descendant gap) then fails on every
- // call against a real relay. So the thread filter MUST carry `kinds`,
- // and every kind MUST be non-p-gated (else the gate still fires). The
- // Playwright mock does not model p-gating, so this unit test is the
- // only guard against the client/relay auth contract drifting.
- let filter = build_thread_replies_filter("root-hex", Some("channel-1"), 64, 200, None);
-
- let kinds = filter
- .get("kinds")
- .and_then(|v| v.as_array())
- .expect("thread filter must carry `kinds` so the p-gate passes");
- assert!(!kinds.is_empty(), "kinds must be non-empty");
- for kind in kinds {
- let k = kind.as_u64().expect("kind is a number") as u32;
- assert!(
- !buzz_core_pkg::kind::P_GATED_KINDS.contains(&k),
- "kind {k} is p-gated; a p-gated kind in the filter re-triggers the \
- 403 that this fix exists to prevent"
- );
- }
- assert_eq!(filter["#e"], serde_json::json!(["root-hex"]));
- assert_eq!(filter["depth_limit"], serde_json::json!(64));
- assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
- }
-
- #[test]
- fn thread_replies_filter_pages_with_composite_cursor() {
- // When a cursor is supplied, both the timestamp and the event-id
- // tiebreak must be emitted (`thread_cursor` + `thread_cursor_id`), else
- // paging degrades to timestamp-only and drops same-second replies.
- let cursor = crate::models::ThreadCursor {
- created_at: 1_700_000_000,
- event_id: "abcd".to_string(),
- };
- let filter = build_thread_replies_filter("root-hex", None, 64, 200, Some(&cursor));
- assert_eq!(filter["thread_cursor"], serde_json::json!(1_700_000_000));
- assert_eq!(filter["thread_cursor_id"], serde_json::json!("abcd"));
- assert!(
- !filter.contains_key("#h"),
- "no channel_id -> no #h scope in the filter"
- );
- }
-
- #[test]
- fn stored_managed_agent_auth_tag_trims_blank_values() {
- assert_eq!(
- stored_managed_agent_auth_tag(Some(" [\"auth\",\"owner\",\"\",\"sig\"] ")),
- Some("[\"auth\",\"owner\",\"\",\"sig\"]".to_string())
- );
- assert_eq!(stored_managed_agent_auth_tag(Some(" ")), None);
- assert_eq!(stored_managed_agent_auth_tag(None), None);
- }
-
- #[test]
- fn legacy_managed_agent_auth_tag_verifies_for_agent_pubkey() {
- let owner_keys = Keys::generate();
- let agent_keys = Keys::generate();
-
- let tag = legacy_managed_agent_auth_tag(&owner_keys, &agent_keys.public_key())
- .expect("legacy auth tag should compute")
- .expect("legacy auth tag should be present");
-
- let owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&tag, &agent_keys.public_key())
- .expect("legacy auth tag should verify");
- assert_eq!(owner, owner_keys.public_key());
- }
-
- #[test]
- fn legacy_managed_agent_auth_tag_skips_self_attestation() {
- let owner_keys = Keys::generate();
-
- let tag = legacy_managed_agent_auth_tag(&owner_keys, &owner_keys.public_key())
- .expect("self-attestation should be skipped");
-
- assert_eq!(tag, None);
- }
-}
-
#[tauri::command]
pub async fn add_reaction(
event_id: String,
@@ -1021,3 +941,6 @@ fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo {
category: category.to_string(),
}
}
+#[cfg(test)]
+#[path = "messages_tests.rs"]
+mod tests;
diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs
new file mode 100644
index 0000000000..3c08f0f113
--- /dev/null
+++ b/desktop/src-tauri/src/commands/messages_tests.rs
@@ -0,0 +1,193 @@
+use super::*;
+
+#[test]
+fn marker_author_scope_validates_scope_and_required_pubkey() {
+ assert_eq!(
+ marker_author_for_scope(None, Some("agent")),
+ Ok(Some("agent"))
+ );
+ assert_eq!(
+ marker_author_for_scope(Some("agent"), Some("agent")),
+ Ok(Some("agent"))
+ );
+ assert_eq!(marker_author_for_scope(Some("channel"), None), Ok(None));
+ assert_eq!(
+ marker_author_for_scope(Some("agent"), None),
+ Err("agent pubkey is required for agent-scoped markers".to_string())
+ );
+ assert_eq!(
+ marker_author_for_scope(None, None),
+ Err("agent pubkey is required for agent-scoped markers".to_string())
+ );
+ assert_eq!(
+ marker_author_for_scope(Some("unexpected"), Some("agent")),
+ Err("unsupported marker scope: unexpected".to_string())
+ );
+}
+
+#[test]
+fn managed_agent_message_builder_adds_mentions_and_client_marker() {
+ let pubkey = Keys::generate().public_key().to_hex();
+ let event = build_managed_agent_channel_message(
+ uuid::Uuid::new_v4(),
+ "Welcome!",
+ None,
+ std::slice::from_ref(&pubkey),
+ &[vec!["client".to_string(), "welcome-v1".to_string()]],
+ )
+ .expect("message should build")
+ .sign_with_keys(&Keys::generate())
+ .expect("message should sign");
+
+ assert!(event.tags.iter().any(|tag| {
+ let parts = tag.as_slice();
+ parts.len() >= 2 && parts[0] == "p" && parts[1] == pubkey
+ }));
+ assert!(event_has_client_marker(&event, "welcome-v1"));
+}
+
+#[test]
+fn managed_agent_message_builder_can_carry_multiple_client_markers() {
+ let event = build_managed_agent_channel_message(
+ uuid::Uuid::new_v4(),
+ "Welcome!",
+ None,
+ &[],
+ &[
+ vec!["client".to_string(), "opener-v1".to_string()],
+ vec!["client".to_string(), "closer-v1".to_string()],
+ ],
+ )
+ .expect("message should build")
+ .sign_with_keys(&Keys::generate())
+ .expect("message should sign");
+
+ assert!(event_has_client_marker(&event, "opener-v1"));
+ assert!(event_has_client_marker(&event, "closer-v1"));
+}
+
+#[test]
+fn managed_agent_message_builder_rejects_invalid_mentions() {
+ let error = build_managed_agent_channel_message(
+ uuid::Uuid::new_v4(),
+ "Welcome!",
+ None,
+ &["not-a-pubkey".to_string()],
+ &[],
+ )
+ .expect_err("invalid mentions should fail");
+ assert!(error.contains("pubkey must be a 64-character hex string"));
+}
+#[test]
+fn search_messages_filter_requests_prefix_mode_for_topbar_typeahead() {
+ let filter = build_search_messages_filter(" pro ", 12, Some("channel-1"));
+
+ assert_eq!(filter["search"], serde_json::json!("pro"));
+ assert_eq!(filter["search_mode"], serde_json::json!("prefix"));
+ assert_eq!(filter["limit"], serde_json::json!(12));
+ assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
+}
+
+#[test]
+fn channel_messages_before_filter_sends_before_id_the_relay_reads() {
+ // The relay bridge's `extract_before_id` reads the composite tiebreak
+ // from `before_id`. If this filter sent the id under any other key (an
+ // earlier cut used `n`), the relay would silently drop the tiebreak and
+ // the dense-second keyset would degrade to a bare inclusive `until` —
+ // re-returning the same page forever. Pin the field name here so the
+ // client/relay contract can't drift without a red test (the Playwright
+ // mock reimplements the keyset in JS and cannot catch this).
+ let filter = build_channel_messages_before_filter("channel-1", 1_700_000_000, Some("ab"), 200);
+
+ assert_eq!(filter["until"], serde_json::json!(1_700_000_000));
+ assert_eq!(filter["before_id"], serde_json::json!("ab"));
+ assert_eq!(filter["limit"], serde_json::json!(200));
+ assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
+ assert!(
+ !filter.contains_key("n"),
+ "tiebreak must be `before_id`, not the `n` alias the relay ignores"
+ );
+}
+
+#[test]
+fn thread_replies_filter_carries_non_p_gated_kinds_to_clear_the_gate() {
+ // The relay bridge p-gates EVERY filter before routing
+ // (`p_gated_filters_authorized`): a kindless filter "could match" a
+ // p-gated kind, so it demands a `#p` tag we don't send -> HTTP 403,
+ // before the thread-subtree query runs. The headline Lane-1 fix
+ // (`useThreadReplies` closing the descendant gap) then fails on every
+ // call against a real relay. So the thread filter MUST carry `kinds`,
+ // and every kind MUST be non-p-gated (else the gate still fires). The
+ // Playwright mock does not model p-gating, so this unit test is the
+ // only guard against the client/relay auth contract drifting.
+ let filter = build_thread_replies_filter("root-hex", Some("channel-1"), 64, 200, None);
+
+ let kinds = filter
+ .get("kinds")
+ .and_then(|v| v.as_array())
+ .expect("thread filter must carry `kinds` so the p-gate passes");
+ assert!(!kinds.is_empty(), "kinds must be non-empty");
+ for kind in kinds {
+ let k = kind.as_u64().expect("kind is a number") as u32;
+ assert!(
+ !buzz_core_pkg::kind::P_GATED_KINDS.contains(&k),
+ "kind {k} is p-gated; a p-gated kind in the filter re-triggers the \
+ 403 that this fix exists to prevent"
+ );
+ }
+ assert_eq!(filter["#e"], serde_json::json!(["root-hex"]));
+ assert_eq!(filter["depth_limit"], serde_json::json!(64));
+ assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
+}
+
+#[test]
+fn thread_replies_filter_pages_with_composite_cursor() {
+ // When a cursor is supplied, both the timestamp and the event-id
+ // tiebreak must be emitted (`thread_cursor` + `thread_cursor_id`), else
+ // paging degrades to timestamp-only and drops same-second replies.
+ let cursor = crate::models::ThreadCursor {
+ created_at: 1_700_000_000,
+ event_id: "abcd".to_string(),
+ };
+ let filter = build_thread_replies_filter("root-hex", None, 64, 200, Some(&cursor));
+ assert_eq!(filter["thread_cursor"], serde_json::json!(1_700_000_000));
+ assert_eq!(filter["thread_cursor_id"], serde_json::json!("abcd"));
+ assert!(
+ !filter.contains_key("#h"),
+ "no channel_id -> no #h scope in the filter"
+ );
+}
+
+#[test]
+fn stored_managed_agent_auth_tag_trims_blank_values() {
+ assert_eq!(
+ stored_managed_agent_auth_tag(Some(" [\"auth\",\"owner\",\"\",\"sig\"] ")),
+ Some("[\"auth\",\"owner\",\"\",\"sig\"]".to_string())
+ );
+ assert_eq!(stored_managed_agent_auth_tag(Some(" ")), None);
+ assert_eq!(stored_managed_agent_auth_tag(None), None);
+}
+
+#[test]
+fn legacy_managed_agent_auth_tag_verifies_for_agent_pubkey() {
+ let owner_keys = Keys::generate();
+ let agent_keys = Keys::generate();
+
+ let tag = legacy_managed_agent_auth_tag(&owner_keys, &agent_keys.public_key())
+ .expect("legacy auth tag should compute")
+ .expect("legacy auth tag should be present");
+
+ let owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&tag, &agent_keys.public_key())
+ .expect("legacy auth tag should verify");
+ assert_eq!(owner, owner_keys.public_key());
+}
+
+#[test]
+fn legacy_managed_agent_auth_tag_skips_self_attestation() {
+ let owner_keys = Keys::generate();
+
+ let tag = legacy_managed_agent_auth_tag(&owner_keys, &owner_keys.public_key())
+ .expect("self-attestation should be skipped");
+
+ assert_eq!(tag, None);
+}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 276f8d14d6..dadd2198b8 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -811,6 +811,7 @@ pub fn run() {
search_messages,
send_channel_message,
send_managed_agent_channel_message,
+ has_managed_agent_channel_message_marker,
get_forum_posts,
get_forum_thread,
get_thread_replies,
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 2c15813e48..31b31e00d0 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -1361,6 +1361,7 @@ pub fn build_managed_agent_summary(
pubkey: record.pubkey.clone(),
name: record.name.clone(),
persona_id: record.persona_id.clone(),
+ team_id: record.team_id.clone(),
relay_url: record.relay_url.clone(),
acp_command: record.acp_command.clone(),
agent_command: effective_command,
diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs
index 6ee8c0b2ed..937893d531 100644
--- a/desktop/src-tauri/src/managed_agents/teams.rs
+++ b/desktop/src-tauri/src/managed_agents/teams.rs
@@ -31,7 +31,12 @@ struct BuiltInTeam {
persona_ids: &'static [&'static str],
}
-const BUILT_IN_TEAMS: &[BuiltInTeam] = &[];
+const BUILT_IN_TEAMS: &[BuiltInTeam] = &[BuiltInTeam {
+ id: "builtin-team:welcome",
+ name: "Welcome Team",
+ description: Some("A friendly starter trio ready to help you plan, create, and ship."),
+ persona_ids: &["builtin:fizz", "builtin:honey", "builtin:bumble"],
+}];
// Built-in teams that have been retired. A stored copy that still exactly
// matches its seed is purged on load (the user never touched it); customized
diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs
index 7e9c7656b4..140ac3cab9 100644
--- a/desktop/src-tauri/src/managed_agents/teams_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs
@@ -317,12 +317,62 @@ fn migration_customized_fizz_is_demoted_to_user_team() {
}
#[test]
-fn migration_empty_store_stays_empty() {
- // Fresh installs see no teams and no spurious changed flag.
+fn welcome_team_is_seeded_and_idempotent() {
let (records, changed) = merge_teams(Vec::new(), "2026-07-01T00:00:00Z");
+ assert!(changed);
+ assert_eq!(records.len(), 1);
+ let welcome = &records[0];
+ assert_eq!(welcome.id, "builtin-team:welcome");
+ assert_eq!(welcome.name, "Welcome Team");
+ assert_eq!(
+ welcome.description.as_deref(),
+ Some("A friendly starter trio ready to help you plan, create, and ship.")
+ );
+ assert_eq!(
+ welcome.persona_ids,
+ vec![
+ "builtin:fizz".to_string(),
+ "builtin:honey".to_string(),
+ "builtin:bumble".to_string(),
+ ]
+ );
+ assert!(welcome.is_builtin);
+
+ let expected = serde_json::to_value(&records).unwrap();
+ let (records_after_second_merge, changed) = merge_teams(records, "2026-07-02T00:00:00Z");
assert!(!changed);
- assert!(records.is_empty());
+ assert_eq!(
+ serde_json::to_value(records_after_second_merge).unwrap(),
+ expected
+ );
+}
+
+#[test]
+fn welcome_team_seed_does_not_overwrite_customization() {
+ let (mut records, _) = merge_teams(Vec::new(), "2026-07-01T00:00:00Z");
+ let welcome = records
+ .iter_mut()
+ .find(|team| team.id == "builtin-team:welcome")
+ .expect("welcome team should be seeded");
+ welcome.name = "My Welcome Team".to_string();
+ welcome.description = Some("My customized starter team.".to_string());
+ welcome.persona_ids = vec!["builtin:honey".to_string()];
+
+ let (records, changed) = merge_teams(records, "2026-07-02T00:00:00Z");
+
+ assert!(!changed);
+ let welcome = records
+ .iter()
+ .find(|team| team.id == "builtin-team:welcome")
+ .expect("customized welcome team should be preserved");
+ assert_eq!(welcome.name, "My Welcome Team");
+ assert_eq!(
+ welcome.description.as_deref(),
+ Some("My customized starter team.")
+ );
+ assert_eq!(welcome.persona_ids, vec!["builtin:honey".to_string()]);
+ assert!(welcome.is_builtin);
}
// ── load_teams_readonly tests ──────────────────────────────────────────
@@ -337,9 +387,9 @@ fn load_teams_readonly_absent_file_performs_no_write() {
let records = load_teams_readonly(&path).unwrap();
- // Returns the merged built-in list (currently empty since BUILT_IN_TEAMS
- // is empty, but the function ran merge_teams + sort_teams).
- assert!(records.is_empty());
+ // Returns the merged built-in list without persisting it.
+ assert_eq!(records.len(), 1);
+ assert_eq!(records[0].id, "builtin-team:welcome");
// The file must still NOT exist — no write-on-load side effect.
assert!(
diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs
index bb0781b643..7f69b77e0d 100644
--- a/desktop/src-tauri/src/managed_agents/types.rs
+++ b/desktop/src-tauri/src/managed_agents/types.rs
@@ -454,6 +454,7 @@ pub struct ManagedAgentSummary {
pub pubkey: String,
pub name: String,
pub persona_id: Option,
+ pub team_id: Option,
pub relay_url: String,
pub acp_command: String,
pub agent_command: String,
diff --git a/desktop/src/features/agents/knownAgentPubkeys.ts b/desktop/src/features/agents/knownAgentPubkeys.ts
index 4d1a4dda68..978509af09 100644
--- a/desktop/src/features/agents/knownAgentPubkeys.ts
+++ b/desktop/src/features/agents/knownAgentPubkeys.ts
@@ -21,3 +21,23 @@ export function mergeKnownAgentPubkeys(
}
return pubkeys;
}
+
+/**
+ * Channel-scoped variant: the managed ∪ relay baseline plus this channel's
+ * bot members (role `bot` or `isAgent`), so member-only agents are included.
+ */
+export function mergeChannelKnownAgentPubkeys(
+ channelMembers:
+ | readonly { pubkey: string; role: string; isAgent: boolean }[]
+ | undefined,
+ managedAgents: readonly { pubkey: string }[] | undefined,
+ relayAgents: readonly { pubkey: string }[] | undefined,
+): ReadonlySet {
+ const pubkeys = new Set(mergeKnownAgentPubkeys(managedAgents, relayAgents));
+ for (const member of channelMembers ?? []) {
+ if (member.role === "bot" || member.isAgent) {
+ pubkeys.add(normalizePubkey(member.pubkey));
+ }
+ }
+ return pubkeys;
+}
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 837b15fcf9..77bd557c97 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -1,6 +1,5 @@
import * as React from "react";
-import { Bot, Hash, LogIn, Plus, Sparkles, UserPlus } from "lucide-react";
-import { HashSearch } from "@/shared/ui/icons";
+import { Hash, LogIn } from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
@@ -45,11 +44,10 @@ import {
type WelcomeComposerBannerState,
} from "@/features/channels/ui/WelcomeComposerBanner";
import {
- getChannelIntroDescription,
- getChannelIntroKind,
isWelcomeSetupSystemMessage,
mentionsKnownAgent,
} from "@/features/channels/ui/ChannelPane.helpers";
+import { useChannelIntro } from "@/features/channels/ui/useChannelIntro";
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection";
import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel";
@@ -57,10 +55,7 @@ import { Button } from "@/shared/ui/button";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
import type { TimelineMessage } from "@/features/messages/types";
-import {
- isWelcomeChannel,
- isWelcomeExperienceChannel as isWelcomeExperience,
-} from "@/features/onboarding/welcome";
+import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { channelChrome } from "@/shared/layout/chromeLayout";
@@ -91,6 +86,8 @@ export const ChannelPane = React.memo(function ChannelPane({
isSinglePanelView = false,
isSending,
isTimelineLoading,
+ entranceMessageId = null,
+ onEntranceMessageComplete,
messages,
threadSummaries,
firstUnreadMessageId = null,
@@ -435,89 +432,20 @@ export const ChannelPane = React.memo(function ChannelPane({
[activeChannel, currentPubkey, profiles],
);
- const channelIntro = React.useMemo(() => {
- if (!activeChannel || activeChannel.channelType === "dm") {
- return null;
- }
-
- const actions = [];
- if (isWelcomeExperience(activeChannel)) {
- if (onBrowseChannels) {
- actions.push({
- icon: ,
- label: "Browse channels",
- onClick: onBrowseChannels,
- testId: "welcome-intro-action-browse-channels",
- });
- }
- if (onCreateChannel) {
- actions.push({
- icon: ,
- label: "Create a channel",
- onClick: onCreateChannel,
- testId: "welcome-intro-action-create-channel",
- });
- }
- if (onAddAgent) {
- actions.push({
- icon: ,
- label: "Create an agent",
- onClick: () =>
- onAddAgent({
- beforeSend: () =>
- messageTimelineRef.current?.scrollToBottomOnNextUpdate(),
- }),
- testId: "welcome-intro-action-create-agent",
- });
- }
- return {
- actions,
- channelKindLabel: isWelcomeChannel(activeChannel)
- ? "private welcome channel"
- : getChannelIntroKind(activeChannel),
- channelName: activeChannel.name,
- description: isWelcomeChannel(activeChannel)
- ? null
- : getChannelIntroDescription(activeChannel),
- icon: ,
- };
- }
-
- if (!activeChannel.archivedAt && activeChannel.isMember) {
- if (onAddAgent) {
- actions.push({
- description: "Add an agent here.",
- icon: ,
- label: "Create agent",
- onClick: onAddAgent,
- testId: "channel-intro-action-create-agent",
- });
- }
-
- if (onOpenMembers) {
- actions.push({
- description: "Invite members.",
- icon: ,
- label: "Add people",
- onClick: onOpenMembers,
- testId: "channel-intro-action-add-people",
- });
- }
- }
-
- return {
- actions,
- channelKindLabel: getChannelIntroKind(activeChannel),
- channelName: activeChannel.name,
- description: getChannelIntroDescription(activeChannel),
- };
- }, [
+ const handleWelcomeAddAgent = React.useCallback(() => {
+ onAddAgent?.({
+ beforeSend: () =>
+ messageTimelineRef.current?.scrollToBottomOnNextUpdate(),
+ });
+ }, [onAddAgent]);
+ const channelIntro = useChannelIntro({
activeChannel,
onAddAgent,
onBrowseChannels,
onCreateChannel,
onOpenMembers,
- ]);
+ onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined,
+ });
const visibleMessages = React.useMemo(() => {
if (!isWelcomeExperience(activeChannel)) {
return messages;
@@ -689,6 +617,8 @@ export const ChannelPane = React.memo(function ChannelPane({
: "No channel selected"
}
isLoading={isTimelineLoading}
+ entranceMessageId={entranceMessageId}
+ onEntranceMessageComplete={onEntranceMessageComplete}
mainEntries={mainTimelineEntries}
threadSummaries={threadSummaries}
messages={visibleMessages}
diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts
index 3004aa4e99..12644d0958 100644
--- a/desktop/src/features/channels/ui/ChannelPane.types.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.types.ts
@@ -53,6 +53,9 @@ export type ChannelPaneProps = {
isSinglePanelView?: boolean;
isSending: boolean;
isTimelineLoading: boolean;
+ /** Newly-created message that should receive the one-shot conversation arrival motion. */
+ entranceMessageId?: string | null;
+ onEntranceMessageComplete?: (messageId: string) => void;
messages: TimelineMessage[];
threadSummaries?: ReadonlyMap;
firstUnreadMessageId?: string | null;
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 8bfcae0fe6..fc147a63fb 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -25,8 +25,10 @@ import {
usePersonasQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
+import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys";
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide";
+import { useWelcomeKickoffEntrance } from "@/features/onboarding/useWelcomeKickoffEntrance";
import { useWelcomeAgentCreate } from "@/features/channels/useWelcomeAgentCreate";
import {
mergeMessages,
@@ -247,6 +249,14 @@ export function ChannelScreen({
return extraEvents.reduce(mergeMessages, currentMessages);
}, [activeChannel, findEvents, messagesQuery.data, targetMessageEvents]);
const threadReplyEvents = threadRepliesQuery.data ?? EMPTY_RELAY_EVENTS;
+ const {
+ entranceMessageId: welcomeEntranceMessageId,
+ handleEntranceComplete: handleWelcomeEntranceComplete,
+ } = useWelcomeKickoffEntrance(
+ activeChannel,
+ resolvedMessages,
+ threadReplyEvents,
+ );
const messageEventProfilePubkeys = useMessageEventProfilePubkeys(
resolvedMessages,
threadReplyEvents,
@@ -284,21 +294,11 @@ export function ChannelScreen({
});
const relayAgentsQuery = useRelayAgentsQuery();
const relayAgents = relayAgentsQuery.data ?? [];
- const knownAgentPubkeys = React.useMemo(() => {
- const pubkeys = new Set();
- for (const member of channelMembers ?? []) {
- if (member.role === "bot" || member.isAgent) {
- pubkeys.add(normalizePubkey(member.pubkey));
- }
- }
- for (const agent of managedAgents) {
- pubkeys.add(normalizePubkey(agent.pubkey));
- }
- for (const agent of relayAgents) {
- pubkeys.add(normalizePubkey(agent.pubkey));
- }
- return pubkeys;
- }, [channelMembers, managedAgents, relayAgents]);
+ const knownAgentPubkeys = React.useMemo(
+ () =>
+ mergeChannelKnownAgentPubkeys(channelMembers, managedAgents, relayAgents),
+ [channelMembers, managedAgents, relayAgents],
+ );
const messageProfilePubkeys = React.useMemo(
() => [
...new Set([
@@ -840,6 +840,8 @@ export function ChannelScreen({
onCreateChannel={openCreateChannel}
onOpenMembers={handleOpenMembersSidebar}
isFetchingOlder={isFetchingOlder}
+ entranceMessageId={welcomeEntranceMessageId}
+ onEntranceMessageComplete={handleWelcomeEntranceComplete}
editTarget={
editTargetMessage
? {
diff --git a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx
index 79978925ea..0fd62cdbd3 100644
--- a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx
+++ b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx
@@ -391,8 +391,8 @@ export function WelcomeComposerBanner({ state }: WelcomeComposerBannerProps) {
key="prompt-copy"
variants={welcomeComposerBannerContentVariants}
>
- Try mentioning to chat with an
- agent in this channel.
+ Mention or another teammate
+ whenever you want their help.
)}
diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx
new file mode 100644
index 0000000000..c22bbff2c2
--- /dev/null
+++ b/desktop/src/features/channels/ui/useChannelIntro.tsx
@@ -0,0 +1,127 @@
+import * as React from "react";
+import { Bot, Plus, Sparkles, UserPlus } from "lucide-react";
+
+import {
+ getChannelIntroDescription,
+ getChannelIntroKind,
+} from "@/features/channels/ui/ChannelPane.helpers";
+import {
+ isWelcomeChannel,
+ isWelcomeExperienceChannel,
+} from "@/features/onboarding/welcome";
+import type { Channel } from "@/shared/api/types";
+import { HashSearch } from "@/shared/ui/icons";
+
+type ChannelIntroAction = {
+ description?: string;
+ icon: React.ReactNode;
+ label: string;
+ onClick: () => void;
+ testId?: string;
+};
+
+/**
+ * Builds the empty-channel intro block (heading, description, action cards)
+ * for the channel timeline. The Welcome channel gets its onboarding trio
+ * (browse / create channel / create agent); other channels get contextual
+ * member actions.
+ */
+export function useChannelIntro({
+ activeChannel,
+ onAddAgent,
+ onBrowseChannels,
+ onCreateChannel,
+ onOpenMembers,
+ onWelcomeAddAgent,
+}: {
+ activeChannel: Channel | null;
+ onAddAgent?: (options?: { beforeSend?: () => void }) => void;
+ onBrowseChannels?: () => void;
+ onCreateChannel?: () => void;
+ onOpenMembers?: () => void;
+ onWelcomeAddAgent?: () => void;
+}) {
+ return React.useMemo(() => {
+ if (!activeChannel || activeChannel.channelType === "dm") {
+ return null;
+ }
+
+ const actions: ChannelIntroAction[] = [];
+ if (isWelcomeExperienceChannel(activeChannel)) {
+ if (onBrowseChannels) {
+ actions.push({
+ icon: ,
+ label: "Browse channels",
+ onClick: onBrowseChannels,
+ testId: "welcome-intro-action-browse-channels",
+ });
+ }
+
+ if (onCreateChannel) {
+ actions.push({
+ icon: ,
+ label: "Create a channel",
+ onClick: onCreateChannel,
+ testId: "welcome-intro-action-create-channel",
+ });
+ }
+
+ if (onWelcomeAddAgent) {
+ actions.push({
+ icon: ,
+ label: "Create an agent",
+ onClick: onWelcomeAddAgent,
+ testId: "welcome-intro-action-create-agent",
+ });
+ }
+
+ return {
+ actions,
+ channelKindLabel: isWelcomeChannel(activeChannel)
+ ? "private welcome channel"
+ : getChannelIntroKind(activeChannel),
+ channelName: activeChannel.name,
+ description: isWelcomeChannel(activeChannel)
+ ? null
+ : getChannelIntroDescription(activeChannel),
+ icon: ,
+ };
+ }
+
+ if (!activeChannel.archivedAt && activeChannel.isMember) {
+ if (onAddAgent) {
+ actions.push({
+ description: "Add an agent here.",
+ icon: ,
+ label: "Create agent",
+ onClick: onAddAgent,
+ testId: "channel-intro-action-create-agent",
+ });
+ }
+
+ if (onOpenMembers) {
+ actions.push({
+ description: "Invite members.",
+ icon: ,
+ label: "Add people",
+ onClick: onOpenMembers,
+ testId: "channel-intro-action-add-people",
+ });
+ }
+ }
+
+ return {
+ actions,
+ channelKindLabel: getChannelIntroKind(activeChannel),
+ channelName: activeChannel.name,
+ description: getChannelIntroDescription(activeChannel),
+ };
+ }, [
+ activeChannel,
+ onAddAgent,
+ onBrowseChannels,
+ onCreateChannel,
+ onOpenMembers,
+ onWelcomeAddAgent,
+ ]);
+}
diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts
index 4b3bbd801e..6bbd1e8a12 100644
--- a/desktop/src/features/messages/lib/formatTimelineMessages.ts
+++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts
@@ -580,3 +580,15 @@ export function collectMessageMentionPubkeys(
return [...pubkeys];
}
+
+/** Every pubkey a channel surface needs profiles for: authors (signer +
+ * attributed actor), mentions, and reaction actors, deduplicated. */
+export function collectMessageProfilePubkeys(events: RelayEvent[]) {
+ return [
+ ...new Set([
+ ...collectMessageAuthorPubkeys(events),
+ ...collectMessageMentionPubkeys(events),
+ ...collectReactionActorPubkeys(events),
+ ]),
+ ];
+}
diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
index 7738d27f03..de410b5b8f 100644
--- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
+++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
@@ -403,6 +403,7 @@ test("timeline-body-surface: loading and deferred-pending both paint the single
assert.equal(
selectTimelineBodySurface({
deferredCount: 0,
+ hasPersistentIntro: false,
isLoading: true,
liveCount: 0,
}),
@@ -411,6 +412,7 @@ test("timeline-body-surface: loading and deferred-pending both paint the single
assert.equal(
selectTimelineBodySurface({
deferredCount: 0,
+ hasPersistentIntro: false,
isLoading: false,
liveCount: 3,
}),
@@ -418,6 +420,18 @@ test("timeline-body-surface: loading and deferred-pending both paint the single
);
});
+test("timeline-body-surface: first deferred message preserves a persistent channel intro", () => {
+ assert.equal(
+ selectTimelineBodySurface({
+ deferredCount: 0,
+ hasPersistentIntro: true,
+ isLoading: false,
+ liveCount: 1,
+ }),
+ "empty",
+ );
+});
+
test("timeline-body-surface: deferred rows paint the message list", () => {
assert.equal(
selectTimelineBodySurface({
diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts
index 2c492fec5a..14d415fb60 100644
--- a/desktop/src/features/messages/lib/timelineSnapshot.ts
+++ b/desktop/src/features/messages/lib/timelineSnapshot.ts
@@ -185,10 +185,12 @@ export type TimelineBodySurface = "skeleton" | "empty" | "list";
export function selectTimelineBodySurface({
deferredCount,
+ hasPersistentIntro = false,
isLoading,
liveCount,
}: {
deferredCount: number;
+ hasPersistentIntro?: boolean;
isLoading: boolean;
liveCount: number;
}): TimelineBodySurface {
@@ -198,7 +200,10 @@ export function selectTimelineBodySurface({
const renderState = selectDeferredListRenderState(deferredCount, liveCount);
if (renderState === "pending") {
- return "skeleton";
+ // A channel/DM intro is already meaningful stable content. Preserve it
+ // while React's deferred snapshot catches up to the first live message;
+ // replacing it with a skeleton makes an append look like a page reload.
+ return hasPersistentIntro ? "empty" : "skeleton";
}
return renderState;
}
diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
new file mode 100644
index 0000000000..98300acb3e
--- /dev/null
+++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
@@ -0,0 +1,125 @@
+import type * as React from "react";
+import { Hash } from "lucide-react";
+
+import { cn } from "@/shared/lib/cn";
+
+export type ChannelIntroAction = {
+ description?: string;
+ icon: React.ReactNode;
+ label: string;
+ onClick: () => void;
+ testId?: string;
+};
+
+export type ChannelIntro = {
+ actions?: ChannelIntroAction[];
+ channelKindLabel: string;
+ channelName: string;
+ description?: string | null;
+ icon?: React.ReactNode;
+};
+
+/**
+ * The empty-channel intro block: channel icon, heading, kind label, and
+ * action cards. Rendered both as the virtualized timeline's leading row and
+ * as the non-virtualized empty state — one component so the two surfaces
+ * cannot drift and the first message always lands below it without layout
+ * shift.
+ */
+export function ChannelIntroBlock({
+ className,
+ intro,
+}: {
+ className?: string;
+ intro: ChannelIntro;
+}) {
+ return (
+
+
+ {intro.icon ?? }
+
+
+ #{intro.channelName}
+
+
+ This is the beginning of the{" "}
+
+ {intro.channelKindLabel}
+
+ .
+
+ {intro.description ? (
+
+ {intro.description}
+
+ ) : null}
+ {intro.actions?.length ? (
+
+ {intro.actions.map((action) => {
+ const hasDescription = Boolean(action.description);
+
+ return (
+
+
+ {action.icon}
+
+
+
+ {action.label}
+
+ {action.description ? (
+
+ {action.description}
+
+ ) : null}
+
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx
index 8aef56277f..0a762bff35 100644
--- a/desktop/src/features/messages/ui/MessageRow.tsx
+++ b/desktop/src/features/messages/ui/MessageRow.tsx
@@ -86,6 +86,8 @@ export const MessageRow = React.memo(
onMarkRead,
onToggleReaction,
onReply,
+ onEntranceComplete,
+ playEntrance = false,
onUnfollowThread,
profiles,
searchQuery,
@@ -132,6 +134,8 @@ export const MessageRow = React.memo(
) => Promise;
onReply?: (message: TimelineMessage) => void;
onUnfollowThread?: (message: TimelineMessage) => void;
+ onEntranceComplete?: (messageId: string) => void;
+ playEntrance?: boolean;
profiles?: UserProfileLookup;
searchQuery?: string;
showDepthGuides?: boolean;
@@ -143,6 +147,17 @@ export const MessageRow = React.memo(
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState(
null,
);
+ const handleEntranceAnimationEnd = React.useCallback(
+ (event: React.AnimationEvent) => {
+ if (
+ playEntrance &&
+ event.animationName === "motion-enter-conversation"
+ ) {
+ onEntranceComplete?.(message.id);
+ }
+ },
+ [message.id, onEntranceComplete, playEntrance],
+ );
const {
reactions,
canToggle: canToggleReactions,
@@ -749,6 +764,7 @@ export const MessageRow = React.memo(
{isThreadReplyLayout ? (
<>
@@ -836,6 +853,8 @@ export const MessageRow = React.memo(
prev.onCollapseDescendants === next.onCollapseDescendants &&
prev.onCollapseDescendantsHoverChange ===
next.onCollapseDescendantsHoverChange &&
+ prev.onEntranceComplete === next.onEntranceComplete &&
+ prev.playEntrance === next.playEntrance &&
prev.profiles === next.profiles &&
prev.searchQuery === next.searchQuery &&
prev.videoReviewContext === next.videoReviewContext,
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index a8e949ece1..0740cf7da8 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -1,5 +1,4 @@
import * as React from "react";
-import { Hash } from "lucide-react";
import {
isDeferredTimelineSnapshotStale,
@@ -18,6 +17,7 @@ import { channelChrome } from "@/shared/layout/chromeLayout";
import { Spinner } from "@/shared/ui/spinner";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
+import { ChannelIntroBlock, type ChannelIntro } from "./ChannelIntroBlock";
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import type { TimelineVirtualizerApi } from "./TimelineMessageList";
@@ -51,6 +51,8 @@ type MessageTimelineProps = {
participants: DirectMessageIntroParticipant[];
} | null;
isLoading?: boolean;
+ entranceMessageId?: string | null;
+ onEntranceMessageComplete?: (messageId: string) => void;
emptyTitle?: string;
emptyDescription?: string;
currentPubkey?: string;
@@ -111,22 +113,6 @@ type MessageTimelineProps = {
threadUnreadCounts?: ReadonlyMap;
};
-type ChannelIntroAction = {
- description?: string;
- icon: React.ReactNode;
- label: string;
- onClick: () => void;
- testId?: string;
-};
-
-type ChannelIntro = {
- actions?: ChannelIntroAction[];
- channelKindLabel: string;
- channelName: string;
- description?: string | null;
- icon?: React.ReactNode;
-};
-
/** Stable empty reference used as the `useDeferredValue` initial value so the
* first render on channel entry stays light instead of blocking on the full
* message list. Must be module-level so its identity never changes. */
@@ -166,6 +152,8 @@ const MessageTimelineBase = React.forwardRef<
mainEntries,
threadSummaries,
isLoading = false,
+ entranceMessageId = null,
+ onEntranceMessageComplete,
emptyTitle = "No messages yet",
emptyDescription = "Send the first message to start the thread.",
currentPubkey,
@@ -282,6 +270,7 @@ const MessageTimelineBase = React.forwardRef<
const timelineBodySurface = selectTimelineBodySurface({
deferredCount: deferredMessages.length,
+ hasPersistentIntro: channelIntro !== null || directMessageIntro !== null,
isLoading: isLoading || isDeferredSnapshotStale,
liveCount: messages.length,
});
@@ -427,6 +416,7 @@ const MessageTimelineBase = React.forwardRef<
activeDirectMessageIntro !== null || activeChannelIntro !== null;
const showGenericEmpty = timelineBodySurface === "empty" && !showIntro;
const showMessageList = timelineBodySurface === "list";
+ const showChannelIntroOnly = activeChannelIntro !== null && !showMessageList;
const prepareForOwnMessage = React.useCallback(() => {
// The user's own send is the deliberate Zulip exception: release buffered
@@ -581,70 +571,7 @@ const MessageTimelineBase = React.forwardRef<
const virtualizedLeadingContent = React.useMemo(
() =>
activeChannelIntro ? (
-
-
- {activeChannelIntro.icon ?? (
-
- )}
-
-
- #{activeChannelIntro.channelName}
-
-
- This is the beginning of the{" "}
-
- {activeChannelIntro.channelKindLabel}
-
- .
-
- {activeChannelIntro.description ? (
-
- {activeChannelIntro.description}
-
- ) : null}
- {activeChannelIntro.actions?.length ? (
-
- {activeChannelIntro.actions.map((action) => (
-
-
- {action.icon}
-
-
-
- {action.label}
-
- {action.description ? (
-
- {action.description}
-
- ) : null}
-
-
- ))}
-
- ) : null}
-
+
) : activeDirectMessageIntro ? (
-
+ {showChannelIntroOnly ? null : (
+
+ )}
- {/* Fixed-height slot: an always-mounted height keeps the virtual
- spacer's offset stable across the load-older fetch toggle, so
- `scrollMargin` doesn't shift mid-fetch and yank the restore. The
- visible fetch spinner lives in the absolute overlay above, which
- does not occupy inline flow. */}
-
+ {/* Fixed-height history slot keeps the virtual spacer's offset
+ stable across load-older fetches. The intro-only state has no
+ history to anchor, so omitting it matches the virtualized
+ leading row's top geometry when the first message arrives. */}
+ {showChannelIntroOnly ? null : (
+
+ )}
-
- {activeChannelIntro.icon ?? (
-
- )}
-
-
- #{activeChannelIntro.channelName}
-
-
- This is the beginning of the{" "}
-
- {activeChannelIntro.channelKindLabel}
-
- .
-
- {activeChannelIntro.description ? (
-
- {activeChannelIntro.description}
-
- ) : null}
- {activeChannelIntro.actions?.length ? (
-
- {activeChannelIntro.actions.map((action) => {
- const hasDescription = Boolean(action.description);
-
- return (
-
-
- {action.icon}
-
-
-
- {action.label}
-
- {action.description ? (
-
- {action.description}
-
- ) : null}
-
-
- );
- })}
-
- ) : null}
-
+ /* Top-anchored like the virtualized leading row, so the
+ first message arrives below with zero layout shift. */
+
) : null}
{showGenericEmpty ? (
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index 1f57263fb8..a05e936f46 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -63,6 +63,8 @@ type TimelineMessageListProps = {
highlightedMessageId?: string | null;
isFollowingThreadById?: (rootId: string) => boolean;
isMessageUnreadById?: (messageId: string) => boolean;
+ entranceMessageId?: string | null;
+ onEntranceMessageComplete?: (messageId: string) => void;
messageFooters?: Record
;
/** Hoisted main-timeline entries (computed once in ChannelPane). Falls back
* to deriving them here when omitted (e.g. the deferred-render pass). */
@@ -130,6 +132,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
huddleMemberPubkeysPending = false,
isFollowingThreadById,
isMessageUnreadById,
+ entranceMessageId = null,
+ onEntranceMessageComplete,
messageFooters,
mainEntries,
threadSummaries,
@@ -262,6 +266,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
isFollowedByContinuation={item.isFollowedByContinuation}
isFollowingThreadById={isFollowingThreadById}
isUnread={isMessageUnreadById?.(item.entry.message.id)}
+ playEntrance={item.entry.message.id === entranceMessageId}
+ onEntranceComplete={onEntranceMessageComplete}
onDelete={onDelete}
onEdit={onEdit}
onMarkRead={onMarkRead}
@@ -290,6 +296,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
huddleMemberPubkeysPending,
isFollowingThreadById,
isMessageUnreadById,
+ entranceMessageId,
+ onEntranceMessageComplete,
messageFooters,
onDelete,
onEdit,
@@ -831,6 +839,8 @@ type MessageRowItemProps = Pick<
isContinuation?: boolean;
isFollowedByContinuation?: boolean;
isUnread?: boolean;
+ playEntrance?: boolean;
+ onEntranceComplete?: (messageId: string) => void;
videoReviewContext: ReturnType;
};
@@ -847,6 +857,8 @@ function MessageRowItem({
isFollowedByContinuation = false,
isFollowingThreadById,
isUnread,
+ playEntrance = false,
+ onEntranceComplete,
onDelete,
onEdit,
onMarkUnread,
@@ -893,6 +905,8 @@ function MessageRowItem({
}
isUnread={isUnread}
isContinuation={isContinuation}
+ playEntrance={playEntrance}
+ onEntranceComplete={onEntranceComplete}
message={message}
onDelete={canDelete}
onEdit={canEdit}
@@ -943,6 +957,8 @@ function MessageRowItem({
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isContinuation={isContinuation}
isUnread={isUnread}
+ playEntrance={playEntrance}
+ onEntranceComplete={onEntranceComplete}
message={message}
onDelete={canDelete}
onEdit={canEdit}
diff --git a/desktop/src/features/onboarding/devFreshOnboarding.ts b/desktop/src/features/onboarding/devFreshOnboarding.ts
new file mode 100644
index 0000000000..3a129d83ff
--- /dev/null
+++ b/desktop/src/features/onboarding/devFreshOnboarding.ts
@@ -0,0 +1,16 @@
+const FORCE_FRESH_VALUE = "true";
+
+/**
+ * Development-only switch for replaying first-run onboarding with an existing
+ * identity. Vite removes the DEV branch from production builds.
+ */
+export const forceFreshOnboarding =
+ import.meta.env?.DEV === true &&
+ import.meta.env.VITE_BUZZ_FORCE_FRESH_ONBOARDING === FORCE_FRESH_VALUE;
+
+const devBootId = forceFreshOnboarding ? crypto.randomUUID() : null;
+
+/** Give each forced-fresh webview boot its own relay marker namespace. */
+export function welcomeKickoffMarker(baseMarker: string) {
+ return devBootId ? `${baseMarker}.dev-${devBootId}` : baseMarker;
+}
diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts
index b8a14fb07d..7495af48eb 100644
--- a/desktop/src/features/onboarding/hooks.ts
+++ b/desktop/src/features/onboarding/hooks.ts
@@ -9,21 +9,62 @@ import {
import { channelsQueryKey } from "@/features/channels/hooks";
import {
ensureStarterChannels,
+ ensureWelcomeChannel,
hasEnsuredWelcomeChannel,
markWelcomeChannelEnsured,
notifyWelcomeChannelReady,
rememberPendingWelcomeChannel,
} from "@/features/onboarding/welcome";
-import { ensureWelcomeGuideIntro } from "@/features/onboarding/welcomeGuide";
+import { forceFreshOnboarding } from "@/features/onboarding/devFreshOnboarding";
+import { ensureWelcomeCanvas } from "@/features/onboarding/welcomeCanvas";
+import { ensureWelcomeTeam } from "@/features/onboarding/welcomeGuide";
import { useProfileQuery } from "@/features/profile/hooks";
import { useCommunities } from "@/features/communities/useCommunities";
import { useIdentityQuery } from "@/shared/api/hooks";
+import type { Channel } from "@/shared/api/types";
import {
+ createChannel,
+ deleteChannel,
ensureStarterChannels as ensureStarterChannelsCommand,
+ getChannelMembers,
getChannels,
+ updateChannel,
} from "@/shared/api/tauri";
-export type ChannelInitResult = { ok: true } | { ok: false; reason: string };
+const STARTER_CHANNEL_SETUP_TOAST_ID = "starter-channel-setup-error";
+
+export type ChannelInitResult =
+ | { ok: true; focusChannelId?: string }
+ | { ok: false; reason: string; focusChannelId?: string };
+
+const welcomeSeedPromises = new Map>();
+
+function seedWelcomeExperience(
+ queryClient: ReturnType,
+ channelId: string,
+ pubkey: string | null,
+ communityScope: string | null,
+) {
+ const key = `${communityScope ?? ""}:${channelId}`;
+ const current = welcomeSeedPromises.get(key);
+ if (current) return current;
+
+ const promise = (async () => {
+ try {
+ await ensureWelcomeTeam(channelId, communityScope);
+ await ensureWelcomeCanvas(channelId);
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
+ queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }),
+ ]);
+ markWelcomeChannelEnsured(pubkey, communityScope);
+ } catch (error) {
+ console.warn("Failed to seed the private Welcome experience.", error);
+ }
+ })().finally(() => welcomeSeedPromises.delete(key));
+ welcomeSeedPromises.set(key, promise);
+ return promise;
+}
export async function initializeStarterChannels(
queryClient: ReturnType,
@@ -38,33 +79,83 @@ export async function initializeStarterChannels(
},
): Promise {
try {
- const starterChannels = await ensureStarterChannels({
- ensureStarterChannels: ensureStarterChannelsCommand,
- getChannels,
- });
-
- markWelcomeChannelEnsured(pubkey, communityScope);
- if (focus) {
- rememberPendingWelcomeChannel(starterChannels.welcomeChannel.id);
- }
- queryClient.setQueryData(channelsQueryKey, starterChannels.channels);
+ let starterChannels: Awaited<
+ ReturnType
+ > | null = null;
+ let starterChannelsError: unknown = null;
try {
- await ensureWelcomeGuideIntro(
- starterChannels.welcomeChannel.id,
- communityScope,
- );
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
- queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }),
- ]);
+ starterChannels = await ensureStarterChannels({
+ ensureStarterChannels: ensureStarterChannelsCommand,
+ getChannels,
+ });
} catch (error) {
- console.warn("Failed to initialize Welcome guide.", error);
+ starterChannelsError = error;
+ console.warn("Failed to initialize public starter channels.", error);
}
+
+ const welcomeChannel = await ensureWelcomeChannel(
+ {
+ createChannel,
+ deleteChannel,
+ getChannelMembers,
+ getChannels,
+ updateChannel,
+ },
+ {
+ replaceExisting: forceFreshOnboarding,
+ },
+ );
+
+ const starterChannelList = starterChannels?.channels ?? [];
+ queryClient.setQueryData(channelsQueryKey, (channels = []) => {
+ const ensuredIds = new Set(
+ starterChannelList.map((channel) => channel.id),
+ );
+ ensuredIds.add(welcomeChannel.id);
+ return [
+ ...starterChannelList,
+ ...(starterChannelList.some(
+ (channel) => channel.id === welcomeChannel.id,
+ )
+ ? []
+ : [welcomeChannel]),
+ ...channels.filter((channel) => !ensuredIds.has(channel.id)),
+ ];
+ });
+ void seedWelcomeExperience(
+ queryClient,
+ welcomeChannel.id,
+ pubkey,
+ communityScope,
+ );
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
if (focus) {
- notifyWelcomeChannelReady(starterChannels.welcomeChannel.id);
+ // Refreshing can briefly replace the optimistic cache with an older relay
+ // snapshot. Reinsert the just-ensured channels before announcing focus so
+ // the route can consume the pending private Welcome channel immediately.
+ queryClient.setQueryData(channelsQueryKey, (channels = []) => {
+ const byId = new Map(
+ [...channels, ...starterChannelList, welcomeChannel].map(
+ (channel) => [channel.id, channel],
+ ),
+ );
+ return [...byId.values()];
+ });
+ rememberPendingWelcomeChannel(welcomeChannel.id);
+ notifyWelcomeChannelReady(welcomeChannel.id);
}
- return { ok: true };
+ const focusChannelId = focus ? welcomeChannel.id : undefined;
+ if (starterChannelsError) {
+ return {
+ ok: false,
+ focusChannelId,
+ reason:
+ starterChannelsError instanceof Error
+ ? starterChannelsError.message
+ : "Failed to set up starter channels",
+ };
+ }
+ return { ok: true, focusChannelId };
} catch (error) {
console.warn("Failed to initialize starter channels.", error);
return {
@@ -116,6 +207,9 @@ function onboardingCompletionStorageKey(pubkey: string) {
}
function readOnboardingCompletion(pubkey: string | null) {
+ if (forceFreshOnboarding) {
+ return false;
+ }
if (typeof window === "undefined" || !pubkey) {
return false;
}
@@ -236,6 +330,7 @@ export function useFirstRunOnboardingGate({
// main checkout. Skip unconditionally without waiting for the relay
// profile query. Guarded by !hasCompletedCurrentPubkey so it fires once.
if (
+ !forceFreshOnboarding &&
isSharedIdentity &&
currentPubkey &&
identityStatus === "success" &&
@@ -292,7 +387,9 @@ export function useFirstRunOnboardingGate({
// still exists, so onboarding is skipped. A missing event (new user, or no
// kind:0 on the relay) always shows onboarding regardless of display_name.
const hasExistingProfile =
- profileStatus === "success" && profileHasEvent === true;
+ !forceFreshOnboarding &&
+ profileStatus === "success" &&
+ profileHasEvent === true;
setGateState((current) =>
updateActiveGateState(current, currentPubkey, (activeGateState) => {
@@ -465,15 +562,14 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
focus,
pubkey: currentPubkey,
communityScope: starterChannelsCommunityScope,
+ }).finally(() => {
+ starterChannelsInitPromisesRef.current.delete(starterChannelsInitKey);
+ starterChannelsFocusIntentRef.current.delete(starterChannelsInitKey);
});
starterChannelsInitPromisesRef.current.set(
starterChannelsInitKey,
promise,
);
- void promise.finally(() => {
- starterChannelsInitPromisesRef.current.delete(starterChannelsInitKey);
- starterChannelsFocusIntentRef.current.delete(starterChannelsInitKey);
- });
return promise;
},
[currentPubkey, queryClient, starterChannelsCommunityScope],
@@ -501,13 +597,22 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
const showStarterRetryToast = React.useCallback(
(reason: string) => {
toast.error("Couldn't set up starter channels", {
+ id: STARTER_CHANNEL_SETUP_TOAST_ID,
action: {
label: "Retry",
- onClick: () => {
+ onClick: (event) => {
+ event.preventDefault();
void requestStarterChannels(true).then((result) => {
if (!result.ok) {
- showStarterRetryToast(result.reason);
+ window.setTimeout(
+ // Sonner dismisses an action toast as its click resolves, so
+ // recreate a failed retry after that dismissal completes.
+ () => showStarterRetryToast(result.reason),
+ 0,
+ );
+ return;
}
+ toast.dismiss(STARTER_CHANNEL_SETUP_TOAST_ID);
});
},
},
@@ -519,17 +624,19 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
const completeAndShowWelcome = React.useCallback(() => {
setIsCompletingStarterSetup(true);
- gateComplete();
- void requestStarterChannels(true)
- .then((starterResult) => {
- if (!starterResult.ok) {
- showStarterRetryToast(starterResult.reason);
- }
- return refreshChannelsCache(queryClient);
- })
- .finally(() => {
- setIsCompletingStarterSetup(false);
- });
+ void requestStarterChannels(true).then(async (starterResult) => {
+ await refreshChannelsCache(queryClient);
+ gateComplete();
+ setIsCompletingStarterSetup(false);
+ if (starterResult.focusChannelId) {
+ window.location.hash = `/channels/${encodeURIComponent(
+ starterResult.focusChannelId,
+ )}`;
+ }
+ if (!starterResult.ok) {
+ showStarterRetryToast(starterResult.reason);
+ }
+ });
}, [
gateComplete,
queryClient,
diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
index 85e20b7046..1928ee3835 100644
--- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
@@ -13,6 +13,7 @@ import {
persistCurrentIdentity,
} from "@/shared/api/tauriIdentity";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
+import { forceFreshOnboarding } from "@/features/onboarding/devFreshOnboarding";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { AvatarStep } from "./AvatarStep";
@@ -512,6 +513,7 @@ export function OnboardingFlow({
}}
direction={transitionDirection}
state={profileStepState}
+ usesExistingIdentity={forceFreshOnboarding}
/>
) : currentPage === "key-import" ? (
{isSaving ? (
+ ) : usesExistingIdentity ? (
+ "Continue"
) : (
"Create an identity key"
)}
@@ -305,16 +309,18 @@ export function ProfileStep({
) : null}
-
- I already have a key
-
+ {!usesExistingIdentity ? (
+
+ I already have a key
+
+ ) : null}
diff --git a/desktop/src/features/onboarding/useWelcomeKickoffEntrance.ts b/desktop/src/features/onboarding/useWelcomeKickoffEntrance.ts
new file mode 100644
index 0000000000..acd83fe062
--- /dev/null
+++ b/desktop/src/features/onboarding/useWelcomeKickoffEntrance.ts
@@ -0,0 +1,43 @@
+import * as React from "react";
+
+import { useWelcomeKickoff } from "@/features/onboarding/welcomeKickoff";
+import type { Channel, RelayEvent } from "@/shared/api/types";
+
+/**
+ * Runs the Welcome kickoff choreography and tracks the one-shot entrance
+ * animation for the newly posted opener.
+ *
+ * The opener lands in the main timeline with a one-shot entrance; the user
+ * opens the thread themselves. Auto-opening it caused a cascade of loading
+ * states (thread skeleton, "No replies" flash) on first run.
+ */
+export function useWelcomeKickoffEntrance(
+ activeChannel: Channel | null,
+ resolvedMessages: readonly RelayEvent[],
+ threadReplyEvents: readonly RelayEvent[],
+) {
+ const welcomeKickoffEvents = React.useMemo(
+ () => [...resolvedMessages, ...threadReplyEvents],
+ [resolvedMessages, threadReplyEvents],
+ );
+ const [entranceMessageId, setEntranceMessageId] = React.useState<
+ string | null
+ >(null);
+ React.useEffect(() => {
+ void activeChannel?.id;
+ setEntranceMessageId(null);
+ }, [activeChannel?.id]);
+ const handleKickoffOpenerPosted = React.useCallback((eventId: string) => {
+ setEntranceMessageId(eventId);
+ }, []);
+ const handleEntranceComplete = React.useCallback((eventId: string) => {
+ setEntranceMessageId((current) => (current === eventId ? null : current));
+ }, []);
+ useWelcomeKickoff(
+ activeChannel,
+ welcomeKickoffEvents,
+ handleKickoffOpenerPosted,
+ );
+
+ return { entranceMessageId, handleEntranceComplete };
+}
diff --git a/desktop/src/features/onboarding/welcome.test.mjs b/desktop/src/features/onboarding/welcome.test.mjs
index b09ceb0946..e0a57a4586 100644
--- a/desktop/src/features/onboarding/welcome.test.mjs
+++ b/desktop/src/features/onboarding/welcome.test.mjs
@@ -96,6 +96,28 @@ test("ensureWelcomeChannel creates a private Welcome channel when one is missing
]);
});
+test("ensureWelcomeChannel replaces existing Welcome in forced-fresh development mode", async () => {
+ const existingChannel = makeChannel({ id: "old-welcome" });
+ const calls = [];
+
+ const result = await ensureWelcomeChannel(
+ {
+ getChannels: async () => [existingChannel],
+ deleteChannel: async (channelId) => {
+ calls.push(["delete", channelId]);
+ },
+ createChannel: async () => {
+ calls.push(["create"]);
+ return makeChannel({ id: "fresh-welcome" });
+ },
+ },
+ { replaceExisting: true },
+ );
+
+ assert.equal(result.id, "fresh-welcome");
+ assert.deepEqual(calls, [["delete", "old-welcome"], ["create"]]);
+});
+
test("ensureWelcomeChannel clears ttl on an existing ephemeral Welcome channel", async () => {
const existingChannel = makeChannel({
description:
diff --git a/desktop/src/features/onboarding/welcome.ts b/desktop/src/features/onboarding/welcome.ts
index a60135e1db..98dc49cf06 100644
--- a/desktop/src/features/onboarding/welcome.ts
+++ b/desktop/src/features/onboarding/welcome.ts
@@ -26,6 +26,7 @@ const WELCOME_CHANNEL_ENSURED_STORAGE_KEY = "buzz-welcome-channel-ensured.v2";
type WelcomeChannelClient = {
createChannel: (input: CreateChannelInput) => Promise
;
+ deleteChannel?: (channelId: string) => Promise;
getChannels: () => Promise;
getChannelMembers?: (channelId: string) => Promise;
updateChannel?: (input: UpdateChannelInput) => Promise;
@@ -44,6 +45,8 @@ export type StarterChannelsResult = {
type WelcomeChannelOptions = {
allowedMemberPubkeys?: readonly string[];
+ /** Development-only replay mode: replace Welcome instead of reusing history. */
+ replaceExisting?: boolean;
};
type PendingWelcomeChannel = {
@@ -225,12 +228,26 @@ export async function ensureWelcomeChannel(
) {
const channels = await client.getChannels();
const existingWelcome = findPrivateWelcomeChannel(channels, options);
+ if (options.replaceExisting && existingWelcome) {
+ if (!client.deleteChannel) {
+ throw new Error("Replacing Welcome requires deleteChannel.");
+ }
+ await client.deleteChannel(existingWelcome.id);
+ return client.createChannel(welcomeChannelInput);
+ }
if (existingWelcome) {
return ensureCurrentWelcomeChannelMetadata(client, existingWelcome);
}
for (const channel of channels.filter(isPrivateWelcomeChannelCandidate)) {
if (await hasOnlyCurrentHumanMember(client, channel)) {
+ if (options.replaceExisting) {
+ if (!client.deleteChannel) {
+ throw new Error("Replacing Welcome requires deleteChannel.");
+ }
+ await client.deleteChannel(channel.id);
+ return client.createChannel(welcomeChannelInput);
+ }
return ensureCurrentWelcomeChannelMetadata(client, channel);
}
}
diff --git a/desktop/src/features/onboarding/welcomeCanvas.test.mjs b/desktop/src/features/onboarding/welcomeCanvas.test.mjs
new file mode 100644
index 0000000000..8e094937ea
--- /dev/null
+++ b/desktop/src/features/onboarding/welcomeCanvas.test.mjs
@@ -0,0 +1,62 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ ensureWelcomeCanvas,
+ WELCOME_CANVAS_CONTENT,
+} from "./welcomeCanvas.ts";
+
+test("welcome canvas covers purpose, agent use, a first challenge, and help", () => {
+ assert.match(WELCOME_CANVAS_CONTENT, /private channel is your home base/i);
+ assert.match(WELCOME_CANVAS_CONTENT, /Mention an agent/i);
+ assert.match(WELCOME_CANVAS_CONTENT, /quick challenge/i);
+ assert.match(WELCOME_CANVAS_CONTENT, /Buzz user guide/i);
+});
+
+test("ensureWelcomeCanvas seeds a fresh channel with no canvas", async () => {
+ const writes = [];
+ const seeded = await ensureWelcomeCanvas("welcome-1", {
+ getCanvas: async () => ({ content: "", updatedAt: null, author: null }),
+ setCanvas: async (input) => {
+ writes.push(input);
+ return { ok: true, eventId: "e1" };
+ },
+ });
+
+ assert.equal(seeded, true);
+ assert.deepEqual(writes, [
+ { channelId: "welcome-1", content: WELCOME_CANVAS_CONTENT },
+ ]);
+});
+
+test("ensureWelcomeCanvas seeds even when the backend omits the empty-state fields", async () => {
+ // Regression: get_canvas once returned `{ content: "" }` with updated_at and
+ // author absent (undefined). `!== null` treated that as an existing canvas
+ // and seeding silently never ran for any fresh channel.
+ const writes = [];
+ const seeded = await ensureWelcomeCanvas("welcome-1", {
+ getCanvas: async () => ({ content: "" }),
+ setCanvas: async (input) => {
+ writes.push(input);
+ return { ok: true, eventId: "e1" };
+ },
+ });
+
+ assert.equal(seeded, true);
+ assert.equal(writes.length, 1);
+});
+
+test("ensureWelcomeCanvas never overwrites an existing canvas", async () => {
+ const seeded = await ensureWelcomeCanvas("welcome-1", {
+ getCanvas: async () => ({
+ content: "my notes",
+ updatedAt: 1_700_000_000,
+ author: "a".repeat(64),
+ }),
+ setCanvas: async () => {
+ throw new Error("must not overwrite an existing canvas");
+ },
+ });
+
+ assert.equal(seeded, false);
+});
diff --git a/desktop/src/features/onboarding/welcomeCanvas.ts b/desktop/src/features/onboarding/welcomeCanvas.ts
new file mode 100644
index 0000000000..7d592fe30a
--- /dev/null
+++ b/desktop/src/features/onboarding/welcomeCanvas.ts
@@ -0,0 +1,41 @@
+import { getCanvas, setCanvas } from "@/shared/api/tauri";
+
+export const WELCOME_CANVAS_CONTENT = `# Welcome to Buzz
+
+This private channel is your home base for getting oriented. Fizz, Honey, and Bumble can help you learn the app, troubleshoot setup, and work through something you are building.
+
+## Work with your agents
+
+- Mention an agent when you want its help.
+- Bring multiple agents into the same conversation when you want different perspectives.
+- Keep decisions, progress, and results in the channel so everyone shares the same context.
+
+## Try something
+
+Bring the team something you are building, or give them a quick challenge to see how they work together.
+
+## Get help
+
+Ask the team a question here, or read the [Buzz user guide](https://github.com/block/buzz#readme).
+`;
+
+type WelcomeCanvasClient = {
+ getCanvas: typeof getCanvas;
+ setCanvas: typeof setCanvas;
+};
+
+/** Seed the Welcome canvas without overwriting anything the user has written. */
+export async function ensureWelcomeCanvas(
+ channelId: string,
+ client: WelcomeCanvasClient = { getCanvas, setCanvas },
+) {
+ const existing = await client.getCanvas(channelId);
+ // Nullish (not `!== null`) so an absent field can never masquerade as an
+ // existing canvas — that exact mismatch silently skipped seeding before.
+ if (existing.updatedAt != null || existing.author != null) {
+ return false;
+ }
+
+ await client.setCanvas({ channelId, content: WELCOME_CANVAS_CONTENT });
+ return true;
+}
diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs
index 49a717d447..4129f938af 100644
--- a/desktop/src/features/onboarding/welcomeGuide.test.mjs
+++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs
@@ -2,11 +2,15 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
+ activateWelcomeTeamPersonasSequentially,
LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT,
pickWelcomeGuideAgent,
pickWelcomeGuideAgentForRelay,
+ pickWelcomeTeamStarterAgentForRelay,
WELCOME_GUIDE_AGENT_NAME,
WELCOME_GUIDE_PERSONA_ID,
+ WELCOME_TEAM_ID,
+ WELCOME_TEAM_STARTERS,
} from "./welcomeGuide.ts";
const PUB_A = "a".repeat(64);
@@ -46,6 +50,7 @@ function makeAgent(overrides = {}) {
backendAgentId: null,
respondTo: "owner-only",
respondToAllowlist: [],
+ teamId: WELCOME_TEAM_ID,
...overrides,
};
}
@@ -128,3 +133,112 @@ test("pickWelcomeGuideAgentForRelay returns null when Fizz only exists in anothe
null,
);
});
+
+test("starter persona activation is serialized to protect the shared store", async () => {
+ const calls = [];
+ let activeWrites = 0;
+
+ await activateWelcomeTeamPersonasSequentially(
+ ["builtin:fizz", "builtin:honey", "builtin:bumble"],
+ async (personaId) => {
+ assert.equal(activeWrites, 0, "activation writes must never overlap");
+ activeWrites += 1;
+ calls.push(personaId);
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ activeWrites -= 1;
+ },
+ );
+
+ assert.deepEqual(calls, ["builtin:fizz", "builtin:honey", "builtin:bumble"]);
+});
+
+test("welcome team starter definitions and role identities are stable", () => {
+ assert.equal(WELCOME_TEAM_ID, "builtin-team:welcome");
+ assert.deepEqual(WELCOME_TEAM_STARTERS, [
+ { name: "Fizz", personaId: "builtin:fizz", role: "lead" },
+ { name: "Honey", personaId: "builtin:honey", role: "teammate" },
+ { name: "Bumble", personaId: "builtin:bumble", role: "teammate" },
+ ]);
+});
+
+test("starter matching ignores user agents with a Welcome persona", () => {
+ const honey = WELCOME_TEAM_STARTERS[1];
+ const userHoney = makeAgent({
+ personaId: honey.personaId,
+ teamId: null,
+ });
+
+ assert.equal(
+ pickWelcomeTeamStarterAgentForRelay([userHoney], honey, RELAY_A),
+ null,
+ );
+});
+
+test("starter matching uses persona identity rather than display name", () => {
+ const honey = WELCOME_TEAM_STARTERS[1];
+ const renamedHoney = makeAgent({
+ name: "Honey the Helper",
+ personaId: honey.personaId,
+ });
+ const nameOnlyHoney = makeAgent({ name: honey.name, pubkey: PUB_B });
+
+ assert.equal(
+ pickWelcomeTeamStarterAgentForRelay(
+ [nameOnlyHoney, renamedHoney],
+ honey,
+ RELAY_A,
+ ),
+ renamedHoney,
+ );
+});
+
+test("starter matching is relay scoped and normalizes trailing slashes", () => {
+ const bumble = WELCOME_TEAM_STARTERS[2];
+ const otherRelay = makeAgent({
+ personaId: bumble.personaId,
+ relayUrl: RELAY_B,
+ status: "running",
+ });
+ const matchingRelay = makeAgent({
+ personaId: bumble.personaId,
+ relayUrl: `${RELAY_A}/`,
+ pubkey: PUB_B,
+ });
+
+ assert.equal(
+ pickWelcomeTeamStarterAgentForRelay(
+ [otherRelay, matchingRelay],
+ bumble,
+ RELAY_A,
+ ),
+ matchingRelay,
+ );
+});
+
+test("starter matching prefers running, then deployed instances", () => {
+ const fizz = WELCOME_TEAM_STARTERS[0];
+ const stopped = makeAgent({ personaId: fizz.personaId });
+ const deployed = makeAgent({
+ personaId: fizz.personaId,
+ pubkey: PUB_B,
+ status: "deployed",
+ });
+ const running = makeAgent({
+ personaId: fizz.personaId,
+ pubkey: PUB_C,
+ status: "running",
+ });
+
+ assert.equal(
+ pickWelcomeTeamStarterAgentForRelay(
+ [stopped, deployed, running],
+ fizz,
+ RELAY_A,
+ ),
+ running,
+ );
+ assert.equal(
+ pickWelcomeTeamStarterAgentForRelay([stopped, deployed], fizz, RELAY_A),
+ deployed,
+ );
+});
diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts
index 051cfb8c7a..f0fb185c36 100644
--- a/desktop/src/features/onboarding/welcomeGuide.ts
+++ b/desktop/src/features/onboarding/welcomeGuide.ts
@@ -3,14 +3,15 @@ import {
createManagedAgent,
getChannelMembers,
listManagedAgents,
+ updateManagedAgent,
} from "@/shared/api/tauri";
-import { sendManagedAgentChannelMessage } from "@/shared/api/tauriManagedAgentMessages";
import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas";
import type { ManagedAgent } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export const WELCOME_GUIDE_AGENT_NAME = "Fizz";
export const WELCOME_GUIDE_PERSONA_ID = "builtin:fizz";
+export const WELCOME_TEAM_ID = "builtin-team:welcome";
export const WELCOME_GUIDE_INTRO_MARKER = "buzz-welcome-intro.v1";
const LEGACY_WELCOME_GUIDE_AGENT_NAME = "Kit";
export const LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT =
@@ -18,6 +19,25 @@ export const LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT =
export const WELCOME_GUIDE_INTRO_MESSAGE =
"Hi, I'm Fizz. Welcome to Buzz.\n\nI can help you get oriented, answer questions, and make the first few steps feel less mysterious.\n\nFeel free to ask me what else you can do in Buzz, or just talk through what you want to build.";
+export type WelcomeTeamRole = "lead" | "teammate";
+
+export type WelcomeTeamStarterDefinition = Readonly<{
+ name: string;
+ personaId: string;
+ role: WelcomeTeamRole;
+}>;
+
+/** Stable identities used to provision the Rust-seeded Welcome Team. */
+export const WELCOME_TEAM_STARTERS = [
+ { name: "Fizz", personaId: "builtin:fizz", role: "lead" },
+ { name: "Honey", personaId: "builtin:honey", role: "teammate" },
+ { name: "Bumble", personaId: "builtin:bumble", role: "teammate" },
+] as const satisfies readonly WelcomeTeamStarterDefinition[];
+
+export type WelcomeTeamAgents = [ManagedAgent, ManagedAgent, ManagedAgent];
+
+const welcomeTeamPromises = new Map>();
+
function normalizeRelayUrl(relayUrl: string | null | undefined) {
return relayUrl?.trim().replace(/\/+$/, "") ?? null;
}
@@ -30,17 +50,8 @@ function isAgentScopedToRelay(agent: ManagedAgent, relayUrl?: string | null) {
return normalizeRelayUrl(agent.relayUrl) === targetRelayUrl;
}
-function isNamedWelcomeGuideAgent(agent: ManagedAgent) {
- return (
- agent.name.trim().toLowerCase() === WELCOME_GUIDE_AGENT_NAME.toLowerCase()
- );
-}
-
function isBuiltInWelcomeGuideAgent(agent: ManagedAgent) {
- return (
- agent.personaId === WELCOME_GUIDE_PERSONA_ID &&
- isNamedWelcomeGuideAgent(agent)
- );
+ return agent.personaId === WELCOME_GUIDE_PERSONA_ID;
}
function isLegacyKitWelcomeGuideAgent(agent: ManagedAgent) {
@@ -82,6 +93,39 @@ export function pickWelcomeGuideAgentForRelay(
);
}
+/** Find the preferred managed instance for one starter persona and relay. */
+export function pickWelcomeTeamStarterAgentForRelay(
+ agents: ManagedAgent[],
+ starter: WelcomeTeamStarterDefinition,
+ relayUrl?: string | null,
+) {
+ return pickAgentByStatus(
+ agents.filter(
+ (agent) =>
+ agent.teamId === WELCOME_TEAM_ID &&
+ agent.personaId === starter.personaId &&
+ isAgentScopedToRelay(agent, relayUrl),
+ ),
+ );
+}
+
+/** Pubkeys belonging to any managed Welcome Team persona on this relay. */
+export async function getWelcomeTeamAgentPubkeys(relayUrl?: string | null) {
+ const personaIds = new Set(
+ WELCOME_TEAM_STARTERS.map(({ personaId }) => personaId),
+ );
+ return (await listManagedAgents())
+ .filter(
+ (agent) =>
+ agent.teamId === WELCOME_TEAM_ID &&
+ agent.personaId !== null &&
+ personaIds.has(agent.personaId) &&
+ isAgentScopedToRelay(agent, relayUrl),
+ )
+ .map((agent) => agent.pubkey);
+}
+
+/** Legacy Fizz/Kit lookup retained for existing channel reuse checks. */
export async function getWelcomeGuideAgentPubkeys(relayUrl?: string | null) {
return (await listManagedAgents())
.filter(
@@ -91,76 +135,138 @@ export async function getWelcomeGuideAgentPubkeys(relayUrl?: string | null) {
.map((agent) => agent.pubkey);
}
-async function ensureWelcomeGuidePersonaActive() {
- const guidePersona = (await listPersonas()).find(
- (persona) => persona.id === WELCOME_GUIDE_PERSONA_ID,
- );
- if (!guidePersona) {
- throw new Error(`${WELCOME_GUIDE_AGENT_NAME} agent not found.`);
- }
- if (!guidePersona.isActive) {
- await setPersonaActive(WELCOME_GUIDE_PERSONA_ID, true);
- }
-}
-
-async function ensureWelcomeGuideAgent(relayUrl?: string | null) {
- const agents = await listManagedAgents();
- const existing = pickWelcomeGuideAgentForRelay(agents, relayUrl);
- if (existing) {
- return existing;
- }
-
- await ensureWelcomeGuidePersonaActive();
-
- const created = await createManagedAgent({
- name: WELCOME_GUIDE_AGENT_NAME,
- personaId: WELCOME_GUIDE_PERSONA_ID,
- relayUrl: relayUrl ?? undefined,
- spawnAfterCreate: false,
- startOnAppLaunch: false,
- respondTo: "owner-only",
- });
-
- return created.agent;
-}
-
-async function ensureWelcomeGuideMembership(
- channelId: string,
- agent: ManagedAgent,
+export async function activateWelcomeTeamPersonasSequentially(
+ inactivePersonaIds: readonly string[],
+ activate: (personaId: string) => Promise,
+) {
+ for (const personaId of inactivePersonaIds) {
+ await activate(personaId);
+ }
+}
+
+async function ensureWelcomeTeamPersonasActive() {
+ const personas = await listPersonas();
+ const personasById = new Map(
+ personas.map((persona) => [persona.id, persona]),
+ );
+
+ for (const starter of WELCOME_TEAM_STARTERS) {
+ if (!personasById.has(starter.personaId)) {
+ throw new Error(`${starter.name} agent not found.`);
+ }
+ }
+
+ // Persona activation is a read-modify-write operation over one shared file.
+ // Run these sequentially so concurrent writes cannot lose a teammate's
+ // activation and leave Welcome provisioning permanently partial.
+ await activateWelcomeTeamPersonasSequentially(
+ WELCOME_TEAM_STARTERS.filter(
+ ({ personaId }) => !personasById.get(personaId)?.isActive,
+ ).map(({ personaId }) => personaId),
+ (personaId) => setPersonaActive(personaId, true),
+ );
+}
+
+async function ensureWelcomeTeamMembership(
+ channelId: string,
+ agents: WelcomeTeamAgents,
) {
- const agentPubkey = normalizePubkey(agent.pubkey);
const members = await getChannelMembers(channelId).catch(() => []);
- if (
- members.some((member) => normalizePubkey(member.pubkey) === agentPubkey)
- ) {
+ const memberPubkeys = new Set(
+ members.map((member) => normalizePubkey(member.pubkey)),
+ );
+ const missingAgents = agents.filter(
+ (agent) => !memberPubkeys.has(normalizePubkey(agent.pubkey)),
+ );
+ if (missingAgents.length === 0) {
return;
}
const result = await addChannelMembers({
channelId,
- pubkeys: [agent.pubkey],
+ pubkeys: missingAgents.map((agent) => agent.pubkey),
role: "bot",
});
- const error = result.errors.find(
- (entry) => normalizePubkey(entry.pubkey) === agentPubkey,
+ const unexpectedError = result.errors.find(
+ ({ error }) => !error.toLowerCase().includes("already"),
);
- if (error && !error.error.toLowerCase().includes("already")) {
- throw new Error(error.error);
+ if (unexpectedError) {
+ throw new Error(unexpectedError.error);
}
}
-export async function ensureWelcomeGuideIntro(
+/**
+ * Ensure the complete built-in Welcome Team is ready for kickoff.
+ * The team itself is Rust-seeded; this only activates personas, creates any
+ * missing relay-scoped instances, and adds all three to Welcome as bots.
+ */
+async function provisionWelcomeTeam(
channelId: string,
relayUrl?: string | null,
-) {
- const agent = await ensureWelcomeGuideAgent(relayUrl);
- await ensureWelcomeGuideMembership(channelId, agent);
- await sendManagedAgentChannelMessage({
- agentPubkey: agent.pubkey,
- channelId,
- content: WELCOME_GUIDE_INTRO_MESSAGE,
- marker: WELCOME_GUIDE_INTRO_MARKER,
- markerScope: "channel",
- });
- return agent;
+): Promise {
+ const existingAgents = await listManagedAgents();
+ await ensureWelcomeTeamPersonasActive();
+
+ const agents: ManagedAgent[] = [];
+ for (const starter of WELCOME_TEAM_STARTERS) {
+ const existing = pickWelcomeTeamStarterAgentForRelay(
+ existingAgents,
+ starter,
+ relayUrl,
+ );
+ if (existing) {
+ agents.push(existing);
+ continue;
+ }
+
+ const created = await createManagedAgent({
+ name: starter.name,
+ personaId: starter.personaId,
+ teamId: WELCOME_TEAM_ID,
+ relayUrl: relayUrl ?? undefined,
+ spawnAfterCreate: false,
+ startOnAppLaunch: false,
+ respondTo: "owner-only",
+ });
+ agents.push(created.agent);
+ }
+ const [lead, honey, bumble] = agents;
+ if (!lead || !honey || !bumble) {
+ throw new Error("Welcome Team provisioning did not return every starter.");
+ }
+ const welcomeAgents: WelcomeTeamAgents = [lead, honey, bumble];
+ const leadPubkey = lead.pubkey;
+ for (const index of [1, 2] as const) {
+ const teammate = welcomeAgents[index];
+ const alreadyAllowsLead =
+ teammate.respondTo === "allowlist" &&
+ teammate.respondToAllowlist.some(
+ (pubkey) => normalizePubkey(pubkey) === normalizePubkey(leadPubkey),
+ );
+ if (!alreadyAllowsLead) {
+ const updated = await updateManagedAgent({
+ pubkey: teammate.pubkey,
+ respondTo: "allowlist",
+ respondToAllowlist: [leadPubkey],
+ });
+ welcomeAgents[index] = updated.agent;
+ }
+ }
+ await ensureWelcomeTeamMembership(channelId, welcomeAgents);
+ return welcomeAgents;
+}
+
+export function ensureWelcomeTeam(
+ channelId: string,
+ relayUrl?: string | null,
+): Promise {
+ const key = `${normalizeRelayUrl(relayUrl) ?? ""}:${channelId}`;
+ const current = welcomeTeamPromises.get(key);
+ if (current) return current;
+
+ const promise = provisionWelcomeTeam(channelId, relayUrl).finally(() =>
+ welcomeTeamPromises.delete(key),
+ );
+ welcomeTeamPromises.set(key, promise);
+ return promise;
}
diff --git a/desktop/src/features/onboarding/welcomeKickoff.test.mjs b/desktop/src/features/onboarding/welcomeKickoff.test.mjs
new file mode 100644
index 0000000000..244d6de05e
--- /dev/null
+++ b/desktop/src/features/onboarding/welcomeKickoff.test.mjs
@@ -0,0 +1,297 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ areWelcomeTeammatesOnline,
+ buildWelcomeKickoffCloser,
+ buildWelcomeKickoffOpener,
+ buildWelcomeKickoffOpenerSendInput,
+ classifyWelcomeKickoffResolution,
+ createWelcomeKickoffCoordinator,
+ resolveWelcomeAgentSet,
+ selectWelcomeKickoffIntroTeammates,
+ waitForWelcomeKickoffBeat,
+ waitForWelcomeTeammatesOnline,
+ welcomeTeammateNeedsRestart,
+} from "./welcomeKickoff.ts";
+
+function agent(name, personaId, pubkey) {
+ return {
+ name,
+ personaId,
+ teamId: "builtin-team:welcome",
+ pubkey,
+ relayUrl: "ws://localhost:3000",
+ status: "stopped",
+ lastError: null,
+ lastStartedAt: null,
+ };
+}
+
+const fizz = agent("Fizz", "builtin:fizz", "f".repeat(64));
+const honey = agent("Honey", "builtin:honey", "h".repeat(64));
+const bumble = agent("Bumble", "builtin:bumble", "b".repeat(64));
+
+test("resolveWelcomeAgentSet orders agents by stable persona identity", () => {
+ assert.deepEqual(resolveWelcomeAgentSet([bumble, fizz, honey]), {
+ lead: fizz,
+ teammates: [honey, bumble],
+ });
+ assert.equal(resolveWelcomeAgentSet([fizz, honey]), null);
+});
+
+test("opener uses current agent names and requests bounded simultaneous intros", () => {
+ const opener = buildWelcomeKickoffOpener({ ...fizz, name: "Fizzy" }, [
+ { ...honey, name: "Honeybee" },
+ bumble,
+ ]);
+
+ assert.match(opener, /I'm Fizzy/);
+ assert.match(opener, /@Honeybee and @Bumble/);
+ assert.doesNotMatch(opener, /@@/);
+ assert.match(opener, /sentence or two/);
+ assert.match(opener, /Don't start any work yet/);
+});
+
+test("teammates are not ready until every harness publishes online presence", () => {
+ assert.equal(areWelcomeTeammatesOnline([honey, bumble], undefined), false);
+ assert.equal(
+ areWelcomeTeammatesOnline([honey, bumble], {
+ [honey.pubkey]: "online",
+ [bumble.pubkey]: "offline",
+ }),
+ false,
+ );
+ assert.equal(
+ areWelcomeTeammatesOnline([honey, bumble], {
+ [honey.pubkey]: "online",
+ [bumble.pubkey]: "online",
+ }),
+ true,
+ );
+});
+
+test("readiness wait observes agents becoming online without navigation", async () => {
+ let reads = 0;
+ const ready = await waitForWelcomeTeammatesOnline([honey, bumble], {
+ isCancelled: () => false,
+ loadPresence: async () => {
+ reads += 1;
+ return reads < 3
+ ? { [honey.pubkey]: "online", [bumble.pubkey]: "offline" }
+ : { [honey.pubkey]: "online", [bumble.pubkey]: "online" };
+ },
+ pollMs: 0,
+ waitMs: 1_000,
+ });
+
+ assert.deepEqual(ready, [honey, bumble]);
+ assert.equal(reads, 3);
+});
+
+test("readiness wait retries transient presence failures", async () => {
+ let reads = 0;
+ const ready = await waitForWelcomeTeammatesOnline([honey, bumble], {
+ isCancelled: () => false,
+ loadPresence: async () => {
+ reads += 1;
+ if (reads === 1) throw new Error("relay unavailable");
+ return { [honey.pubkey]: "online", [bumble.pubkey]: "online" };
+ },
+ pollMs: 0,
+ waitMs: 1_000,
+ });
+
+ assert.deepEqual(ready, [honey, bumble]);
+ assert.equal(reads, 2);
+});
+
+test("readiness wait cancels when Welcome loses focus", async () => {
+ const ready = await waitForWelcomeTeammatesOnline([honey, bumble], {
+ isCancelled: () => true,
+ loadPresence: async () => {
+ throw new Error("cancelled waits must not query");
+ },
+ pollMs: 0,
+ waitMs: 1_000,
+ });
+
+ assert.deepEqual(ready, []);
+});
+
+test("kickoff beat waits for the configured pacing interval", async () => {
+ const startedAt = Date.now();
+ assert.equal(await waitForWelcomeKickoffBeat({ waitMs: 10 }), true);
+ assert.ok(Date.now() - startedAt >= 8);
+});
+
+test("kickoff beat cancels when Welcome loses focus", async () => {
+ const controller = new AbortController();
+ const beat = waitForWelcomeKickoffBeat({
+ signal: controller.signal,
+ waitMs: 1_000,
+ });
+ controller.abort();
+ assert.equal(await beat, false);
+});
+
+test("kickoff coordinator preserves one task across rerenders and cancels on navigation", () => {
+ const coordinator = createWelcomeKickoffCoordinator();
+ const first = coordinator.begin("welcome");
+ assert.ok(first);
+ assert.equal(coordinator.begin("welcome"), null);
+ assert.equal(first.signal.aborted, false);
+
+ coordinator.cancel("welcome");
+ assert.equal(first.signal.aborted, true);
+ assert.ok(coordinator.begin("welcome"));
+});
+
+test("closer degrades coherently for partial and total startup failure", () => {
+ assert.match(buildWelcomeKickoffCloser([]), /What can we help you build/);
+ assert.match(buildWelcomeKickoffCloser(["Honey"]), /Honey is having trouble/);
+ assert.match(
+ buildWelcomeKickoffCloser(["Honey", "Bumble"]),
+ /Honey and Bumble couldn't start/,
+ );
+ assert.match(
+ buildWelcomeKickoffCloser(["Honey", "Bumble"]),
+ /I'm still here to help/,
+ );
+});
+
+test("closer names teammates that did not reply before the intro wait", () => {
+ assert.match(
+ buildWelcomeKickoffCloser([], ["Bumble"]),
+ /Bumble is taking longer to reply/,
+ );
+ assert.match(
+ buildWelcomeKickoffCloser(["Honey"], ["Bumble"]),
+ /Honey and Bumble are taking longer than expected/,
+ );
+});
+
+test("running teammates restart when their allowlist does not include the lead", () => {
+ assert.equal(
+ welcomeTeammateNeedsRestart(
+ {
+ ...honey,
+ status: "running",
+ respondTo: "allowlist",
+ respondToAllowlist: [fizz.pubkey],
+ },
+ fizz.pubkey,
+ ),
+ false,
+ );
+ assert.equal(
+ welcomeTeammateNeedsRestart(
+ {
+ ...bumble,
+ status: "running",
+ respondTo: "allowlist",
+ respondToAllowlist: [honey.pubkey],
+ },
+ fizz.pubkey,
+ ),
+ true,
+ );
+});
+
+test("opener keeps partial-readiness warm and mentions only online teammates", () => {
+ const agentSet = { lead: fizz, teammates: [honey, bumble] };
+ const introTeammates = selectWelcomeKickoffIntroTeammates(
+ agentSet.teammates,
+ [honey],
+ );
+ const input = buildWelcomeKickoffOpenerSendInput(
+ agentSet,
+ introTeammates,
+ "welcome-1",
+ );
+
+ assert.deepEqual(input.mentionPubkeys, [honey.pubkey]);
+ assert.deepEqual(input.additionalMarkers, []);
+ assert.match(input.content, /@Honey, introduce yourself/);
+ assert.doesNotMatch(input.content, /@@/);
+ assert.doesNotMatch(
+ input.content,
+ /Bumble.*trouble|couldn't start|taking longer/i,
+ );
+});
+
+test("opener degrades to one seeded Fizz message when no teammate comes online", () => {
+ const agentSet = { lead: fizz, teammates: [honey, bumble] };
+ const input = buildWelcomeKickoffOpenerSendInput(agentSet, [], "welcome-1");
+
+ assert.deepEqual(input.mentionPubkeys, []);
+ assert.equal(input.additionalMarkers.length, 1);
+ assert.match(input.content, /I'm here with Honey and Bumble/);
+ assert.match(input.content, /What can we help you build/);
+ assert.doesNotMatch(
+ input.content,
+ /introduce yourselves|trouble|couldn't start|taking longer/i,
+ );
+});
+
+test("readiness wait returns the subset that became online by the deadline", async () => {
+ const online = await waitForWelcomeTeammatesOnline([honey, bumble], {
+ isCancelled: () => false,
+ loadPresence: async () => ({
+ [honey.pubkey]: "online",
+ [bumble.pubkey]: "offline",
+ }),
+ pollMs: 0,
+ waitMs: 1,
+ });
+
+ assert.deepEqual(online, [honey]);
+});
+
+function relayEvent({ id, pubkey, createdAt = 1, tags = [], content = "" }) {
+ return {
+ id,
+ pubkey,
+ created_at: createdAt,
+ kind: 9,
+ tags,
+ content,
+ sig: "sig",
+ };
+}
+
+test("closer classification sees replies that arrive during the final beat", async () => {
+ const agentSet = { lead: fizz, teammates: [honey, bumble] };
+ const opener = relayEvent({
+ id: "opener",
+ pubkey: fizz.pubkey,
+ tags: [["client", "buzz-welcome-kickoff.opener.v1"]],
+ });
+ const events = [opener];
+
+ const beforeBeat = classifyWelcomeKickoffResolution(events, opener, agentSet);
+ assert.deepEqual(
+ beforeBeat.unresolved.map((agent) => agent.name),
+ ["Honey", "Bumble"],
+ );
+
+ const beat = waitForWelcomeKickoffBeat({ waitMs: 5 });
+ events.push(
+ relayEvent({
+ id: "honey-intro",
+ pubkey: honey.pubkey,
+ createdAt: 2,
+ tags: [
+ ["e", opener.id, "", "root"],
+ ["e", opener.id, "", "reply"],
+ ],
+ }),
+ );
+ assert.equal(await beat, true);
+
+ const afterBeat = classifyWelcomeKickoffResolution(events, opener, agentSet);
+ assert.deepEqual(
+ afterBeat.unresolved.map((agent) => agent.name),
+ ["Bumble"],
+ );
+});
diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts
new file mode 100644
index 0000000000..c6e1e49874
--- /dev/null
+++ b/desktop/src/features/onboarding/welcomeKickoff.ts
@@ -0,0 +1,727 @@
+import * as React from "react";
+
+import {
+ managedAgentsQueryKey,
+ useAcpRuntimesQuery,
+ useManagedAgentsQuery,
+} from "@/features/agents/hooks";
+import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
+import { useCommunities } from "@/features/communities/useCommunities";
+import { welcomeKickoffMarker } from "@/features/onboarding/devFreshOnboarding";
+import { resolveAgentReadiness } from "@/features/onboarding/ui/agentReadiness";
+import {
+ ensureWelcomeTeam,
+ pickWelcomeTeamStarterAgentForRelay,
+ WELCOME_TEAM_STARTERS,
+ type WelcomeTeamStarterDefinition,
+} from "@/features/onboarding/welcomeGuide";
+import { isWelcomeChannel } from "@/features/onboarding/welcome";
+import { getThreadReference } from "@/features/messages/lib/threading";
+import {
+ startManagedAgent,
+ stopManagedAgent,
+} from "@/shared/api/tauriManagedAgents";
+import { hasManagedAgentChannelMessageMarker } from "@/shared/api/tauriManagedAgentMessageMarkers";
+import { sendManagedAgentChannelMessage } from "@/shared/api/tauriManagedAgentMessages";
+import { getPresence, listManagedAgents } from "@/shared/api/tauri";
+import type { Channel, ManagedAgent, RelayEvent } from "@/shared/api/types";
+import { normalizePubkey } from "@/shared/lib/pubkey";
+import { useQueryClient } from "@tanstack/react-query";
+
+export const WELCOME_KICKOFF_OPENER_MARKER = "buzz-welcome-kickoff.opener.v1";
+export const WELCOME_KICKOFF_CLOSER_MARKER = "buzz-welcome-kickoff.closer.v1";
+export const WELCOME_KICKOFF_PROVIDER_MARKER =
+ "buzz-welcome-kickoff.provider-required.v1";
+
+const openerMarker = welcomeKickoffMarker(WELCOME_KICKOFF_OPENER_MARKER);
+const closerMarker = welcomeKickoffMarker(WELCOME_KICKOFF_CLOSER_MARKER);
+const providerMarker = welcomeKickoffMarker(WELCOME_KICKOFF_PROVIDER_MARKER);
+
+export const WELCOME_KICKOFF_PROVIDER_MESSAGE =
+ "To get started with agents, connect to an AI provider in Settings. Once you're connected, come back here and we'll introduce the team.";
+
+const WELCOME_KICKOFF_CTA =
+ "What can we help you build? Bring us something you're working on, or give us a quick challenge to see how we work together.";
+
+function formatAgentNames(agents: readonly ManagedAgent[]) {
+ if (agents.length === 0) return "";
+ if (agents.length === 1) return agents[0]?.name ?? "";
+ return `${agents
+ .slice(0, -1)
+ .map((agent) => agent.name)
+ .join(", ")} and ${agents[agents.length - 1]?.name ?? ""}`;
+}
+
+function formatMentionNames(agents: readonly ManagedAgent[]) {
+ if (agents.length === 0) return "";
+ if (agents.length === 1) return `@${agents[0]?.name ?? ""}`;
+ return `${agents
+ .slice(0, -1)
+ .map((agent) => `@${agent.name}`)
+ .join(", ")} and @${agents[agents.length - 1]?.name ?? ""}`;
+}
+export function createWelcomeKickoffCoordinator() {
+ const controllers = new Map();
+ return {
+ begin(channelId: string) {
+ if (controllers.has(channelId)) return null;
+ const controller = new AbortController();
+ controllers.set(channelId, controller);
+ return controller;
+ },
+ cancel(channelId: string) {
+ controllers.get(channelId)?.abort();
+ controllers.delete(channelId);
+ },
+ finish(channelId: string, controller: AbortController) {
+ if (controllers.get(channelId) === controller) {
+ controllers.delete(channelId);
+ }
+ },
+ };
+}
+
+const kickoffCoordinator = createWelcomeKickoffCoordinator();
+const closerInFlight = new Set();
+const TEAMMATE_READY_POLL_MS = 250;
+const TEAMMATE_READY_WAIT_MS = 60_000;
+const TEAMMATE_INTRO_WAIT_MS = 15_000;
+const KICKOFF_BEAT_MS = 3_000;
+const CLOSER_BEAT_MS = 10_000;
+const closerAbortControllers = new Map();
+const closerTimeouts = new Map<
+ string,
+ ReturnType
+>();
+
+type WelcomeAgentSet = {
+ lead: ManagedAgent;
+ teammates: [ManagedAgent, ManagedAgent];
+};
+
+function markerEvent(events: readonly RelayEvent[], marker: string) {
+ return events.find((event) =>
+ event.tags.some(
+ (tag) => tag.length >= 2 && tag[0] === "client" && tag[1] === marker,
+ ),
+ );
+}
+
+export function resolveWelcomeAgentSet(
+ agents: readonly ManagedAgent[],
+): WelcomeAgentSet | null {
+ const ordered = WELCOME_TEAM_STARTERS.map((starter) =>
+ pickWelcomeTeamStarterAgentForRelay([...agents], starter),
+ );
+ if (ordered.some((agent) => !agent)) return null;
+ return {
+ lead: ordered[0] as ManagedAgent,
+ teammates: [ordered[1] as ManagedAgent, ordered[2] as ManagedAgent],
+ };
+}
+
+function normalizeRelayUrl(relayUrl?: string | null) {
+ return relayUrl?.trim().replace(/\/+$/, "") ?? null;
+}
+
+function resolveWelcomeAgentSetForRelay(
+ agents: readonly ManagedAgent[],
+ relayUrl?: string | null,
+) {
+ const normalizedRelayUrl = normalizeRelayUrl(relayUrl);
+ return resolveWelcomeAgentSet(
+ agents.filter(
+ (agent) =>
+ !normalizedRelayUrl ||
+ normalizeRelayUrl(agent.relayUrl) === normalizedRelayUrl,
+ ),
+ );
+}
+
+export function buildWelcomeKickoffOpener(
+ lead: ManagedAgent,
+ introTeammates: readonly ManagedAgent[],
+ allTeammates: readonly ManagedAgent[] = introTeammates,
+) {
+ const introNames = formatMentionNames(introTeammates);
+ if (introTeammates.length === 0) {
+ const teammateNames = formatAgentNames(allTeammates);
+ const teammatePhrase = teammateNames ? ` with ${teammateNames}` : "";
+ return `Hi, I'm ${lead.name}. Welcome to Buzz. This is your private home base, and I'm here${teammatePhrase} to help you get oriented or work through something you're building.\n\n${WELCOME_KICKOFF_CTA}`;
+ }
+
+ return `Hi, I'm ${lead.name}. Welcome to Buzz. This is your private home base, and we're here to help you get oriented or work through something you're building.\n\n${introNames}, introduce ${introTeammates.length === 1 ? "yourself" : "yourselves"} in a sentence or two — share what you're good at and when to bring you in. Don't start any work yet.`;
+}
+
+export function onlineWelcomeTeammates(
+ teammates: readonly ManagedAgent[],
+ presence: Readonly> | undefined,
+) {
+ return teammates.filter(
+ (agent) => presence?.[normalizePubkey(agent.pubkey)] === "online",
+ );
+}
+
+export function areWelcomeTeammatesOnline(
+ teammates: readonly ManagedAgent[],
+ presence: Readonly> | undefined,
+) {
+ return (
+ onlineWelcomeTeammates(teammates, presence).length === teammates.length
+ );
+}
+
+export async function waitForWelcomeTeammatesOnline(
+ teammates: readonly ManagedAgent[],
+ options: {
+ isCancelled: () => boolean;
+ loadPresence?: typeof getPresence;
+ pollMs?: number;
+ waitMs?: number;
+ },
+) {
+ const loadPresence = options.loadPresence ?? getPresence;
+ const pollMs = options.pollMs ?? TEAMMATE_READY_POLL_MS;
+ const deadline = Date.now() + (options.waitMs ?? TEAMMATE_READY_WAIT_MS);
+ const pubkeys = teammates.map((agent) => agent.pubkey);
+ let latestOnline: ManagedAgent[] = [];
+
+ while (!options.isCancelled() && Date.now() < deadline) {
+ try {
+ latestOnline = onlineWelcomeTeammates(
+ teammates,
+ await loadPresence(pubkeys),
+ );
+ if (latestOnline.length === teammates.length) {
+ return latestOnline;
+ }
+ } catch (error) {
+ console.warn("Welcome teammate presence check failed; retrying.", error);
+ }
+ await new Promise((resolve) => globalThis.setTimeout(resolve, pollMs));
+ }
+ return options.isCancelled() ? [] : latestOnline;
+}
+
+export async function waitForWelcomeKickoffBeat(
+ options: { signal?: AbortSignal; waitMs?: number } = {},
+) {
+ const waitMs = options.waitMs ?? KICKOFF_BEAT_MS;
+ if (options.signal?.aborted) return false;
+
+ return new Promise((resolve) => {
+ const timer = globalThis.setTimeout(() => {
+ options.signal?.removeEventListener("abort", cancel);
+ resolve(true);
+ }, waitMs);
+ const cancel = () => {
+ globalThis.clearTimeout(timer);
+ resolve(false);
+ };
+ options.signal?.addEventListener("abort", cancel, { once: true });
+ });
+}
+
+export function buildWelcomeKickoffCloser(
+ failedNames: readonly string[],
+ delayedNames: readonly string[] = [],
+) {
+ if (failedNames.length === 0 && delayedNames.length === 0) {
+ return WELCOME_KICKOFF_CTA;
+ }
+ if (failedNames.length === 1 && delayedNames.length === 0) {
+ return `${failedNames[0]} is having trouble starting — you can check on them in Agents.\n\n${WELCOME_KICKOFF_CTA}`;
+ }
+ if (failedNames.length > 1 && delayedNames.length === 0) {
+ return `${failedNames.join(" and ")} couldn't start. You can check on them in Agents; I'm still here to help.\n\n${WELCOME_KICKOFF_CTA}`;
+ }
+ if (failedNames.length === 0 && delayedNames.length === 1) {
+ return `${delayedNames[0]} is taking longer to reply — I'm still here to help.\n\n${WELCOME_KICKOFF_CTA}`;
+ }
+ const names = [...failedNames, ...delayedNames].join(" and ");
+ return `${names} are taking longer than expected. I'm still here to help.\n\n${WELCOME_KICKOFF_CTA}`;
+}
+
+function isReplyToOpener(event: RelayEvent, opener: RelayEvent) {
+ const threadRef = getThreadReference(event.tags);
+ return threadRef.rootId === opener.id || threadRef.parentId === opener.id;
+}
+
+function introAuthorsAfterOpener(
+ events: readonly RelayEvent[],
+ opener: RelayEvent,
+ teammates: readonly [ManagedAgent, ManagedAgent],
+) {
+ const authors = new Set(
+ events
+ .filter(
+ (event) =>
+ event.created_at >= opener.created_at &&
+ isReplyToOpener(event, opener),
+ )
+ .map((event) => normalizePubkey(event.pubkey)),
+ );
+ return new Set(
+ teammates
+ .filter((agent) => authors.has(normalizePubkey(agent.pubkey)))
+ .map((agent) => normalizePubkey(agent.pubkey)),
+ );
+}
+
+function failedAfterKickoff(agent: ManagedAgent, opener: RelayEvent) {
+ if (agent.status !== "stopped" || !agent.lastError || !agent.lastStoppedAt) {
+ return false;
+ }
+ return (
+ Math.floor(new Date(agent.lastStoppedAt).getTime() / 1_000) >=
+ opener.created_at
+ );
+}
+
+export function classifyWelcomeKickoffResolution(
+ events: readonly RelayEvent[],
+ opener: RelayEvent,
+ agentSet: WelcomeAgentSet,
+) {
+ const introAuthors = introAuthorsAfterOpener(
+ events,
+ opener,
+ agentSet.teammates,
+ );
+ const failed = agentSet.teammates.filter((agent) =>
+ failedAfterKickoff(agent, opener),
+ );
+ const unresolved = agentSet.teammates.filter(
+ (agent) =>
+ !introAuthors.has(normalizePubkey(agent.pubkey)) &&
+ !failed.includes(agent),
+ );
+ return { failed, unresolved };
+}
+
+async function resolveLatestWelcomeAgentSet({
+ fallback,
+ queryClient,
+ relayUrl,
+}: {
+ fallback: WelcomeAgentSet;
+ queryClient: ReturnType;
+ relayUrl?: string | null;
+}) {
+ const agents = await queryClient.fetchQuery({
+ queryKey: managedAgentsQueryKey,
+ queryFn: listManagedAgents,
+ });
+ return resolveWelcomeAgentSetForRelay(agents, relayUrl) ?? fallback;
+}
+
+async function markerExists(channelId: string, marker: string) {
+ return hasManagedAgentChannelMessageMarker({
+ channelId,
+ marker,
+ markerScope: "channel",
+ });
+}
+
+export function welcomeTeammateNeedsRestart(
+ agent: ManagedAgent,
+ leadPubkey: string,
+) {
+ return (
+ agent.status === "running" &&
+ (agent.needsRestart ||
+ agent.respondTo !== "allowlist" ||
+ !agent.respondToAllowlist.some(
+ (pubkey) => normalizePubkey(pubkey) === normalizePubkey(leadPubkey),
+ ))
+ );
+}
+
+export function selectWelcomeKickoffIntroTeammates(
+ teammates: readonly ManagedAgent[],
+ onlineTeammates: readonly ManagedAgent[],
+) {
+ const onlinePubkeys = new Set(
+ onlineTeammates.map((agent) => normalizePubkey(agent.pubkey)),
+ );
+ return teammates.filter((agent) =>
+ onlinePubkeys.has(normalizePubkey(agent.pubkey)),
+ );
+}
+
+export function buildWelcomeKickoffOpenerSendInput(
+ agentSet: WelcomeAgentSet,
+ introTeammates: readonly ManagedAgent[],
+ channelId: string,
+) {
+ return {
+ agentPubkey: agentSet.lead.pubkey,
+ channelId,
+ content: buildWelcomeKickoffOpener(
+ agentSet.lead,
+ introTeammates,
+ agentSet.teammates,
+ ),
+ marker: openerMarker,
+ markerScope: "channel" as const,
+ mentionPubkeys: introTeammates.map((agent) => agent.pubkey),
+ additionalMarkers: introTeammates.length === 0 ? [closerMarker] : [],
+ };
+}
+
+export async function restartWelcomeTeammate(
+ agent: ManagedAgent,
+ options: {
+ stopAgent?: typeof stopManagedAgent;
+ startAgent?: typeof startManagedAgent;
+ } = {},
+) {
+ const stopAgent = options.stopAgent ?? stopManagedAgent;
+ const startAgent = options.startAgent ?? startManagedAgent;
+ if (agent.status === "running") {
+ await stopAgent(agent.pubkey);
+ }
+ return startAgent(agent.pubkey);
+}
+
+async function sendWelcomeKickoffCloser({
+ agentSet,
+ channelId,
+ content,
+ opener,
+}: {
+ agentSet: WelcomeAgentSet;
+ channelId: string;
+ content: string;
+ opener: RelayEvent;
+}) {
+ if (await markerExists(channelId, closerMarker)) return;
+
+ await sendManagedAgentChannelMessage({
+ agentPubkey: agentSet.lead.pubkey,
+ channelId,
+ content,
+ marker: closerMarker,
+ markerScope: "channel",
+ parentEventId: opener.id,
+ });
+}
+
+/** Runs the Welcome choreography only while the Welcome channel is focused. */
+export function useWelcomeKickoff(
+ activeChannel: Channel | null,
+ channelEvents: readonly RelayEvent[],
+ onKickoffOpenerPosted?: (eventId: string) => void,
+) {
+ const queryClient = useQueryClient();
+ const { activeCommunity } = useCommunities();
+ const runtimesQuery = useAcpRuntimesQuery();
+ const managedAgentsQuery = useManagedAgentsQuery();
+ const { globalConfig, isLoading: configLoading } = useGlobalAgentConfig();
+ const channelId = activeChannel?.id ?? null;
+ const isActiveWelcome = isWelcomeChannel(activeChannel);
+ const focusedWelcomeChannelRef = React.useRef(null);
+ focusedWelcomeChannelRef.current = isActiveWelcome ? channelId : null;
+ const channelEventsRef = React.useRef(channelEvents);
+ channelEventsRef.current = channelEvents;
+ const agentSet = React.useMemo(
+ () =>
+ resolveWelcomeAgentSetForRelay(
+ managedAgentsQuery.data ?? [],
+ activeCommunity?.relayUrl,
+ ),
+ [activeCommunity?.relayUrl, managedAgentsQuery.data],
+ );
+ const readiness = React.useMemo(
+ () => resolveAgentReadiness(runtimesQuery.data ?? [], globalConfig),
+ [globalConfig, runtimesQuery.data],
+ );
+ React.useEffect(() => {
+ if (
+ !channelId ||
+ !isActiveWelcome ||
+ configLoading ||
+ runtimesQuery.isPending
+ ) {
+ return;
+ }
+
+ const kickoffController = kickoffCoordinator.begin(channelId);
+ if (!kickoffController) return;
+ const isCancelled = () =>
+ kickoffController.signal.aborted ||
+ focusedWelcomeChannelRef.current !== channelId;
+ const landingBeat = waitForWelcomeKickoffBeat({
+ signal: kickoffController.signal,
+ });
+ void (async () => {
+ try {
+ const welcomeTeam = await ensureWelcomeTeam(
+ channelId,
+ activeCommunity?.relayUrl,
+ );
+ await queryClient.invalidateQueries({
+ queryKey: managedAgentsQueryKey,
+ });
+ const resolvedAgentSet: WelcomeAgentSet = {
+ lead: welcomeTeam[0],
+ teammates: [welcomeTeam[1], welcomeTeam[2]],
+ };
+
+ if (await markerExists(channelId, closerMarker)) {
+ return;
+ }
+ if (!readiness.ready) {
+ await sendManagedAgentChannelMessage({
+ agentPubkey: resolvedAgentSet.lead.pubkey,
+ channelId,
+ content: WELCOME_KICKOFF_PROVIDER_MESSAGE,
+ marker: providerMarker,
+ markerScope: "channel",
+ });
+ return;
+ }
+ const openerAlreadySent = await markerExists(channelId, openerMarker);
+
+ // Start before publishing the mention. buzz-acp replays events from its
+ // startup watermark, so no separate subscription-ready wait is needed.
+ // On resume, restart unresolved teammates but never replay the opener.
+ const agentsToStart = openerAlreadySent
+ ? resolvedAgentSet.teammates
+ : [resolvedAgentSet.lead, ...resolvedAgentSet.teammates];
+ const startResults = await Promise.allSettled(
+ agentsToStart.map((agent) => {
+ const isTeammate = resolvedAgentSet.teammates.some(
+ (teammate) =>
+ normalizePubkey(teammate.pubkey) ===
+ normalizePubkey(agent.pubkey),
+ );
+ if (
+ isTeammate &&
+ welcomeTeammateNeedsRestart(agent, resolvedAgentSet.lead.pubkey)
+ ) {
+ return restartWelcomeTeammate(agent);
+ }
+ return agent.status === "running" || agent.status === "deployed"
+ ? Promise.resolve(agent)
+ : startManagedAgent(agent.pubkey);
+ }),
+ );
+ for (const [index, result] of startResults.entries()) {
+ if (result.status === "rejected") {
+ console.warn(
+ `Failed to start Welcome agent ${agentsToStart[index]?.name ?? "unknown"}.`,
+ result.reason,
+ );
+ }
+ }
+ await queryClient.invalidateQueries({
+ queryKey: managedAgentsQueryKey,
+ });
+ if (openerAlreadySent) return;
+
+ const leadStartIndex = agentsToStart.findIndex(
+ (agent) => agent.pubkey === resolvedAgentSet.lead.pubkey,
+ );
+ if (startResults[leadStartIndex]?.status === "rejected") return;
+ const teammatesToAwait = resolvedAgentSet.teammates.filter(
+ (teammate) =>
+ startResults[
+ agentsToStart.findIndex(
+ (agent) => agent.pubkey === teammate.pubkey,
+ )
+ ]?.status !== "rejected",
+ );
+ const onlineTeammates = await waitForWelcomeTeammatesOnline(
+ teammatesToAwait,
+ { isCancelled },
+ );
+ if (isCancelled()) return;
+ const introTeammates = selectWelcomeKickoffIntroTeammates(
+ resolvedAgentSet.teammates,
+ onlineTeammates,
+ );
+ if (introTeammates.length < resolvedAgentSet.teammates.length) {
+ console.warn(
+ "Some Welcome teammates did not become ready; continuing with a degraded kickoff.",
+ );
+ }
+ if (!(await landingBeat) || isCancelled()) return;
+
+ const openerResult = await sendManagedAgentChannelMessage(
+ buildWelcomeKickoffOpenerSendInput(
+ resolvedAgentSet,
+ introTeammates,
+ channelId,
+ ),
+ );
+ if (!isCancelled()) onKickoffOpenerPosted?.(openerResult.eventId);
+ } catch (error) {
+ console.warn("Failed to start the Welcome team kickoff.", error);
+ } finally {
+ kickoffCoordinator.finish(channelId, kickoffController);
+ }
+ })();
+ }, [
+ activeCommunity?.relayUrl,
+ channelId,
+ configLoading,
+ isActiveWelcome,
+ onKickoffOpenerPosted,
+ queryClient,
+ readiness,
+ runtimesQuery.isPending,
+ ]);
+
+ React.useEffect(() => {
+ void isActiveWelcome;
+ return () => {
+ if (!channelId) return;
+ kickoffCoordinator.cancel(channelId);
+ closerAbortControllers.get(channelId)?.abort();
+ closerAbortControllers.delete(channelId);
+ const timeout = closerTimeouts.get(channelId);
+ if (timeout) globalThis.clearTimeout(timeout);
+ closerTimeouts.delete(channelId);
+ closerInFlight.delete(channelId);
+ };
+ }, [channelId, isActiveWelcome]);
+
+ React.useEffect(() => {
+ if (
+ !channelId ||
+ !isActiveWelcome ||
+ !agentSet ||
+ closerInFlight.has(channelId)
+ )
+ return;
+ const opener = markerEvent(channelEvents, openerMarker);
+ if (!opener || markerEvent(channelEvents, closerMarker)) {
+ return;
+ }
+
+ const { unresolved } = classifyWelcomeKickoffResolution(
+ channelEvents,
+ opener,
+ agentSet,
+ );
+
+ if (unresolved.length > 0) {
+ if (!closerTimeouts.has(channelId)) {
+ const elapsedMs = Math.max(0, Date.now() - opener.created_at * 1_000);
+ const waitMs = Math.max(0, TEAMMATE_INTRO_WAIT_MS - elapsedMs);
+ const timeout = globalThis.setTimeout(() => {
+ closerTimeouts.delete(channelId);
+ if (focusedWelcomeChannelRef.current !== channelId) return;
+ const controller = new AbortController();
+ closerAbortControllers.set(channelId, controller);
+ closerInFlight.add(channelId);
+ void (async () => {
+ if (
+ !(await waitForWelcomeKickoffBeat({
+ signal: controller.signal,
+ waitMs: CLOSER_BEAT_MS,
+ })) ||
+ controller.signal.aborted ||
+ focusedWelcomeChannelRef.current !== channelId
+ )
+ return;
+
+ const latestEvents = channelEventsRef.current;
+ if (markerEvent(latestEvents, closerMarker)) return;
+ const latestOpener =
+ markerEvent(latestEvents, openerMarker) ?? opener;
+ const latestAgentSet = await resolveLatestWelcomeAgentSet({
+ fallback: agentSet,
+ queryClient,
+ relayUrl: activeCommunity?.relayUrl,
+ });
+ const latestResolution = classifyWelcomeKickoffResolution(
+ latestEvents,
+ latestOpener,
+ latestAgentSet,
+ );
+ await sendWelcomeKickoffCloser({
+ agentSet: latestAgentSet,
+ channelId,
+ content: buildWelcomeKickoffCloser(
+ latestResolution.failed.map((agent) => agent.name),
+ latestResolution.unresolved.map((agent) => agent.name),
+ ),
+ opener: latestOpener,
+ });
+ })()
+ .catch((error) => {
+ console.warn("Failed to finish the Welcome team kickoff.", error);
+ })
+ .finally(() => {
+ if (closerAbortControllers.get(channelId) === controller) {
+ closerAbortControllers.delete(channelId);
+ }
+ closerInFlight.delete(channelId);
+ });
+ }, waitMs);
+ closerTimeouts.set(channelId, timeout);
+ }
+ return;
+ }
+
+ const timeout = closerTimeouts.get(channelId);
+ if (timeout) globalThis.clearTimeout(timeout);
+ closerTimeouts.delete(channelId);
+
+ const controller = new AbortController();
+ closerAbortControllers.set(channelId, controller);
+ closerInFlight.add(channelId);
+ void (async () => {
+ if (
+ !(await waitForWelcomeKickoffBeat({
+ signal: controller.signal,
+ waitMs: CLOSER_BEAT_MS,
+ })) ||
+ controller.signal.aborted ||
+ focusedWelcomeChannelRef.current !== channelId
+ )
+ return;
+
+ const latestEvents = channelEventsRef.current;
+ const latestOpener = markerEvent(latestEvents, openerMarker) ?? opener;
+ const latestAgentSet = await resolveLatestWelcomeAgentSet({
+ fallback: agentSet,
+ queryClient,
+ relayUrl: activeCommunity?.relayUrl,
+ });
+ const latestResolution = classifyWelcomeKickoffResolution(
+ latestEvents,
+ latestOpener,
+ latestAgentSet,
+ );
+ await sendWelcomeKickoffCloser({
+ agentSet: latestAgentSet,
+ channelId,
+ content: buildWelcomeKickoffCloser(
+ latestResolution.failed.map((agent) => agent.name),
+ ),
+ opener: latestOpener,
+ });
+ })()
+ .catch((error) => {
+ console.warn("Failed to finish the Welcome team kickoff.", error);
+ })
+ .finally(() => {
+ if (closerAbortControllers.get(channelId) === controller) {
+ closerAbortControllers.delete(channelId);
+ }
+ closerInFlight.delete(channelId);
+ });
+ }, [
+ activeCommunity?.relayUrl,
+ agentSet,
+ channelEvents,
+ channelId,
+ isActiveWelcome,
+ queryClient,
+ ]);
+}
+
+export type { WelcomeTeamStarterDefinition };
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index 9082db841d..afb0a2c3c1 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -112,6 +112,7 @@ export type RawManagedAgent = {
pubkey: string;
name: string;
persona_id: string | null;
+ team_id?: string | null;
relay_url: string;
acp_command: string;
agent_command: string;
@@ -366,8 +367,11 @@ export async function getCanvas(channelId: string): Promise {
});
return {
content: response.content,
- updatedAt: response.updated_at,
- author: response.author,
+ // Normalize absent keys to null: ensureWelcomeCanvas treats null as
+ // "no canvas yet", and `undefined !== null` would make every fresh
+ // channel look already-seeded.
+ updatedAt: response.updated_at ?? null,
+ author: response.author ?? null,
};
}
@@ -636,6 +640,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
pubkey: agent.pubkey,
name: agent.name,
personaId: agent.persona_id,
+ teamId: agent.team_id ?? null,
relayUrl: agent.relay_url,
acpCommand: agent.acp_command,
agentCommand: agent.agent_command,
@@ -649,7 +654,6 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
systemPrompt: agent.system_prompt,
avatarUrl: agent.avatar_url ?? null,
model: agent.model,
- // Fallbacks for pre-feature mocks/fixtures. Real records always carry them.
provider: agent.provider ?? null,
personaOutOfDate: agent.persona_out_of_date ?? false,
personaOrphaned: agent.persona_orphaned ?? false,
diff --git a/desktop/src/shared/api/tauriManagedAgentMessageMarkers.ts b/desktop/src/shared/api/tauriManagedAgentMessageMarkers.ts
new file mode 100644
index 0000000000..2582e752f2
--- /dev/null
+++ b/desktop/src/shared/api/tauriManagedAgentMessageMarkers.ts
@@ -0,0 +1,15 @@
+import { invokeTauri } from "@/shared/api/tauri";
+
+export async function hasManagedAgentChannelMessageMarker(input: {
+ channelId: string;
+ marker: string;
+ agentPubkey?: string;
+ markerScope?: "agent" | "channel";
+}): Promise {
+ return invokeTauri("has_managed_agent_channel_message_marker", {
+ channelId: input.channelId,
+ marker: input.marker,
+ agentPubkey: input.agentPubkey ?? null,
+ markerScope: input.markerScope ?? null,
+ });
+}
diff --git a/desktop/src/shared/api/tauriManagedAgentMessages.ts b/desktop/src/shared/api/tauriManagedAgentMessages.ts
index b7a6b2114a..4e01b87c84 100644
--- a/desktop/src/shared/api/tauriManagedAgentMessages.ts
+++ b/desktop/src/shared/api/tauriManagedAgentMessages.ts
@@ -15,6 +15,9 @@ export async function sendManagedAgentChannelMessage(input: {
content: string;
marker?: string;
markerScope?: "agent" | "channel";
+ mentionPubkeys?: string[];
+ parentEventId?: string;
+ additionalMarkers?: string[];
}): Promise {
const response = await invokeTauri(
"send_managed_agent_channel_message",
@@ -24,6 +27,9 @@ export async function sendManagedAgentChannelMessage(input: {
content: input.content,
marker: input.marker ?? null,
markerScope: input.markerScope ?? null,
+ mentionPubkeys: input.mentionPubkeys ?? null,
+ parentEventId: input.parentEventId ?? null,
+ additionalMarkers: input.additionalMarkers ?? null,
},
);
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index b82fa06d3a..fdd355d88b 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -319,6 +319,7 @@ export type ManagedAgent = {
pubkey: string;
name: string;
personaId: string | null;
+ teamId?: string | null;
relayUrl: string;
acpCommand: string;
/** Resolved/effective harness command (persona-wins, override-honored). */
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index fd82e04c6b..fd60621933 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -1,6 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./globals/scrollbars.css";
+@import "./globals/motion.css";
@import "./globals/animations.css";
@import "./globals/composer.css";
@import "./globals/markdown.css";
diff --git a/desktop/src/shared/styles/globals/motion.css b/desktop/src/shared/styles/globals/motion.css
new file mode 100644
index 0000000000..902f2f5be3
--- /dev/null
+++ b/desktop/src/shared/styles/globals/motion.css
@@ -0,0 +1,57 @@
+/*
+ * Buzz motion system
+ *
+ * Keep shared timing, easing, distance, and semantic motion primitives here.
+ * Features decide when motion runs; this layer defines how Buzz moves.
+ */
+
+:root {
+ /* Durations: feedback → state change → composed arrival. */
+ --motion-duration-instant: 120ms;
+ --motion-duration-fast: 180ms;
+ --motion-duration-standard: 240ms;
+ --motion-duration-arrival: 500ms;
+
+ /* Easing: direct for feedback, confident deceleration for entrances. */
+ --motion-ease-standard: cubic-bezier(0.25, 1, 0.5, 1);
+ --motion-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
+
+ /* Distances stay restrained so motion supports hierarchy without spectacle. */
+ --motion-distance-arrival: 0.75rem;
+ --motion-blur-arrival: 2px;
+}
+
+/* A newly-created conversational object settling into the timeline. */
+.motion-enter-conversation {
+ animation: motion-enter-conversation var(--motion-duration-arrival)
+ var(--motion-ease-arrival) both;
+}
+
+@keyframes motion-enter-conversation {
+ from {
+ filter: blur(var(--motion-blur-arrival));
+ opacity: 0;
+ transform: translateY(var(--motion-distance-arrival));
+ }
+
+ to {
+ filter: blur(0);
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .motion-enter-conversation {
+ animation-duration: 1ms;
+ }
+
+ @keyframes motion-enter-conversation {
+ from,
+ to {
+ filter: none;
+ opacity: 1;
+ transform: none;
+ }
+ }
+}
diff --git a/desktop/src/shared/styles/globals/motion.test.mjs b/desktop/src/shared/styles/globals/motion.test.mjs
new file mode 100644
index 0000000000..e47e9b89a8
--- /dev/null
+++ b/desktop/src/shared/styles/globals/motion.test.mjs
@@ -0,0 +1,24 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const motionCss = readFileSync(
+ new URL("./motion.css", import.meta.url),
+ "utf8",
+);
+
+test("conversation arrival uses shared motion tokens", () => {
+ assert.match(motionCss, /--motion-duration-arrival:\s*500ms/);
+ assert.match(motionCss, /--motion-ease-arrival:/);
+ assert.match(
+ motionCss,
+ /\.motion-enter-conversation\s*\{[\s\S]*var\(--motion-duration-arrival\)[\s\S]*var\(--motion-ease-arrival\)/,
+ );
+});
+
+test("conversation arrival has a reduced-motion treatment", () => {
+ assert.match(
+ motionCss,
+ /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.motion-enter-conversation/,
+ );
+});
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 08d4205094..f9ae719999 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -7272,6 +7272,7 @@ async function handleStartManagedAgent(
agent.updated_at = now;
agent.last_started_at = now;
agent.last_error = null;
+ setMockPresenceStatus(agent.pubkey, "online");
agent.log_lines.push(
agent.backend.type === "provider"
? `deployed mock provider harness at ${now}`
@@ -7290,6 +7291,7 @@ async function handleStopManagedAgent(args: {
agent.pid = null;
agent.updated_at = now;
agent.last_stopped_at = now;
+ setMockPresenceStatus(agent.pubkey, "offline");
agent.log_lines.push(`stopped mock harness at ${now}`);
syncMockRelayAgentsFromManagedAgents();
return cloneManagedAgent(agent);
@@ -7696,6 +7698,10 @@ async function handleSendManagedAgentChannelMessage(
channelId: string;
content: string;
marker?: string | null;
+ markerScope?: "agent" | "channel" | null;
+ mentionPubkeys?: string[] | null;
+ parentEventId?: string | null;
+ additionalMarkers?: string[] | null;
},
_config: E2eConfig | undefined,
): Promise {
@@ -7704,25 +7710,41 @@ async function handleSendManagedAgentChannelMessage(
if (marker) {
const existing = getMockMessageStore(args.channelId).find(
(event) =>
- event.pubkey === agent.pubkey &&
+ (args.markerScope === "channel" || event.pubkey === agent.pubkey) &&
event.tags.some((tag) => tag[0] === "client" && tag[1] === marker),
);
if (existing) {
return {
event_id: existing.id,
- parent_event_id: null,
- root_event_id: null,
- depth: 0,
+ parent_event_id: args.parentEventId ?? null,
+ root_event_id: args.parentEventId ?? null,
+ depth: args.parentEventId ? 1 : 0,
created_at: existing.created_at,
};
}
}
const createdAt = Math.floor(Date.now() / 1000);
+ const tags = args.parentEventId
+ ? buildReplyMessageTags(
+ args.channelId,
+ agent.pubkey,
+ args.parentEventId,
+ args.parentEventId,
+ args.mentionPubkeys ?? undefined,
+ )
+ : buildTopLevelMessageTags(
+ args.channelId,
+ args.mentionPubkeys ?? undefined,
+ agent.pubkey,
+ );
+ for (const clientMarker of [marker, ...(args.additionalMarkers ?? [])]) {
+ if (clientMarker?.trim()) tags.push(["client", clientMarker.trim()]);
+ }
const event = createMockEvent(
9,
args.content.trim(),
- [["h", args.channelId], ...(marker ? [["client", marker]] : [])],
+ tags,
agent.pubkey,
createdAt,
);
@@ -7731,9 +7753,9 @@ async function handleSendManagedAgentChannelMessage(
return {
event_id: event.id,
- parent_event_id: null,
- root_event_id: null,
- depth: 0,
+ parent_event_id: args.parentEventId ?? null,
+ root_event_id: args.parentEventId ?? null,
+ depth: args.parentEventId ? 1 : 0,
created_at: createdAt,
};
}
@@ -9521,6 +9543,22 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters[0],
activeConfig,
);
+ case "has_managed_agent_channel_message_marker": {
+ const args = payload as {
+ channelId: string;
+ marker: string;
+ agentPubkey?: string | null;
+ markerScope?: "agent" | "channel" | null;
+ };
+ return getMockMessageStore(args.channelId).some(
+ (event) =>
+ (args.markerScope === "channel" ||
+ event.pubkey === args.agentPubkey) &&
+ event.tags.some(
+ (tag) => tag[0] === "client" && tag[1] === args.marker,
+ ),
+ );
+ }
case "send_managed_agent_channel_message":
return handleSendManagedAgentChannelMessage(
payload as Parameters[0],
@@ -9778,6 +9816,8 @@ export function maybeInstallE2eTauriMocks() {
// The spec only verifies UI state, not the submitted request shape;
// returning null mirrors the Rust submit_event success path.
return null;
+ case "set_canvas":
+ return { ok: true, event_id: mockEventId() };
case "get_canvas": {
const canvasReadError = activeConfig?.mock?.canvasReadError;
if (canvasReadError) {
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index bdb4dc4ede..39a720c923 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -246,22 +246,25 @@ async function expectWelcomePersonaMention(page: Page) {
expect(alignment.display).toBe("inline-block");
expect(alignment.verticalAlign).toBe("baseline");
expect(alignment.lineHeightMatchesBanner).toBe(true);
- await expect(banner).toContainText("Try mentioning");
+ await expect(banner).toContainText("Mention");
+}
+
+async function expectPrivateWelcomeLanding(page: Page) {
+ await expect(page).toHaveURL(/#\/channels\/[^/?#]+$/);
+ await expect(page.getByTestId("channel-Welcome")).toBeVisible();
+ await expect(page.getByTestId("chat-title")).toContainText("Welcome");
}
async function expectWelcomeView(page: Page) {
- await expect(page).toHaveURL(/#\/channels\/[^/?#]+$/);
+ await expectPrivateWelcomeLanding(page);
await expect(page.getByTestId("channel-general")).toBeVisible();
await expect(page.getByTestId("channel-welcome-everyone")).toBeVisible();
- await expect(page.getByTestId("chat-title")).toContainText(
- "welcome-everyone",
- );
await expect(page.getByTestId("channel-ephemeral-Welcome")).toHaveCount(0);
await expect(page.getByTestId("chat-ephemeral-badge")).toHaveCount(0);
await expect(page.getByTestId("message-unread-pill")).toHaveCount(0);
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(page.getByTestId("message-channel-intro")).toContainText(
- "This is the beginning of the regular channel.",
+ "private welcome channel",
);
await expect(
page.getByTestId("message-channel-intro").getByRole("button"),
@@ -313,10 +316,10 @@ async function expectWelcomeView(page: Page) {
await expect(page.getByTestId("message-composer")).toBeVisible();
await expect(page.getByTestId("welcome-composer-guide-banner")).toBeVisible();
await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText(
- "Try mentioning",
+ "Mention",
);
await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText(
- "to chat with an agent in this channel.",
+ "whenever you want their help.",
);
await expectWelcomePersonaMention(page);
await expectWelcomeComposerBannerLayout(page);
@@ -419,7 +422,7 @@ async function getWelcomeChannelId(page: Page) {
return (
channels.find(
(channel) =>
- channel.name === "welcome-everyone" && channel.visibility === "open",
+ channel.name === "Welcome" && channel.visibility === "private",
)?.id ?? null
);
}
@@ -474,19 +477,13 @@ async function expectWelcomeGuideIntro(
return null;
}
- const [members, agents, introSearch] = await Promise.all([
+ const [members, agents] = await Promise.all([
invokeMockCommand<{
members: Array<{ pubkey: string; role: string; is_agent: boolean }>;
}>(page, "get_channel_members", { channelId }),
invokeMockCommand<
Array<{ pubkey: string; name: string; persona_id: string | null }>
>(page, "list_managed_agents"),
- invokeMockCommand<{
- hits: Array<{ pubkey: string; content: string }>;
- }>(page, "search_messages", {
- q: "Hi, I'm Fizz",
- limit: 10,
- }),
]);
const fizz = agents.find(
(agent) => agent.name === "Fizz" && agent.persona_id === "builtin:fizz",
@@ -494,12 +491,6 @@ async function expectWelcomeGuideIntro(
const fizzMember = fizz
? members.members.find((member) => member.pubkey === fizz.pubkey)
: null;
- const intro = fizz
- ? introSearch.hits.find((hit) => hit.pubkey === fizz.pubkey)
- : null;
- const fizzWelcomeHits = fizz
- ? introSearch.hits.filter((hit) => hit.pubkey === fizz.pubkey)
- : [];
const profileAvatarUrl = fizz
? (
await invokeMockCommand<{
@@ -513,32 +504,17 @@ async function expectWelcomeGuideIntro(
return {
fizzIsBot: fizzMember?.role === "bot" && fizzMember.is_agent,
fizzPersonaId: fizz?.persona_id ?? null,
- introContent: intro?.content ?? null,
- introMatchesFizz: Boolean(fizz && intro?.pubkey === fizz.pubkey),
- fizzWelcomeHitCount: fizzWelcomeHits.length,
profileAvatarUrl,
};
})
.toEqual({
fizzIsBot: true,
fizzPersonaId: "builtin:fizz",
- introContent:
- "Hi, I'm Fizz. Welcome to Buzz.\n\nI can help you get oriented, answer questions, and make the first few steps feel less mysterious.\n\nFeel free to ask me what else you can do in Buzz, or just talk through what you want to build.",
- introMatchesFizz: true,
- fizzWelcomeHitCount: 1,
profileAvatarUrl: null,
});
if (expectVisible) {
- await expect(page.getByTestId("message-timeline")).toContainText(
- "Hi, I'm Fizz. Welcome to Buzz.",
- );
- await expect(page.getByTestId("message-timeline")).toContainText(
- "Feel free to ask me what else you can do in Buzz",
- );
- await expect(
- page.getByTestId("message-timeline-day-divider"),
- ).toBeVisible();
+ await expect(page.getByTestId("message-channel-intro")).toBeVisible();
await expect(page.getByTestId("system-message-row")).toHaveCount(0);
}
}
@@ -943,7 +919,7 @@ test("avatar upload accepts a file whose server-detected MIME is an image", asyn
await expect(page.getByTestId("onboarding-avatar-error")).toHaveCount(0);
});
-test("first-run onboarding keeps the shell hidden and lands on welcome-everyone after profile setup", async ({
+test("first-run onboarding keeps the shell hidden and lands on private Welcome after profile setup", async ({
page,
}) => {
await seedActiveIdentity(page, FIRST_RUN_ALICE);
@@ -974,70 +950,58 @@ async function retryToastAction(
{ command, title }: { command: string; title: string },
) {
const activeToast = retryToast(page, title);
- await expect(activeToast).toBeVisible();
await expect(
activeToast.getByRole("button", { name: "Retry" }),
).toBeVisible();
+ const before = await commandCount(page, command);
+ await activeToast
+ .getByRole("button", { name: "Retry" })
+ .dispatchEvent("click");
+ await expect.poll(() => commandCount(page, command)).toBeGreaterThan(before);
+}
- const commandCountBeforeRetry = await page.evaluate(
- (retryCommand) =>
- (
- window as Window & {
- __BUZZ_E2E_COMMANDS__?: string[];
- }
- ).__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === retryCommand)
+async function commandCount(page: Page, command: string) {
+ return page.evaluate(
+ (target) =>
+ window.__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === target)
.length ?? 0,
command,
);
- await activeToast
- .getByRole("button", { name: "Retry" })
- .evaluate((button) => (button as HTMLButtonElement).click());
-
- await expect
- .poll(() =>
- page.evaluate(
- ({ retryCommand }) =>
- (
- window as Window & {
- __BUZZ_E2E_COMMANDS__?: string[];
- }
- ).__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === retryCommand)
- .length ?? 0,
- { retryCommand: command, minimum: commandCountBeforeRetry + 1 },
- ),
- )
- .toBeGreaterThanOrEqual(commandCountBeforeRetry + 1);
-}
-
-async function expectRetryFailureRecreatesActionableToast(
- page: Page,
- { command, error, title }: { command: string; error: string; title: string },
-) {
- const activeToast = retryToast(page, title);
- await expect(activeToast).toContainText(error);
-
- await retryToastAction(page, { command, title });
-
- await expect(activeToast).toHaveCount(1);
- await expect(activeToast).toContainText(error);
- await expect(
- activeToast.getByRole("button", { name: "Retry" }),
- ).toBeVisible();
-}
-
-async function expectRetrySuccessDismissesToast(
- page: Page,
- { command, title }: { command: string; title: string },
-) {
- const activeToast = retryToast(page, title);
-
- await retryToastAction(page, { command, title });
-
- await expect(activeToast).toHaveCount(0);
}
test("failed starter channel retries recreate actionable toasts", async ({
page,
+}) => {
+ const starterError = "Mock starter channel setup failed.";
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(
+ page,
+ {
+ ensureStarterChannelsErrors: [starterError, starterError, starterError],
+ },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await completeProfileOnboarding(page);
+
+ await expectPrivateWelcomeLanding(page);
+ const title = "Couldn't set up starter channels";
+ const activeToast = retryToast(page, title);
+ await expect(activeToast).toContainText(starterError);
+ await retryToastAction(page, {
+ command: "ensure_starter_channels",
+ title,
+ });
+ await expect(activeToast).toContainText(starterError);
+ await expect(
+ activeToast.getByRole("button", { name: "Retry" }),
+ ).toBeVisible();
+});
+
+test("successful starter channel retry clears its actionable toast", async ({
+ page,
}) => {
const starterError = "Mock starter channel setup failed.";
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -1053,22 +1017,27 @@ test("failed starter channel retries recreate actionable toasts", async ({
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
- await expectRetryFailureRecreatesActionableToast(page, {
+ const title = "Couldn't set up starter channels";
+ await expect(retryToast(page, title)).toContainText(starterError);
+ await retryToastAction(page, {
command: "ensure_starter_channels",
- error: starterError,
- title: "Couldn't set up starter channels",
+ title,
});
+ await expect(retryToast(page, title)).toHaveCount(0);
+ await expectWelcomeView(page);
+ await expectStarterChannels(page);
});
-test("successful starter channel retry clears its actionable toast", async ({
- page,
-}) => {
- const starterError = "Mock starter channel setup failed.";
+test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
- ensureStarterChannelsErrors: [starterError],
+ globalAgentConfig: {
+ env_vars: { OPENAI_API_KEY: "e2e-placeholder" },
+ provider: "openai",
+ model: "gpt-5.5",
+ },
},
{ skipOnboardingSeed: true },
);
@@ -1077,21 +1046,22 @@ test("successful starter channel retry clears its actionable toast", async ({
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
- await expectRetrySuccessDismissesToast(page, {
- command: "ensure_starter_channels",
- title: "Couldn't set up starter channels",
- });
- await expectWelcomeView(page);
- await expect(page.getByTestId("channel-general")).toBeVisible();
+ await expectPrivateWelcomeLanding(page);
+ await expect(page.getByTestId("message-timeline")).toContainText(
+ "Hi, I'm Fizz. Welcome to Buzz.",
+ );
+ await expect(page.getByTestId("message-timeline")).toContainText(
+ "Honey and Bumble, introduce yourselves",
+ );
});
-test("first-run onboarding shows setup loading until starter channel bootstrap completes", async ({
+test("first-run onboarding lands before Welcome team bootstrap completes", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
- { createManagedAgentDelayMs: 9_000 },
+ { createManagedAgentDelayMs: 1_000 },
{ skipOnboardingSeed: true },
);
await page.goto("/");
@@ -1099,69 +1069,13 @@ test("first-run onboarding shows setup loading until starter channel bootstrap c
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
- const loadingGate = page.getByTestId("app-loading-gate");
- await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
- await expect(loadingGate).toBeVisible();
- await expect(loadingGate).toContainText("Setting up your community...");
-
- // The boot gate is the theme-adaptive grainient with the flapping Buzz bee
- // as its hero. The mark must paint complete on the FIRST frame — a blank
- // gate reads as "nothing is loading" — so nothing about it may depend on
- // SMIL/scripted animation ( count stays 0); the wing flap is pure
- // CSS on HTML-level wing layers so it keeps running on the compositor even
- // while boot work hogs the main thread.
- await expect(
- loadingGate.getByTestId("setup-grainient-background"),
- ).toBeVisible();
- const mark = loadingGate.locator(".buzz-mark");
- await expect(mark).toBeVisible();
- const gateTreatment = await loadingGate.evaluate((element) => {
- const markElement = element.querySelector(".buzz-mark");
- const markSvgs = markElement
- ? Array.from(markElement.querySelectorAll("svg"))
- : [];
- const wing = element.querySelector(".bee-wing");
- const wingStyles =
- wing instanceof SVGElement ? window.getComputedStyle(wing) : null;
- const wash = element.querySelector(".buzz-setup-grainient__wash");
- const washStyles =
- wash instanceof HTMLElement ? window.getComputedStyle(wash) : null;
- return {
- animateElementCount: element.querySelectorAll("animate").length,
- // The document must match the OS scheme before the gate mounts (inline
- //