mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Speed up the Welcome kickoff and show the team arriving (#2066)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+83
-57
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 1–2 (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.
|
||||
Reference in New Issue
Block a user