Speed up the Welcome kickoff and show the team arriving#2066
Conversation
… opener thread directly - Remove the 3s landing beat before Fizz's opener — the opener now sends as soon as the welcome team is online. - Shorten the closer beat from 10s to 3s after teammate intros resolve. - Watch the opener's thread replies via a dedicated query keyed to the opener event, so the closer fires without the user opening the thread (previously classification was blind unless the thread panel was open).
While the welcome team is being set up, the Welcome composer banner reads "Setting up your welcome team…" and the three starter-team characters (animated APNGs) stand on top of the banner with a staggered rise-in entrance. The stage crossfades out when the first agent message lands — one rule covers every resolution (opener, provider fallback, setup nudge, or a user message). - useWelcomeKickoffStage: pure local phase machine (hidden/active/ timed-out/exiting) gated on *visible* timeline rows, so invisible events (canvas seed, membership records, welcome setup system messages) never suppress the stage; 90s timeout for silent failures. - useWelcomeKickoffStagePresence: composes gating + rendered stage for ChannelScreen (also keeps ChannelScreen under the file-size limit). - WelcomeKickoffStage: characters perched on the banner (bottom-full, pointer-events-none, decorative; banner copy carries status for screen readers). - motion.css: kickoff character enter / stage exit primitives on the shared motion tokens, with reduced-motion treatments. - docs/welcome-kickoff-silent-failures.md: inventory of kickoff message fallbacks and the silent failure paths still needing real handling. Includes a DEV_FORCE_STAGE dev-only switch (off) for iterating on the choreography without re-running fresh onboarding.
…annel New users previously rode through the Inbox to reach Welcome: onboarding cleared, the router mounted on Home, and a Home-route listener redirected to the Welcome channel once the channels list confirmed it — flashing the Inbox skeleton, an empty Inbox, and a channel skeleton along the way. Now the final onboarding screen hands off directly: - On Enter, the router is pointed at the Welcome channel route *before* the app mounts, so Welcome is the app's first-ever route. The pending Welcome entry is consumed at that moment (leaving it would yank a later Home visit back to Welcome); the Home-route listener stays as a fallback for other paths (e.g. relaunch mid-onboarding). - New "entering" transaction stage: the "Meet your starter team" screen stays up as an opaque curtain while the app mounts and loads underneath, then fades out (500ms, instant under reduced motion) once the Welcome surface reports its first settled render. An 8s safety timeout fades anyway so a slow load can never strand the user. - The onboarding flow keeps 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 and visibly restart the screen mid-handoff. - WELCOME_SURFACE_READY_EVENT: announced by the kickoff stage presence hook on the Welcome timeline's first settled render (per channel). New-user sequence is now: starter team screen → fade → Welcome with the kickoff loading stage already running.
The kickoff opener now resolves the owner's profile and opens with "Hi @name, I'm Fizz." — the display name renders as a mention pill and the owner's pubkey rides in the opener's p tags, so Fizz's first message files into the user's Inbox mentions feed instead of leaving the Inbox an empty state on first visit. - buildWelcomeKickoffOpener takes an optional owner display name for the greeting; blank/missing names fall back to the unnamed greeting. - buildWelcomeKickoffOpenerSendInput takes a WelcomeKickoffOwner ({pubkey, displayName}), appends the pubkey to mentionPubkeys without duplicating, and covers every opener variant including the degraded no-teammates path. - Profile resolution is best-effort: a failed lookup degrades to an ungreeted, untagged opener rather than blocking the kickoff.
- Drive the entering-curtain fade duration from ENTERING_CURTAIN_FADE_MS via inline style (matching the boot-splash pattern) instead of a hardcoded duration-500 class that had to stay in sync by hand. - Remove the DEV_FORCE_STAGE dev preview switch — it was scaffolding for iterating on the kickoff stage choreography, and that work is done. - Retire the opener thread-replies query once the closer marker exists: the kickoff is resolved, so revisits to Welcome no longer refetch the opener's thread subtree forever (staleTime 0 made that a permanent extra relay fetch per visit). - Normalize pubkeys when deduping the owner against the opener's mention tags, matching the repo's normalizePubkey convention.
… thread watch Three fixes from a pre-PR review pass over this branch. 1. The kickoff stage's `timed-out` phase did nothing. It rendered identically to `active` (only `data-phase` differed) and `welcomeKickoffSettingUp` stayed true, so a failed kickoff left the characters on stage and the banner reading "Setting up your welcome team…" indefinitely. Every silent failure path in docs/welcome-kickoff-silent-failures.md — including the rate-limiting seen in practice — landed there. Now the characters play their exit and the banner reverts to the normal hint, degrading to an ordinary empty channel. This needs a terminal `done` phase: `hidden` meant both "not yet" and "finished", so a stage that exited on timeout would re-enter `active` off the still-empty timeline and loop forever. 2. Extract `mergeKickoffEvents` and test it. The opener-thread watch (the fix this branch exists for) had no test that the pre-fix code would fail — the merge was inline in the hook, so reverting to `channelEvents` alone kept the suite green. 3. `kickoffResolved` derived from `channelEvents`, but the closer is a thread reply and so never appears there unless the user opened the thread — the watch never retired. It's now a per-channel latch, and the closer effect respects it: retiring the watch drops the subtree that holds the closer, so an events-only guard would classify every teammate as silent and re-run the closer on each revisit. Co-Authored-By: Claude <noreply@anthropic.com>
The doc described the loading animation as "in flight" and claimed it resolved into a quiet timeout state. That was aspirational when written — the timed-out phase rendered identically to active and the banner kept claiming setup was in progress. It's true as of this branch, so record it as landed and keep the doc open for the part that still isn't: telling the user *why* nothing happened. Also notes two things the follow-up needs: the stage can't diagnose a cause (it only knows the timeline is empty), and the empty channel it degrades to invites an @mention of the very agent that failed. Renames the stale "loading card" references to the actual `timed-out` phase. Co-Authored-By: Claude <noreply@anthropic.com>
Resolves four conflicts where main's onboarding polish (#2048, #2055, #2056, #2058) overlapped this branch's Welcome-kickoff choreography. - App.tsx: main added a WelcomeSetup back button inside the early-return block this branch replaced with the `appContent` assignment. Kept this branch's structure and grafted `onBack={onBackToMachineConfig}` onto the relocated render, so the new back navigation survives the restructure. - CommunityOnboardingFlow: main moved the isProfileStage/isTeamStage declaration above `if (!transaction) return null` to feed its new name-input autofocus effect, and that move auto-merged — leaving a duplicate. Dropped this branch's copy and added the `entering` stage to main's relocated isTeamStage. Without that graft the step chrome would unmount and the shell would snap from items-start to items-stretch the instant the curtain stage began, a visible jump before the fade. - CommunityOnboardingFlow: took main's onboarding shell layout wholesale (h-dvh, per-stage alignment, pb-36) and layered this branch's curtain fade on as a third cn() argument. This branch never intended spacing polish on these screens; main's ramp wins. - CommunityOnboardingFlow: kept both `isCurtainFading` and `nameInputRef`. welcomeKickoff.ts auto-merged; main's teammate-presence loop fix (always poll once before checking the deadline) is compatible with this branch's timing changes. Co-Authored-By: Claude <noreply@anthropic.com>
The two first-run kickoff e2e tests still expected "Hi, I'm Fizz." — the ungreeted opener. 0487699 made Fizz greet the new user by name, so the live opener now reads "Hi @morty QA, I'm Fizz." and both assertions were stale against our own intentional copy change. Assert the greeted form including the name rather than relaxing to a name-agnostic substring: both tests already type "Morty QA" during onboarding, so this pins the greeting feature instead of passing whether or not the name renders. The ungreeted path is still covered by welcomeKickoff.test.mjs, which exercises the no-owner-name fallback. Co-Authored-By: Claude <noreply@anthropic.com>
wesbillman
left a comment
There was a problem hiding this comment.
Reviewed the full diff against merge-base (79598bd — one commit behind main, no overlap with #2050). Approving.
What holds up well:
- The stage phase machine is sound:
doneis terminal and checked beforeisWelcomeso a momentary non-Welcome read can't launder it back tohidden; the timeout genuinely resolves; all pinned by the new unit tests. - The per-channel latch on the opener-thread query correctly escapes the self-referential trap (retiring drops the very evidence that justified retiring) — the comment explains it and the code matches.
mergeKickoffEventsfixes a real bug (thread-reply intros invisible to the closer classification) and the dedup handles the legitimate overlap between live subscription and open-thread feed.- The App.tsx restructure preserves boot semantics — transaction → needsSetup → error → applied-gate ordering is unchanged, and keeping the flow in one stable tree slot across the finalizing→entering flip avoids the remount restart.
- Edge cases covered: reduced-motion keeps a 1ms animation (so
onAnimationEndstill fires and the stage can reachdone), owner mention deduped,getProfilefailure degrades to an ungreeted opener, 8s curtain safety valve.
Non-blocking nits:
announcedChannelIdRefinuseWelcomeKickoffStagePresenceholds a single id, so A→B→A re-announces the surface-ready event. Harmless today (nothing listens once the curtain is gone), just noting it.- Stray blank line added in
ChannelScreen.tsx(~line 261).
CI is fully green including both E2E integration shards and all four smoke shards. The documented silent-failure gap is reasonably scoped out to the follow-up doc.
brow
left a comment
There was a problem hiding this comment.
🤖 Independent second review (load-bearing), requested in the Buzz #buzz-launch thread. Reviewed the full diff at 7fafb93 against merge-base 79598bd and ran the onboarding unit suites locally (53/53 pass). No red flags. Report-only pass — approval is with the primary reviewer.
What I verified:
- Stage phase machine:
donechecked beforeisWelcomeso the terminal state can't be laundered back tohidden; timeout only downgradesactive; reduced-motion keeps 1ms animations soonAnimationEndstill fires anddonestays reachable. The container'sonAnimationEndcorrectly filters onanimationName, so bubbled child enter-animations can't trigger a premature exit. - Kickoff thread-watch latch: one-way per-channel latch is the right call (deriving from
kickoffEventswould be self-referential, as the comment explains); the closer path is double-guarded (merged-events marker check + server-sidemarkerExistsinsendWelcomeKickoffCloser), so no duplicate closer even across remounts where the latch resets. - App.tsx restructure: boot ordering preserved (transaction → needsSetup → apply-error → applied gate); the flow keeps one stable tree slot across finalizing→entering so no remount restart;
key={communityKey}retained on provider andAppReady. - Direct entry:
takePendingWelcomeChannelForDirectEntryconsumes the pending entry so a later Home visit can't be yanked back to Welcome; a persistedenteringtransaction self-heals on app relaunch via the 8s safety valve (isTransactionaccepts the new stage, so the localStorage round-trip parses). - Beat removal:
KICKOFF_BEAT_MSremoved cleanly;waitForWelcomeKickoffBeatnow requireswaitMsand all remaining callers pass it.
Two non-blocking notes as inline comments (curtain event not scoped to the target channel; single-slot resolvedChannelId latch).
| }, ENTERING_CURTAIN_FADE_MS); | ||
| }; | ||
|
|
||
| window.addEventListener(WELCOME_SURFACE_READY_EVENT, beginFade); |
There was a problem hiding this comment.
🤖 Non-blocking: beginFade fires on any WELCOME_SURFACE_READY_EVENT without checking detail.channelId against the channel the router was just pointed at. Today only the Welcome ChannelScreen emits this event and direct entry navigates there synchronously before entering begins, so a mismatched emitter can't realistically exist — but if a second Welcome-like surface ever announces readiness, the curtain would fade on the wrong channel's signal. A one-line detail.channelId check would make the contract explicit.
| // 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< |
There was a problem hiding this comment.
🤖 Non-blocking: resolvedChannelId holds a single channel id, so with two Welcome channels (e.g. per-community) an A→B→A visit drops A's latch and re-enables the opener-thread query for one refetch until the closer marker re-latches it. Correctness is unaffected — the closer path is still guarded by the merged-events marker check plus the server-side markerExists in sendWelcomeKickoffCloser — it just costs one redundant subtree fetch. A small Set<string> would avoid it if that ever matters.
Why
Landing on the Welcome channel meant staring at an empty room. The kickoff had a deliberate delay before it started, the closer dragged, and the whole thing gave no sign it was working — so a new user's first impression of Buzz was silence, and a failed kickoff looked exactly like a slow one.
The new-user sequence
Before: onboarding ends → brief blank → empty Welcome channel → (eventually) Fizz speaks, or doesn't, with no way to tell which you're in.
After: onboarding's last screen stays up as a curtain while the app mounts behind it → it fades to reveal Welcome with the starter team already on the composer banner → Fizz greets you by name → teammates introduce themselves in-thread → Fizz closes with the CTA.
What's in it
Failure states
That last row is the honest gap. The stage waits 90s, then the characters exit and the banner reverts to its normal mention hint — so a failed kickoff stops claiming a team is coming, but it can't say why. It's driven purely by "is the timeline empty" plus a timer; it never reads real kickoff state, so it can't tell "Fizz crashed" from "the relay is slow."
Explaining the cause is deliberately out of scope here and tracked in
docs/welcome-kickoff-silent-failures.md, which this PR updates to reflect that the perception gap is now handled while the silent paths themselves are not.Review findings addressed
Three issues found in self-review and fixed in
c887e34a:timed-outrendered identically toactive— the banner kept promising a team was being set up, forever. Nowtimed-outexits, and a terminaldonephase keeps a finished stage from re-enteringactiveoff the still-empty timeline and looping the characters.mergeKickoffEventsso the closer's classification sees them without the user opening the thread — pinned by a test that fails against the old behavior.staleTime: 0). Latched off once the closer marker exists.Merge with main
mainmoved 13 commits during this work, 5 of them onboarding PRs touching the same screens (#2048, #2055, #2056, #2058). Merged in07144e95— four conflicts, resolved in favor of main wherever it had made a deliberate choice this branch hadn't:enteringstage was grafted onto it. Without that, the step chrome would unmount and the shell would snap alignment the instant the curtain began — a visible jump before the fade.h-dvh, per-stage alignment,pb-36). This branch never intended spacing polish there; main's ramp wins.Testing
doneis terminal, banner never claims setup once resolved) and onmergeKickoffEvents.Not verified by hand: the app was not run manually for this change. Verification is automated gates plus code reading. In particular the 90s timeout path has no eyes-on confirmation — it's covered by unit tests on the phase logic, not by watching it happen.
🤖 Generated with Claude Code