Speed up the Welcome kickoff and show the team arriving (#2066)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
morgmart
2026-07-18 09:13:22 -07:00
committed by GitHub
co-authored by Claude
parent 1bdd938491
commit 1fdb73e17b
17 changed files with 1069 additions and 90 deletions
+83 -57
View File
@@ -329,47 +329,15 @@ function CommunityApp({
communityOnboarding.update({ stage: "profile", error: undefined });
}
}, [communityOnboarding.update, targetIsReady, transaction?.stage]);
if (transaction) {
return (
<CommunityOnboardingFlow onConnect={handleCommunityOnboardingConnect} />
);
}
// Show welcome setup for first-run users with no communities
if (community.needsSetup) {
return (
<WelcomeSetup
defaultRelayUrl={community.defaultRelayUrl}
onBack={onBackToMachineConfig}
/>
);
}
// Surface apply failures so the user can retry or change community.
if ("error" in community && community.error) {
return (
<>
<CommunityApplyErrorScreen
error={community.error}
onChangeCommunity={() => setIsCommunityChangeOpen(true)}
onRetry={reconnectCommunity}
/>
{isCommunityChangeOpen ? (
<CommunityChangeOverlay
onClose={() => setIsCommunityChangeOpen(false)}
/>
) : null}
</>
);
}
// Wait for this exact community config to be applied to the backend before
// rendering anything that connects to the relay. The appliedKey check avoids
// a one-render race where React sees the new active community while the Tauri
// backend is still configured for the previous one.
if (!community.isReady || community.appliedKey !== communityKey) {
return isCommunitySwitch ? <CommunitySwitchGate /> : <AppLoadingGate />;
}
// During "entering" the transaction stays alive as a curtain: the app mounts
// underneath (already pointed at the Welcome channel route) while the
// onboarding screen covers it, then fades once Welcome reports ready.
//
// The flow must keep ONE stable position in the element tree across every
// stage. Rendering it from a different slot when the stage flips to
// "entering" would remount it — React state resets and the "Meet your
// starter team" screen visibly restarts mid-handoff.
const isEnteringCurtain = transaction?.stage === "entering";
// The app mounts (and starts loading data) beneath the splash overlay; the
// overlay just keeps the bee on screen long enough to be seen, then fades.
@@ -377,27 +345,85 @@ function CommunityApp({
const showBootSplashOverlay =
bootSplashPhase !== "done" && !isCommunitySwitch;
let appContent: ReactNode = null;
if (!transaction) {
if (community.needsSetup) {
// Show welcome setup for first-run users with no communities
appContent = (
<WelcomeSetup
defaultRelayUrl={community.defaultRelayUrl}
onBack={onBackToMachineConfig}
/>
);
} else if ("error" in community && community.error) {
// Surface apply failures so the user can retry or change community.
appContent = (
<>
<CommunityApplyErrorScreen
error={community.error}
onChangeCommunity={() => setIsCommunityChangeOpen(true)}
onRetry={reconnectCommunity}
/>
{isCommunityChangeOpen ? (
<CommunityChangeOverlay
onClose={() => setIsCommunityChangeOpen(false)}
/>
) : null}
</>
);
}
}
// Wait for this exact community config to be applied to the backend before
// rendering anything that connects to the relay. The appliedKey check avoids
// a one-render race where React sees the new active community while the
// Tauri backend is still configured for the previous one.
const communityApplied =
community.isReady && community.appliedKey === communityKey;
if (appContent === null && (!transaction || isEnteringCurtain)) {
appContent = communityApplied ? (
<CommunityQueryProvider key={communityKey}>
<AppReady
isCommunitySwitch={isCommunitySwitch}
key={communityKey}
isSharedIdentity={sharedIdentity}
/>
{showBootSplashOverlay ? (
<div
aria-hidden="true"
className={cn(
"fixed inset-0 z-50 transition-opacity",
bootSplashPhase === "fading" ? "opacity-0" : "opacity-100",
)}
data-testid="boot-splash-overlay"
style={{ transitionDuration: `${BOOT_SPLASH_FADE_MS}ms` }}
>
<AppLoadingGate />
</div>
) : null}
</CommunityQueryProvider>
) : isCommunitySwitch ? (
<CommunitySwitchGate />
) : (
<AppLoadingGate />
);
}
return (
<CommunityQueryProvider key={communityKey}>
<AppReady
isCommunitySwitch={isCommunitySwitch}
key={communityKey}
isSharedIdentity={sharedIdentity}
/>
{showBootSplashOverlay ? (
<>
{appContent}
{transaction ? (
<div
aria-hidden="true"
className={cn(
"fixed inset-0 z-50 transition-opacity",
bootSplashPhase === "fading" ? "opacity-0" : "opacity-100",
)}
data-testid="boot-splash-overlay"
style={{ transitionDuration: `${BOOT_SPLASH_FADE_MS}ms` }}
className={isEnteringCurtain ? "fixed inset-0 z-50" : undefined}
data-testid={
isEnteringCurtain ? "onboarding-entering-curtain" : undefined
}
>
<AppLoadingGate />
<CommunityOnboardingFlow
onConnect={handleCommunityOnboardingConnect}
/>
</div>
) : null}
</CommunityQueryProvider>
</>
);
}
@@ -88,6 +88,8 @@ export const ChannelPane = React.memo(function ChannelPane({
isTimelineLoading,
entranceMessageId = null,
onEntranceMessageComplete,
welcomeKickoffStage = null,
welcomeKickoffSettingUp = false,
messages,
threadSummaries,
firstUnreadMessageId = null,
@@ -684,7 +686,13 @@ export const ChannelPane = React.memo(function ChannelPane({
expiresAtMs={timeoutState.expiresAtMs}
/>
) : isActiveWelcomeChannel ? (
<WelcomeComposerBanner state={welcomeComposerBannerState} />
<div className="relative">
{welcomeKickoffStage}
<WelcomeComposerBanner
settingUp={welcomeKickoffSettingUp}
state={welcomeComposerBannerState}
/>
</div>
) : null}
<MessageComposer
channelId={activeChannel?.id ?? null}
@@ -56,6 +56,10 @@ export type ChannelPaneProps = {
/** Newly-created message that should receive the one-shot conversation arrival motion. */
entranceMessageId?: string | null;
onEntranceMessageComplete?: (messageId: string) => void;
/** Welcome kickoff characters, rendered standing on the Welcome composer banner. */
welcomeKickoffStage?: React.ReactNode;
/** The kickoff is still setting up the team — the banner copy reads as setup status. */
welcomeKickoffSettingUp?: boolean;
messages: TimelineMessage[];
threadSummaries?: ReadonlyMap<string, ChannelWindowThreadSummary>;
firstUnreadMessageId?: string | null;
@@ -29,6 +29,7 @@ import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubke
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide";
import { useWelcomeKickoffEntrance } from "@/features/onboarding/useWelcomeKickoffEntrance";
import { useWelcomeKickoffStagePresence } from "@/features/onboarding/useWelcomeKickoffStagePresence";
import { useWelcomeAgentCreate } from "@/features/channels/useWelcomeAgentCreate";
import {
mergeMessages,
@@ -257,6 +258,7 @@ export function ChannelScreen({
resolvedMessages,
threadReplyEvents,
);
const messageEventProfilePubkeys = useMessageEventProfilePubkeys(
resolvedMessages,
threadReplyEvents,
@@ -622,6 +624,12 @@ export function ChannelScreen({
timelineLoadingNow,
);
settledChannelIdRef.current = settledChannelId;
const { welcomeKickoffStage, welcomeKickoffSettingUp } =
useWelcomeKickoffStagePresence(
activeChannel,
timelineMessages,
isTimelineLoading,
);
const resetComposerTargets = React.useCallback(
(_channelId: string | null) => {
setExpandedThreadReplyIds(new Set());
@@ -842,6 +850,8 @@ export function ChannelScreen({
isFetchingOlder={isFetchingOlder}
entranceMessageId={welcomeEntranceMessageId}
onEntranceMessageComplete={handleWelcomeEntranceComplete}
welcomeKickoffStage={welcomeKickoffStage}
welcomeKickoffSettingUp={welcomeKickoffSettingUp}
editTarget={
editTargetMessage
? {
@@ -288,9 +288,19 @@ function WelcomeComposerPersonaMention() {
type WelcomeComposerBannerProps = {
state: WelcomeComposerBannerState;
/**
* While the Welcome kickoff is still setting up the team, the banner's
* prompt copy reads as a setup status ("Setting up your welcome team…")
* instead of the mention hint the kickoff characters stand on top of the
* banner during this window.
*/
settingUp?: boolean;
};
export function WelcomeComposerBanner({ state }: WelcomeComposerBannerProps) {
export function WelcomeComposerBanner({
settingUp = false,
state,
}: WelcomeComposerBannerProps) {
if (state === "hidden") {
return null;
}
@@ -382,6 +392,18 @@ export function WelcomeComposerBanner({ state }: WelcomeComposerBannerProps) {
>
Nice work.
</motion.span>
) : settingUp ? (
<motion.span
animate="animate"
className="min-w-0"
data-testid="welcome-composer-setting-up-copy"
exit="exit"
initial="initial"
key="setting-up-copy"
variants={welcomeComposerBannerContentVariants}
>
Setting up your welcome team
</motion.span>
) : (
<motion.span
animate="animate"
@@ -18,7 +18,13 @@ export type CommunityOnboardingStage =
| "connecting"
| "profile"
| "team-intro"
| "finalizing";
| "finalizing"
/**
* Backend setup is done and the app is mounting directly on the Welcome
* channel underneath the onboarding screen, which stays up as an opaque
* curtain until Welcome reports settled (or a safety timeout), then fades.
*/
| "entering";
export type CommunityOnboardingTransaction = {
id: string;
@@ -84,9 +90,14 @@ function isTransaction(
typeof transaction.communityName === "string" &&
typeof transaction.createdAt === "string" &&
typeof transaction.updatedAt === "string" &&
["claiming", "connecting", "profile", "team-intro", "finalizing"].includes(
transaction.stage ?? "",
)
[
"claiming",
"connecting",
"profile",
"team-intro",
"finalizing",
"entering",
].includes(transaction.stage ?? "")
);
}
@@ -8,6 +8,10 @@ import {
} from "@/features/onboarding/communityOnboarding";
import { initializeStarterChannels } from "@/features/onboarding/hooks";
import { useClaimInvite } from "@/features/onboarding/useClaimInvite";
import {
takePendingWelcomeChannelForDirectEntry,
WELCOME_SURFACE_READY_EVENT,
} from "@/features/onboarding/welcome";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
parseEmojiAvatarDataUrl,
@@ -37,6 +41,14 @@ const STARTER_PERSONA_ANIMATIONS: Record<string, string> = {
Bumble: "/onboarding/starter-team/bumble.png",
};
/** Fade duration for the "entering" curtain over the mounting app. */
const ENTERING_CURTAIN_FADE_MS = 500;
/**
* Safety valve: if Welcome never reports ready (slow relay, failed query),
* fade anyway rather than stranding the user on the onboarding screen.
*/
const ENTERING_CURTAIN_MAX_WAIT_MS = 8_000;
const NEUTRAL_EMOJI_PICKER_THEME_VARS = {
"--buzz-emoji-picker-rgb-background":
"var(--buzz-onboarding-emoji-picker-background)",
@@ -101,10 +113,17 @@ export function CommunityOnboardingFlow({
[],
);
const [isPending, setIsPending] = React.useState(false);
const [isCurtainFading, setIsCurtainFading] = React.useState(false);
const nameInputRef = React.useRef<HTMLInputElement | null>(null);
// Also fetch on "entering": the curtain is a fresh mount of this component,
// so the team-intro fetch from the pre-curtain instance isn't in this state.
const isTeamIntroVisible =
transaction?.stage === "team-intro" ||
transaction?.stage === "finalizing" ||
transaction?.stage === "entering";
React.useEffect(() => {
if (transaction?.stage !== "team-intro") return;
if (!isTeamIntroVisible) return;
void listPersonas()
.then((personas) =>
setStarterPersonas(
@@ -117,7 +136,7 @@ export function CommunityOnboardingFlow({
),
)
.catch(() => setStarterPersonas([]));
}, [transaction?.stage]);
}, [isTeamIntroVisible]);
useClaimInvite();
@@ -125,6 +144,34 @@ export function CommunityOnboardingFlow({
if (transaction?.stage === "connecting") onConnect();
}, [onConnect, transaction?.stage]);
// "Entering" curtain: the app is mounting on the Welcome route underneath.
// Fade out when Welcome reports its first settled render — or after a
// safety timeout so a slow load can never strand the user on this screen.
const isEnteringStage = transaction?.stage === "entering";
React.useEffect(() => {
if (!isEnteringStage) return;
let fadeTimer: number | null = null;
const beginFade = () => {
if (fadeTimer !== null) return;
setIsCurtainFading(true);
fadeTimer = window.setTimeout(() => {
clear();
}, ENTERING_CURTAIN_FADE_MS);
};
window.addEventListener(WELCOME_SURFACE_READY_EVENT, beginFade);
const safetyTimer = window.setTimeout(
beginFade,
ENTERING_CURTAIN_MAX_WAIT_MS,
);
return () => {
window.removeEventListener(WELCOME_SURFACE_READY_EVENT, beginFade);
window.clearTimeout(safetyTimer);
if (fadeTimer !== null) window.clearTimeout(fadeTimer);
};
}, [clear, isEnteringStage]);
const retryClaim = () => update({ stage: "claiming", error: undefined });
const relayUrl = transaction?.relayUrl;
const finish = React.useCallback(async () => {
@@ -145,6 +192,19 @@ export function CommunityOnboardingFlow({
communityScope: relayUrl,
});
if (!result.ok) throw new Error(result.reason);
if (result.focusChannelId) {
// Direct entry: point the router at the Welcome channel *before* the
// app mounts, so it never lands on Home first. Consume the pending
// entry — it exists for the Home-route fallback, and leaving it would
// yank a later Home visit back to Welcome.
takePendingWelcomeChannelForDirectEntry();
window.location.hash = `/channels/${result.focusChannelId}`;
markCommunityOnboardingComplete(identity.pubkey, relayUrl);
// Keep this screen mounted as a curtain over the loading app; the
// "entering" stage fades it out once Welcome reports ready.
update({ stage: "entering", error: undefined });
return;
}
await finish();
} catch (error) {
update({
@@ -156,7 +216,9 @@ export function CommunityOnboardingFlow({
const isProfileStage = transaction?.stage === "profile";
const isTeamStage =
transaction?.stage === "team-intro" || transaction?.stage === "finalizing";
transaction?.stage === "team-intro" ||
transaction?.stage === "finalizing" ||
transaction?.stage === "entering";
React.useLayoutEffect(() => {
if (isProfileStage && !isAvatarEditorOpen) {
@@ -189,8 +251,15 @@ export function CommunityOnboardingFlow({
isProfileStage || isTeamStage
? "items-start pb-36 pt-[106px]"
: "items-stretch",
isCurtainFading &&
"pointer-events-none opacity-0 transition-opacity ease-out motion-reduce:transition-none",
)}
data-testid="community-onboarding-flow"
style={
isCurtainFading
? { transitionDuration: `${ENTERING_CURTAIN_FADE_MS}ms` }
: undefined
}
>
<StartupWindowDragRegion />
{isProfileStage || isTeamStage ? (
@@ -384,10 +453,11 @@ export function CommunityOnboardingFlow({
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
disabled={isPending}
disabled={isPending || transaction.stage === "entering"}
onClick={() => void finalize()}
>
{transaction.stage === "finalizing"
{transaction.stage === "finalizing" ||
transaction.stage === "entering"
? "Preparing Welcome…"
: `Enter ${transaction.communityName}`}
</Button>
@@ -0,0 +1,76 @@
import * as React from "react";
import {
isWelcomeKickoffStageExiting,
type WelcomeKickoffStagePhase,
} from "@/features/onboarding/useWelcomeKickoffStage";
import { cn } from "@/shared/lib/cn";
type StageCharacter = {
name: string;
animationUrl: string;
};
/** Same animated APNGs the "Meet your starter team" onboarding step uses. */
const STAGE_CHARACTERS: readonly StageCharacter[] = [
{ name: "Fizz", animationUrl: "/onboarding/starter-team/fizz.png" },
{ name: "Honey", animationUrl: "/onboarding/starter-team/honey.png" },
{ name: "Bumble", animationUrl: "/onboarding/starter-team/bumble.png" },
];
const STAGE_EXIT_ANIMATION = "motion-kickoff-stage-exit";
/**
* The welcome team characters standing on top of the Welcome composer banner
* while the team is being set up. Positioned relative to the banner wrapper
* (`bottom-full` = feet on the banner's top edge) and purely decorative
* the banner's own copy carries the setup status for screen readers.
*
* Placeholder choreography: staggered rise-from-below entrance per character
* (CSS `motion-kickoff-character-enter`, delay via `--stagger-index`), whole
* row crossfades out on either resolution the first agent message landing,
* or the wait timing out. The characters must not linger after a timeout: a
* stage that stays up implies a team is still coming when none is.
*/
export function WelcomeKickoffStage({
onExitComplete,
phase,
}: {
onExitComplete: () => void;
phase: WelcomeKickoffStagePhase;
}) {
const handleAnimationEnd = React.useCallback(
(event: React.AnimationEvent<HTMLDivElement>) => {
if (event.animationName === STAGE_EXIT_ANIMATION) {
onExitComplete();
}
},
[onExitComplete],
);
if (phase === "hidden" || phase === "done") return null;
return (
<div
aria-hidden
className={cn(
"pointer-events-none absolute bottom-full left-10 z-10 flex items-end gap-4",
isWelcomeKickoffStageExiting(phase) && "motion-kickoff-stage-exit",
)}
data-phase={phase}
data-testid="welcome-kickoff-stage"
onAnimationEnd={handleAnimationEnd}
>
{STAGE_CHARACTERS.map((character, index) => (
<img
alt=""
className="motion-kickoff-character-enter h-16 w-16 object-contain"
data-testid={`welcome-kickoff-stage-${character.name.toLowerCase()}`}
key={character.name}
src={character.animationUrl}
style={{ "--stagger-index": index } as React.CSSProperties}
/>
))}
</div>
);
}
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isWelcomeKickoffSettingUp,
isWelcomeKickoffStageExiting,
resolveWelcomeKickoffStagePhase,
} from "./useWelcomeKickoffStage.ts";
const base = {
isWelcome: true,
timelineSettled: true,
hasMessages: false,
timedOut: false,
};
test("stage stays hidden outside the Welcome channel", () => {
assert.equal(
resolveWelcomeKickoffStagePhase("hidden", { ...base, isWelcome: false }),
"hidden",
);
assert.equal(
resolveWelcomeKickoffStagePhase("active", { ...base, isWelcome: false }),
"hidden",
);
});
test("stage waits for the timeline to settle before entering", () => {
assert.equal(
resolveWelcomeKickoffStagePhase("hidden", {
...base,
timelineSettled: false,
}),
"hidden",
);
assert.equal(resolveWelcomeKickoffStagePhase("hidden", base), "active");
});
test("stage never enters when messages already exist (revisit)", () => {
assert.equal(
resolveWelcomeKickoffStagePhase("hidden", { ...base, hasMessages: true }),
"hidden",
);
});
test("first message moves an active stage to exiting", () => {
assert.equal(
resolveWelcomeKickoffStagePhase("active", { ...base, hasMessages: true }),
"exiting",
);
});
test("first message also dismisses a timed-out stage", () => {
assert.equal(
resolveWelcomeKickoffStagePhase("timed-out", {
...base,
hasMessages: true,
}),
"exiting",
);
});
test("timeout only downgrades an active stage", () => {
assert.equal(
resolveWelcomeKickoffStagePhase("active", { ...base, timedOut: true }),
"timed-out",
);
assert.equal(
resolveWelcomeKickoffStagePhase("exiting", { ...base, timedOut: true }),
"exiting",
);
});
test("exiting is terminal until the exit animation completes", () => {
assert.equal(resolveWelcomeKickoffStagePhase("exiting", base), "exiting");
});
// The timeout exists to stop a failed kickoff from claiming a team is coming
// forever. These pin the *consequences* of timing out, not just the state name
// — the timed-out phase previously rendered identically to `active`.
test("a timed-out stage stops claiming the team is being set up", () => {
assert.equal(isWelcomeKickoffSettingUp("active"), true);
assert.equal(isWelcomeKickoffSettingUp("timed-out"), false);
});
test("a timed-out stage leaves instead of standing there", () => {
assert.equal(isWelcomeKickoffStageExiting("timed-out"), true);
assert.equal(isWelcomeKickoffStageExiting("exiting"), true);
assert.equal(isWelcomeKickoffStageExiting("active"), false);
});
test("the banner never claims setup once the stage has resolved", () => {
for (const phase of ["hidden", "exiting", "timed-out", "done"]) {
assert.equal(
isWelcomeKickoffSettingUp(phase),
false,
`${phase} must not claim setup is in progress`,
);
}
});
// Regression: `done` must be distinct from `hidden`. If a finished stage fell
// back to `hidden`, the still-empty timeline would re-enter `active` and the
// characters would loop forever.
test("done is terminal and never replays on a still-empty timeline", () => {
assert.equal(resolveWelcomeKickoffStagePhase("done", base), "done");
assert.equal(
resolveWelcomeKickoffStagePhase("done", { ...base, isWelcome: false }),
"done",
);
assert.equal(
resolveWelcomeKickoffStagePhase("done", { ...base, timedOut: true }),
"done",
);
});
@@ -0,0 +1,145 @@
import * as React from "react";
import { isWelcomeChannel } from "@/features/onboarding/welcome";
import type { Channel } from "@/shared/api/types";
/**
* Stage lifecycle for the Welcome kickoff loading animation.
*
* - `hidden`: not shown yet (not Welcome, or the timeline hasn't settled)
* - `active`: characters on stage the team is genuinely being set up
* - `timed-out`: nothing arrived within the window; leave quietly (see below)
* - `exiting`: a message landed play the exit animation
* - `done`: finished for this channel; terminal, never replays
*
* `hidden` and `done` are deliberately separate. `hidden` means "not yet",
* `done` means "already happened" collapsing them would let the resolver
* re-enter `active` off a still-empty timeline the moment the stage left,
* looping the characters forever.
*
* `timed-out` is a real, user-visible resolution, not a bookkeeping flag: the
* characters leave and the banner stops claiming setup is in progress, so a
* failed kickoff degrades to an ordinary empty channel the user can type in.
* Explaining *why* it failed is follow-up work see
* docs/welcome-kickoff-silent-failures.md.
*/
export type WelcomeKickoffStagePhase =
| "hidden"
| "active"
| "timed-out"
| "exiting"
| "done";
/**
* How long the stage waits for the first agent message before settling into
* the quiet timed-out state. Generous because the teammate presence wait
* alone can take up to 60s (see welcomeKickoff.ts TEAMMATE_READY_WAIT_MS).
*/
export const WELCOME_KICKOFF_STAGE_TIMEOUT_MS = 90_000;
export type WelcomeKickoffStageInput = {
/** The active channel is the private Welcome channel. */
isWelcome: boolean;
/** The timeline query has settled — an empty list means truly empty. */
timelineSettled: boolean;
/** Any message exists in the channel (agent or user authored). */
hasMessages: boolean;
/** The timeout window elapsed while the stage was active. */
timedOut: boolean;
};
/**
* Pure phase transition one rule dismisses the stage for every resolution
* (happy-path opener, provider fallback, setup nudge, or a user message):
* the first message in the channel moves the stage to `exiting`.
*
* The stage only ever *enters* from `hidden` on a confirmed-empty timeline,
* and `done` is terminal, so a stage that already left never replays.
*/
export function resolveWelcomeKickoffStagePhase(
current: WelcomeKickoffStagePhase,
input: WelcomeKickoffStageInput,
): WelcomeKickoffStagePhase {
// Checked before `isWelcome` so the terminal state can never be laundered
// back into `hidden` (and from there into a replay) by a channel that
// momentarily reads as non-Welcome. Real channel changes reset the hook.
if (current === "done") return "done";
if (!input.isWelcome) return "hidden";
if (current === "hidden") {
return input.timelineSettled && !input.hasMessages ? "active" : "hidden";
}
if (current === "exiting") return "exiting";
if (input.hasMessages) return "exiting";
if (input.timedOut && current === "active") return "timed-out";
return current;
}
/**
* Whether the banner copy may claim the team is still being set up. True only
* while that is actually happening a timed-out stage has given up, so it
* must stop promising a team is coming.
*/
export function isWelcomeKickoffSettingUp(phase: WelcomeKickoffStagePhase) {
return phase === "active";
}
/**
* Whether the stage should play its exit animation. Both resolutions leave:
* `exiting` because a message landed, `timed-out` because none ever will.
*/
export function isWelcomeKickoffStageExiting(phase: WelcomeKickoffStagePhase) {
return phase === "exiting" || phase === "timed-out";
}
/**
* Drives the Welcome kickoff stage from local state only no network
* round-trips. The stage appears the instant the user lands on a confirmed
* empty Welcome channel and dismisses when the first message arrives.
*
* `hasTimelineMessages` must reflect *visible timeline rows* (the formatted
* message list), not raw channel events. A fresh Welcome channel already
* carries non-message events (canvas seed, membership records) that render
* nothing gating on raw events keeps the stage hidden forever.
*/
export function useWelcomeKickoffStage(
activeChannel: Channel | null,
hasTimelineMessages: boolean,
timelineLoading: boolean,
) {
const channelId = activeChannel?.id ?? null;
const isWelcome = isWelcomeChannel(activeChannel);
const [phase, setPhase] = React.useState<WelcomeKickoffStagePhase>("hidden");
const [timedOut, setTimedOut] = React.useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: reset stage state exactly when the active channel changes.
React.useEffect(() => {
setPhase("hidden");
setTimedOut(false);
}, [channelId]);
React.useEffect(() => {
setPhase((current) =>
resolveWelcomeKickoffStagePhase(current, {
isWelcome,
timelineSettled: !timelineLoading,
hasMessages: hasTimelineMessages,
timedOut,
}),
);
}, [hasTimelineMessages, isWelcome, timedOut, timelineLoading]);
React.useEffect(() => {
if (phase !== "active") return;
const timer = globalThis.setTimeout(
() => setTimedOut(true),
WELCOME_KICKOFF_STAGE_TIMEOUT_MS,
);
return () => globalThis.clearTimeout(timer);
}, [phase]);
const handleExitComplete = React.useCallback(() => {
setPhase("done");
}, []);
return { phase, handleExitComplete };
}
@@ -0,0 +1,61 @@
import * as React from "react";
import { isWelcomeSetupSystemMessage } from "@/features/channels/ui/ChannelPane.helpers";
import type { TimelineMessage } from "@/features/messages/types";
import { WelcomeKickoffStage } from "@/features/onboarding/ui/WelcomeKickoffStage";
import {
isWelcomeKickoffSettingUp,
useWelcomeKickoffStage,
} from "@/features/onboarding/useWelcomeKickoffStage";
import {
isWelcomeChannel,
notifyWelcomeSurfaceReady,
} from "@/features/onboarding/welcome";
import type { Channel } from "@/shared/api/types";
/**
* Composes the Welcome kickoff stage for the channel screen: gates on the
* timeline's *visible* rows and returns the rendered stage element plus the
* "still setting up" flag for the composer banner copy.
*
* Welcome setup system messages (channel_created / member_joined) render no
* timeline rows ChannelPane filters them out of the visible list. The stage
* gates on the same visibility rule, or a "blank" Welcome channel counts as
* non-empty and the stage never shows.
*/
export function useWelcomeKickoffStagePresence(
activeChannel: Channel | null,
timelineMessages: readonly TimelineMessage[],
isTimelineLoading: boolean,
) {
const hasVisibleTimelineMessages = React.useMemo(
() =>
timelineMessages.some((message) => !isWelcomeSetupSystemMessage(message)),
[timelineMessages],
);
const { phase, handleExitComplete } = useWelcomeKickoffStage(
activeChannel,
hasVisibleTimelineMessages,
isTimelineLoading,
);
// Announce the Welcome surface's first settled render (per channel) so the
// onboarding "entering" curtain knows it can fade. Harmless outside
// onboarding — nothing listens unless the curtain is up.
const announcedChannelIdRef = React.useRef<string | null>(null);
const channelId = activeChannel?.id ?? null;
React.useEffect(() => {
if (!channelId || isTimelineLoading) return;
if (!isWelcomeChannel(activeChannel)) return;
if (announcedChannelIdRef.current === channelId) return;
announcedChannelIdRef.current = channelId;
notifyWelcomeSurfaceReady(channelId);
}, [activeChannel, channelId, isTimelineLoading]);
const welcomeKickoffStage =
phase !== "hidden" ? (
<WelcomeKickoffStage onExitComplete={handleExitComplete} phase={phase} />
) : null;
return {
welcomeKickoffStage,
welcomeKickoffSettingUp: isWelcomeKickoffSettingUp(phase),
};
}
@@ -16,6 +16,8 @@ export const STARTER_WELCOME_CHANNEL_DESCRIPTION =
"Say hi, ask a question, or share what brought you here.";
export const WELCOME_CHANNEL_READY_EVENT =
"buzz:onboarding-welcome-channel-ready";
export const WELCOME_SURFACE_READY_EVENT =
"buzz:onboarding-welcome-surface-ready";
const PENDING_WELCOME_CHANNEL_STORAGE_KEY =
"buzz:onboarding-welcome-channel.v1";
@@ -425,6 +427,36 @@ export function consumePendingWelcomeInitialUnreadSuppression(
return true;
}
/**
* Direct-entry path (end of onboarding): the app mounts straight onto the
* Welcome channel route, so the pending entry must be consumed here if it
* stayed behind, a visit to Home within its max age would yank the user back
* to Welcome. Unlike `consumePendingWelcomeChannel` this skips the
* channels-list availability check: the caller just created the channel.
* The Home-route listener remains as a fallback for every other path.
*/
export function takePendingWelcomeChannelForDirectEntry() {
const pending = readPendingWelcomeChannel();
clearPendingWelcomeChannel();
return pending?.channelId ?? null;
}
/**
* Announce that the Welcome channel screen has rendered with settled timeline
* data. The onboarding "entering" curtain listens for this to fade out.
*/
export function notifyWelcomeSurfaceReady(channelId: string) {
if (typeof window === "undefined") {
return;
}
window.dispatchEvent(
new CustomEvent(WELCOME_SURFACE_READY_EVENT, {
detail: { channelId },
}),
);
}
export function notifyWelcomeChannelReady(channelId: string) {
if (typeof window === "undefined") {
return;
@@ -8,6 +8,7 @@ import {
buildWelcomeKickoffOpenerSendInput,
classifyWelcomeKickoffResolution,
createWelcomeKickoffCoordinator,
mergeKickoffEvents,
resolveWelcomeAgentSet,
selectWelcomeKickoffIntroTeammates,
waitForWelcomeKickoffBeat,
@@ -220,6 +221,66 @@ test("opener keeps partial-readiness warm and mentions only online teammates", (
);
});
test("opener greets the owner by name and tags their pubkey", () => {
const agentSet = { lead: fizz, teammates: [honey, bumble] };
const owner = { pubkey: "owner-pubkey-hex", displayName: "Morgan" };
const input = buildWelcomeKickoffOpenerSendInput(
agentSet,
agentSet.teammates,
"welcome-1",
owner,
);
assert.deepEqual(input.mentionPubkeys, [
honey.pubkey,
bumble.pubkey,
owner.pubkey,
]);
assert.match(input.content, /^Hi @Morgan, I'm Fizz\./);
// The raw pubkey must never leak into the visible copy.
assert.doesNotMatch(input.content, /owner-pubkey-hex/);
});
test("opener falls back to an unnamed greeting when the display name is missing", () => {
const agentSet = { lead: fizz, teammates: [honey, bumble] };
const owner = { pubkey: "owner-pubkey-hex", displayName: " " };
const input = buildWelcomeKickoffOpenerSendInput(
agentSet,
agentSet.teammates,
"welcome-1",
owner,
);
// Still tagged for the Inbox mentions feed, just no visible greeting name.
assert.ok(input.mentionPubkeys.includes(owner.pubkey));
assert.match(input.content, /^Hi, I'm Fizz\./);
assert.doesNotMatch(input.content, /@\s/);
});
test("opener greets and tags the owner even when no teammates come online", () => {
const agentSet = { lead: fizz, teammates: [honey, bumble] };
const input = buildWelcomeKickoffOpenerSendInput(agentSet, [], "welcome-1", {
pubkey: "owner-pubkey-hex",
displayName: "Morgan",
});
assert.deepEqual(input.mentionPubkeys, ["owner-pubkey-hex"]);
assert.equal(input.additionalMarkers.length, 1);
assert.match(input.content, /^Hi @Morgan, I'm Fizz\./);
});
test("opener does not duplicate the owner pubkey if already mentioned", () => {
const agentSet = { lead: fizz, teammates: [honey, bumble] };
const input = buildWelcomeKickoffOpenerSendInput(
agentSet,
[honey],
"welcome-1",
{ pubkey: honey.pubkey, displayName: honey.name },
);
assert.deepEqual(input.mentionPubkeys, [honey.pubkey]);
});
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");
@@ -295,3 +356,74 @@ test("closer classification sees replies that arrive during the final beat", asy
["Bumble"],
);
});
function introReply(id, pubkey, openerId) {
return relayEvent({
id,
pubkey,
createdAt: 2,
tags: [
["e", openerId, "", "root"],
["e", openerId, "", "reply"],
],
});
}
const kickoffOpener = relayEvent({
id: "opener",
pubkey: fizz.pubkey,
tags: [["client", "buzz-welcome-kickoff.opener.v1"]],
});
// The bug this branch fixes: teammate intros are thread replies, which the
// channel window excludes from the main timeline. So the kickoff saw the
// opener and never the intros, and the closer stalled until the user happened
// to click into the thread. Merging the opener's subtree in is the fix.
test("intro replies reach the closer classification without the user opening the thread", () => {
const agentSet = { lead: fizz, teammates: [honey, bumble] };
const channelEvents = [kickoffOpener];
const openerReplies = [
introReply("honey-intro", honey.pubkey, kickoffOpener.id),
introReply("bumble-intro", bumble.pubkey, kickoffOpener.id),
];
// Pin the pre-fix behaviour: on the channel events alone, both teammates
// look silent forever. This is what stalled the closer.
assert.deepEqual(
classifyWelcomeKickoffResolution(
channelEvents,
kickoffOpener,
agentSet,
).unresolved.map((agent) => agent.name),
["Honey", "Bumble"],
);
// With the subtree merged in, the same intros resolve the kickoff.
assert.deepEqual(
classifyWelcomeKickoffResolution(
mergeKickoffEvents(channelEvents, openerReplies),
kickoffOpener,
agentSet,
).unresolved,
[],
);
});
test("merging the opener subtree never double-counts an already-visible reply", () => {
const honeyIntro = introReply("honey-intro", honey.pubkey, kickoffOpener.id);
// An open thread feeds the same replies in through both sources.
const merged = mergeKickoffEvents(
[kickoffOpener, honeyIntro],
[honeyIntro, introReply("bumble-intro", bumble.pubkey, kickoffOpener.id)],
);
assert.deepEqual(
merged.map((event) => event.id),
["opener", "honey-intro", "bumble-intro"],
);
});
test("merging with no subtree replies leaves the channel events untouched", () => {
const channelEvents = [kickoffOpener];
assert.equal(mergeKickoffEvents(channelEvents, []), channelEvents);
});
+124 -20
View File
@@ -17,6 +17,7 @@ import {
} from "@/features/onboarding/welcomeGuide";
import { isWelcomeChannel } from "@/features/onboarding/welcome";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useThreadReplies } from "@/features/messages/useThreadReplies";
import {
startManagedAgent,
stopManagedAgent,
@@ -24,6 +25,7 @@ import {
import { hasManagedAgentChannelMessageMarker } from "@/shared/api/tauriManagedAgentMessageMarkers";
import { sendManagedAgentChannelMessage } from "@/shared/api/tauriManagedAgentMessages";
import { getPresence, listManagedAgents } from "@/shared/api/tauri";
import { getProfile } from "@/shared/api/tauriProfiles";
import type { Channel, ManagedAgent, RelayEvent } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { useQueryClient } from "@tanstack/react-query";
@@ -86,8 +88,7 @@ const closerInFlight = new Set<string>();
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 CLOSER_BEAT_MS = 3_000;
const closerAbortControllers = new Map<string, AbortController>();
const closerTimeouts = new Map<
string,
@@ -142,15 +143,23 @@ export function buildWelcomeKickoffOpener(
lead: ManagedAgent,
introTeammates: readonly ManagedAgent[],
allTeammates: readonly ManagedAgent[] = introTeammates,
ownerName?: string | null,
) {
// Greet the new user by name when we know it. Paired with their pubkey in
// the p tags, the @mention renders as a pill and files the opener into
// their Inbox mentions feed.
const trimmedOwnerName = ownerName?.trim();
const greeting = trimmedOwnerName
? `Hi @${trimmedOwnerName}, I'm ${lead.name}.`
: `Hi, I'm ${lead.name}.`;
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 `${greeting} 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.`;
return `${greeting} 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(
@@ -204,10 +213,11 @@ export async function waitForWelcomeTeammatesOnline(
return options.isCancelled() ? [] : latestOnline;
}
export async function waitForWelcomeKickoffBeat(
options: { signal?: AbortSignal; waitMs?: number } = {},
) {
const waitMs = options.waitMs ?? KICKOFF_BEAT_MS;
export async function waitForWelcomeKickoffBeat(options: {
signal?: AbortSignal;
waitMs: number;
}) {
const waitMs = options.waitMs;
if (options.signal?.aborted) return false;
return new Promise<boolean>((resolve) => {
@@ -350,11 +360,55 @@ export function selectWelcomeKickoffIntroTeammates(
);
}
export type WelcomeKickoffOwner = {
pubkey: string;
displayName?: string | null;
};
/**
* The event view the kickoff classification reasons over: the channel's own
* events plus the opener's thread replies.
*
* Teammate intros (and the closer) are thread replies, which the channel
* window deliberately excludes only broadcast replies reach the main
* timeline. So `channelEvents` alone shows the opener and never the intros,
* and the closer stalls until the user happens to open the thread. Merging the
* opener's subtree in is what lets the choreography resolve on its own.
*
* De-duplicated because the two sources legitimately overlap: the live
* subscription writes replies into the thread cache, and an open thread also
* feeds the same replies in through `channelEvents`.
*/
export function mergeKickoffEvents(
channelEvents: readonly RelayEvent[],
openerReplies: readonly RelayEvent[],
): readonly RelayEvent[] {
if (openerReplies.length === 0) return channelEvents;
const seen = new Set(channelEvents.map((event) => event.id));
return [
...channelEvents,
...openerReplies.filter((event) => !seen.has(event.id)),
];
}
export function buildWelcomeKickoffOpenerSendInput(
agentSet: WelcomeAgentSet,
introTeammates: readonly ManagedAgent[],
channelId: string,
owner?: WelcomeKickoffOwner | null,
) {
// Greet the new user by name and tag their pubkey. The p tag renders the
// "@Name" in the copy as a mention pill and files the opener into their
// Inbox mentions feed, so the Inbox isn't an empty state on first visit.
const mentionPubkeys = introTeammates.map((agent) => agent.pubkey);
if (
owner?.pubkey &&
!mentionPubkeys.some(
(pubkey) => normalizePubkey(pubkey) === normalizePubkey(owner.pubkey),
)
) {
mentionPubkeys.push(owner.pubkey);
}
return {
agentPubkey: agentSet.lead.pubkey,
channelId,
@@ -362,10 +416,11 @@ export function buildWelcomeKickoffOpenerSendInput(
agentSet.lead,
introTeammates,
agentSet.teammates,
owner?.displayName,
),
marker: openerMarker,
markerScope: "channel" as const,
mentionPubkeys: introTeammates.map((agent) => agent.pubkey),
mentionPubkeys,
additionalMarkers: introTeammates.length === 0 ? [closerMarker] : [],
};
}
@@ -423,8 +478,44 @@ export function useWelcomeKickoff(
const isActiveWelcome = isWelcomeChannel(activeChannel);
const focusedWelcomeChannelRef = React.useRef<string | null>(null);
focusedWelcomeChannelRef.current = isActiveWelcome ? channelId : null;
const channelEventsRef = React.useRef(channelEvents);
channelEventsRef.current = channelEvents;
// Watch the opener's thread subtree directly so teammate intro replies are
// visible to the closer classification even when the user never opens the
// thread. Without this, replies only surfaced through the UI's open-thread
// query and the closer stalled until the user clicked into the thread.
const openerEvent = React.useMemo(
() => markerEvent(channelEvents, openerMarker) ?? null,
[channelEvents],
);
// Retire the watch once the closer exists: the kickoff is resolved, so
// revisits to Welcome shouldn't keep refetching the subtree forever.
//
// This has to be a latch rather than a plain derivation. The closer is a
// *thread reply* to the opener (see sendWelcomeKickoffCloser), so it never
// appears in `channelEvents` unless the user happened to open the thread —
// deriving from `channelEvents` meant this never retired at all. Deriving
// from `kickoffEvents` instead is self-referential: it gates the query that
// feeds it, so retiring would drop the evidence that justified retiring and
// (on a cache eviction) flip the query back on. Latching per channel keeps
// the decision one-way and stable.
const [resolvedChannelId, setResolvedChannelId] = React.useState<
string | null
>(null);
const kickoffResolved = channelId !== null && resolvedChannelId === channelId;
const openerThreadQuery = useThreadReplies(
isActiveWelcome && !kickoffResolved ? activeChannel : null,
openerEvent?.id ?? null,
);
const kickoffEvents = React.useMemo(
() => mergeKickoffEvents(channelEvents, openerThreadQuery.data ?? []),
[channelEvents, openerThreadQuery.data],
);
React.useEffect(() => {
if (!channelId || kickoffResolved) return;
if (markerEvent(kickoffEvents, closerMarker) == null) return;
setResolvedChannelId(channelId);
}, [channelId, kickoffEvents, kickoffResolved]);
const channelEventsRef = React.useRef(kickoffEvents);
channelEventsRef.current = kickoffEvents;
const agentSet = React.useMemo(
() =>
resolveWelcomeAgentSetForRelay(
@@ -452,9 +543,6 @@ export function useWelcomeKickoff(
const isCancelled = () =>
kickoffController.signal.aborted ||
focusedWelcomeChannelRef.current !== channelId;
const landingBeat = waitForWelcomeKickoffBeat({
signal: kickoffController.signal,
});
void (async () => {
try {
const welcomeTeam = await ensureWelcomeTeam(
@@ -547,13 +635,22 @@ export function useWelcomeKickoff(
"Some Welcome teammates did not become ready; continuing with a degraded kickoff.",
);
}
if (!(await landingBeat) || isCancelled()) return;
if (isCancelled()) return;
// Best-effort: a missing profile should degrade to an ungreeted,
// untagged opener, never block the kickoff.
const owner = await getProfile()
.then((profile) => ({
pubkey: profile.pubkey,
displayName: profile.displayName,
}))
.catch(() => null);
const openerResult = await sendManagedAgentChannelMessage(
buildWelcomeKickoffOpenerSendInput(
resolvedAgentSet,
introTeammates,
channelId,
owner,
),
);
if (!isCancelled()) onKickoffOpenerPosted?.(openerResult.eventId);
@@ -593,16 +690,22 @@ export function useWelcomeKickoff(
!channelId ||
!isActiveWelcome ||
!agentSet ||
closerInFlight.has(channelId)
closerInFlight.has(channelId) ||
// Respect the latch, not just the events. Retiring the opener-thread
// watch drops the subtree from `kickoffEvents`, which is where the closer
// lives — so once resolved, the marker check below can no longer see it
// and would classify every teammate as silent and re-run the closer on
// each revisit. The latch is the durable "already resolved" signal.
kickoffResolved
)
return;
const opener = markerEvent(channelEvents, openerMarker);
if (!opener || markerEvent(channelEvents, closerMarker)) {
const opener = markerEvent(kickoffEvents, openerMarker);
if (!opener || markerEvent(kickoffEvents, closerMarker)) {
return;
}
const { unresolved } = classifyWelcomeKickoffResolution(
channelEvents,
kickoffEvents,
opener,
agentSet,
);
@@ -718,7 +821,8 @@ export function useWelcomeKickoff(
}, [
activeCommunity?.relayUrl,
agentSet,
channelEvents,
kickoffEvents,
kickoffResolved,
channelId,
isActiveWelcome,
queryClient,
@@ -55,3 +55,58 @@
}
}
}
/*
* Welcome kickoff stage: the starter-team characters arrive from below with
* a staggered entrance while the team is being set up, then the stage fades
* out when the first agent message lands. Per-character stagger is driven by
* the --stagger-index custom property set inline on each character.
*/
.motion-kickoff-character-enter {
animation: motion-kickoff-character-enter var(--motion-duration-arrival)
var(--motion-ease-arrival) both;
animation-delay: calc(var(--stagger-index, 0) * 120ms);
}
@keyframes motion-kickoff-character-enter {
from {
opacity: 0;
transform: translateY(2.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.motion-kickoff-stage-exit {
animation: motion-kickoff-stage-exit var(--motion-duration-standard)
var(--motion-ease-standard) both;
}
@keyframes motion-kickoff-stage-exit {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.motion-kickoff-character-enter,
.motion-kickoff-stage-exit {
animation-delay: 0ms;
animation-duration: 1ms;
}
@keyframes motion-kickoff-character-enter {
from,
to {
opacity: 1;
transform: none;
}
}
}
+4 -2
View File
@@ -1273,8 +1273,10 @@ test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => {
await completeProfileOnboarding(page);
await expectPrivateWelcomeLanding(page);
// Greeted by the name typed above — the @mention pill also files the opener
// into the new user's Inbox mentions feed.
await expect(page.getByTestId("message-timeline")).toContainText(
"Hi, I'm Fizz. Welcome to Buzz.",
"Hi @Morty QA, I'm Fizz. Welcome to Buzz.",
);
await expect(page.getByTestId("message-timeline")).toContainText(
"Honey and Bumble, introduce yourselves",
@@ -1298,7 +1300,7 @@ test("first-run onboarding lands before Welcome team bootstrap completes", async
await expectPrivateWelcomeLanding(page);
await expect(page.getByTestId("app-loading-gate")).toHaveCount(0);
await expect(page.getByTestId("message-timeline")).toContainText(
"Hi, I'm Fizz. Welcome to Buzz.",
"Hi @Morty QA, I'm Fizz. Welcome to Buzz.",
);
await page.waitForTimeout(1_500);
expect(await commandCount(page, "create_managed_agent")).toBe(3);
+106
View File
@@ -0,0 +1,106 @@
# Welcome Kickoff — Silent Failure Paths
Status: **open** — the *perception* gap is handled (see below); the silent
paths themselves are not. Documented for follow-up work.
Context: the Welcome-channel kickoff choreography
(`desktop/src/features/onboarding/welcomeKickoff.ts`) where Fizz posts an
opener, teammates introduce themselves in-thread, and Fizz posts a closer.
## The problem
Every fallback message in the kickoff assumes Fizz — the lead agent and
sender — is alive and able to post. When Fizz itself fails, or an early step
throws, **nothing is ever posted** and the user stares at an empty Welcome
channel with no explanation.
The client-side kickoff stage (the starter-team characters standing on the
Welcome composer banner) covers the *perception* gap, and that part has landed:
after `WELCOME_KICKOFF_STAGE_TIMEOUT_MS` (90s) with no message, the characters
play their exit and the banner drops back to its normal mention hint. A failed
kickoff degrades to an ordinary, usable empty channel rather than claiming a
team is still being set up.
What it does **not** do is explain anything — the silent paths below still need
real handling. Two things worth knowing before picking this up:
- The stage is driven purely by "is the timeline empty" plus that timer
(`useWelcomeKickoffStage.ts`). It never reads the real kickoff state, so it
cannot distinguish "Fizz crashed" from "the relay is slow" — the timeout is a
perception backstop, not a diagnosis. Surfacing a cause means plumbing one out
of `useWelcomeKickoff` (step 1 of the Sketch below).
- The empty channel it degrades to invites the user to `@`-mention Fizz — who,
in exactly these failure cases, is the thing that isn't working. So the quiet
timeout is honest but still a dead end.
## Message inventory (what the user CAN receive today)
All hard-coded client-side; only teammate intro replies are LLM-generated.
| # | Message | Trigger | Sender |
|---|---------|---------|--------|
| 1 | Provider fallback ("connect to an AI provider in Settings…") | Readiness check fails before kickoff | Fizz (marker: `provider-required.v1`) |
| 2 | Happy-path opener (mentions teammates, asks them to introduce themselves) | Team online | Fizz (marker: `opener.v1`) |
| 3 | Degraded opener ("I'm here with Honey and Bumble…") | Fizz online, zero teammates online within 60s | Fizz (opener + closer markers, self-contained) |
| 4 | Closer variants (clean / failed / slow teammate wording) | 3s beat after intros resolve, or 15s intro timeout | Fizz (marker: `closer.v1`) |
| 5 | Setup-mode nudge ("here's what you still need to configure") | Agent process spawns but its requirements check fails (e.g. missing API key) | The agent process itself (backend, buzz-acp setup-listener mode) |
## Silent paths (what the user CANNOT be told today)
1. **Fizz fails to start.** `startManagedAgent` for the lead rejects (harness
binary missing, spawn error). The kickoff effect logs
`Failed to start Welcome agent…` and returns — by design, the opener is
only sent by Fizz, so nobody speaks.
2. **Any step throws.** The entire kickoff runs in one `try/catch` that logs
`Failed to start the Welcome team kickoff.` and gives up. Causes seen in
practice:
- relay unreachable / websocket down
- `ensureWelcomeTeam` failure (team record creation)
- the send itself rejected — e.g. relay rate-limiting
("rate-limited: quota exceeded", observed 2026-07-17 with an agent
publishing in a tight retry loop)
3. **Closer-path failures.** The closer send failing is also caught-and-logged
only; the thread ends without the CTA. Lower stakes than 12 (an opener and
intros already happened) but still a dangling state.
Navigation away mid-kickoff also cancels silently, but that is intentional
(the kickoff resumes on next visit) — not a failure.
## Constraints for the fix
- **Fizz cannot be the messenger** for these paths: she is the thing that
failed. Any user-visible fallback must come from the client UI itself
(banner, intro-block state, or the kickoff stage's `timed-out` phase) — not a
channel message impersonating an agent.
- A relay-side or system-authored message is possible in principle
(kind-scoped system event) but heavier; the client already knows locally
that the kickoff threw, so a local UI state is the cheap, honest option.
- Whatever surfaces must be **idempotent across revisits** — same rule as the
opener markers: don't re-alarm the user every time they click Welcome.
- Distinguish *retryable* (relay hiccup, rate-limit) from *actionable*
(harness missing → point at Agents/Settings). The `Requirement` machinery
in `desktop/src-tauri/src/managed_agents/readiness.rs` already classifies
the actionable ones.
## Sketch (to validate later)
1. Surface a `kickoffError` phase from `useWelcomeKickoff` when the catch
block fires or the lead's start rejects, with a coarse cause
(`lead-start-failed` | `relay` | `unknown`).
2. The kickoff stage's `timed-out` phase renders that cause: quiet copy + a
pointer to Agents (for start failures) or a retry affordance (for relay
failures). Retry = re-run the effect (the coordinator already dedupes).
Note the phase currently exits immediately on timeout — giving it copy to
show means holding it on screen instead, and the stage is `aria-hidden`
decoration today, so anything it says needs to reach screen readers too.
3. Consider a bounded auto-retry (once, after a short delay) for the relay
class before showing anything.
4. Closer-path failure: on send rejection, retry once; otherwise leave the
thread as-is (intros already delivered the core experience).
## Related
- Rate-limiting incident: one Welcome agent produced a 42KB log of
"rate-limited: quota exceeded" retries within seconds
(2026-07-17, remote relay `onboarding.communities.buzz.xyz`). Worth a
separate look at buzz-acp publish backoff — a tight retry loop against a
quota makes every other send in the session fail too.