From e1aff043a04c792c4bf2abd215e1839e4be3654c Mon Sep 17 00:00:00 2001 From: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Sat, 8 Aug 2026 12:09:12 -0400 Subject: [PATCH 1/3] fix(desktop): welcome banner overlap and missing dismiss control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WelcomeComposerGuidanceLayer was positioned absolute/bottom-full outside the composerWrapperRef measurement boundary, so its height was invisible to useComposerHeightPadding. The banner sat on top of the newest message and blocked thread affordances on that message. There was also no way to dismiss the banner without sending a message that mentions an agent. Fix overlap: Change WelcomeComposerGuidanceLayer from `absolute inset-x-0 bottom-full z-[-1]` to `relative` (in-flow). Being a normal-flow child of composer-dock, its full height is now measured by the ResizeObserver and fed into the timeline's paddingBottom, so the newest message is always fully visible and its thread affordance is always clickable while the banner shows. Fix dismiss: Add an X close button on the prompt state that fires `onDismiss`. The dismiss callback drives `dismissing → hidden` immediately (same slide-down animation as the auto-dismiss path) and marks the channel as completed so the banner does not reappear on channel re-entry within the session. Extract the banner state machine (refs, timers, effects, callbacks) into useWelcomeComposerBanner.ts to keep ChannelPane.tsx under the 1000-line file-size ratchet. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/features/channels/ui/ChannelPane.tsx | 72 ++------------ .../channels/ui/WelcomeComposerBanner.tsx | 53 ++++++---- .../channels/ui/useWelcomeComposerBanner.ts | 99 +++++++++++++++++++ 3 files changed, 141 insertions(+), 83 deletions(-) create mode 100644 desktop/src/features/channels/ui/useWelcomeComposerBanner.ts diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 7ea63f2339..13790d779f 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -43,12 +43,8 @@ import { ChannelComposerActivityAccessory } from "@/features/channels/ui/Channel import { containsWelcomePersonaMention, WelcomeComposerGuidanceLayer, - WELCOME_COMPOSER_BANNER_DISMISS_DURATION_SECONDS, - WELCOME_COMPOSER_BANNER_HIDE_BUFFER_MS, - WELCOME_COMPOSER_BANNER_SUCCESS_SETTLE_MS, - WELCOME_PERSONA_ROTATION_MS, - type WelcomeComposerBannerState, } from "@/features/channels/ui/WelcomeComposerBanner"; +import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; @@ -168,11 +164,6 @@ export const ChannelPane = React.memo(function ChannelPane({ const timelineScrollRef = React.useRef(null); const messageTimelineRef = React.useRef(null); const composerWrapperRef = React.useRef(null); - const completedWelcomeBannerChannelIdsRef = React.useRef(new Set()); - const welcomeComposerDismissTimerRef = React.useRef(null); - const welcomeComposerHideTimerRef = React.useRef(null); - const [welcomeComposerBannerState, setWelcomeComposerBannerState] = - React.useState("prompt"); const { goChannel } = useAppNavigation(); const prepareDmSendChannel = usePrepareDmSendChannel( activeChannel, @@ -221,36 +212,11 @@ export const ChannelPane = React.memo(function ChannelPane({ "css-variable", () => messageTimelineRef.current?.settleAtBottom() ?? false, ); - const clearWelcomeComposerDismissTimer = React.useCallback(() => { - if (welcomeComposerDismissTimerRef.current !== null) { - window.clearTimeout(welcomeComposerDismissTimerRef.current); - welcomeComposerDismissTimerRef.current = null; - } - if (welcomeComposerHideTimerRef.current !== null) { - window.clearTimeout(welcomeComposerHideTimerRef.current); - welcomeComposerHideTimerRef.current = null; - } - }, []); - React.useEffect( - () => () => clearWelcomeComposerDismissTimer(), - [clearWelcomeComposerDismissTimer], - ); - React.useEffect(() => { - clearWelcomeComposerDismissTimer(); - if ( - activeChannelId && - isActiveWelcomeChannel && - completedWelcomeBannerChannelIdsRef.current.has(activeChannelId) - ) { - setWelcomeComposerBannerState("hidden"); - return; - } - setWelcomeComposerBannerState("prompt"); - }, [ - activeChannelId, - clearWelcomeComposerDismissTimer, - isActiveWelcomeChannel, - ]); + const { + bannerState: welcomeComposerBannerState, + completeBanner: completeWelcomeComposerBanner, + dismissBanner: handleDismissWelcomeBanner, + } = useWelcomeComposerBanner(activeChannelId, isActiveWelcomeChannel); const isEditInThread = editTarget != null && threadHeadMessage != null && @@ -330,31 +296,6 @@ export const ChannelPane = React.memo(function ChannelPane({ return pubkeys; }, [activityAgents, agentPubkeys, agentSessionAgents]); - const completeWelcomeComposerBanner = React.useCallback(() => { - if (!activeChannelId || !isActiveWelcomeChannel) { - return; - } - - clearWelcomeComposerDismissTimer(); - completedWelcomeBannerChannelIdsRef.current.add(activeChannelId); - setWelcomeComposerBannerState("complete"); - welcomeComposerDismissTimerRef.current = window.setTimeout(() => { - setWelcomeComposerBannerState("dismissing"); - welcomeComposerDismissTimerRef.current = null; - welcomeComposerHideTimerRef.current = window.setTimeout( - () => { - setWelcomeComposerBannerState("hidden"); - welcomeComposerHideTimerRef.current = null; - }, - WELCOME_COMPOSER_BANNER_DISMISS_DURATION_SECONDS * 1000 + - WELCOME_COMPOSER_BANNER_HIDE_BUFFER_MS, - ); - }, WELCOME_PERSONA_ROTATION_MS + WELCOME_COMPOSER_BANNER_SUCCESS_SETTLE_MS); - }, [ - activeChannelId, - clearWelcomeComposerDismissTimer, - isActiveWelcomeChannel, - ]); const handleSendMessage = React.useCallback( async ( content: string, @@ -739,6 +680,7 @@ export const ChannelPane = React.memo(function ChannelPane({ > {isActiveWelcomeChannel && !timeoutState.active ? ( diff --git a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx index f75b8dbeb9..46aaa04081 100644 --- a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx +++ b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { Bot, Check } from "lucide-react"; +import { Bot, Check, X } from "lucide-react"; import { ComposerDockGlassBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; import { cn } from "@/shared/lib/cn"; @@ -291,9 +291,15 @@ type WelcomeComposerBannerProps = { * banner during this window. */ settingUp?: boolean; + /** + * Called when the user dismisses the banner manually via the close button. + * Only rendered while `state === "prompt"`. + */ + onDismiss?: () => void; }; export function WelcomeComposerBanner({ + onDismiss, settingUp = false, state, }: WelcomeComposerBannerProps) { @@ -307,7 +313,7 @@ export function WelcomeComposerBanner({ animate={{ height: state === "dismissing" ? 0 : "auto", }} - className="overflow-visible" + className="overflow-hidden" initial={false} transition={{ duration: @@ -326,7 +332,7 @@ export function WelcomeComposerBanner({ : 0, }} className={cn( - "relative z-[1] mx-5 -mb-3 flex items-center gap-2 rounded-t-2xl border border-b-0 px-4 pb-5 pt-2.5 text-sm leading-5 transition-colors", + "relative z-[1] mx-5 mb-0 flex items-center gap-2 rounded-t-2xl border border-b-0 px-4 pb-5 pt-2.5 text-sm leading-5 transition-colors", state !== "prompt" ? "border-emerald-500/30 bg-emerald-500/15 text-foreground" : "border-border/60 bg-muted/55 text-muted-foreground", @@ -376,7 +382,7 @@ export function WelcomeComposerBanner({ {state !== "prompt" ? ( )} + {state === "prompt" && onDismiss ? ( + + ) : null} @@ -422,22 +439,22 @@ type WelcomeComposerGuidanceLayerProps = WelcomeComposerBannerProps & { export function WelcomeComposerGuidanceLayer({ children, + onDismiss, settingUp, state, }: WelcomeComposerGuidanceLayerProps) { return ( -
-
- - {children} - -
+
+ + {children} +
); } diff --git a/desktop/src/features/channels/ui/useWelcomeComposerBanner.ts b/desktop/src/features/channels/ui/useWelcomeComposerBanner.ts new file mode 100644 index 0000000000..eb09eddd0e --- /dev/null +++ b/desktop/src/features/channels/ui/useWelcomeComposerBanner.ts @@ -0,0 +1,99 @@ +import * as React from "react"; + +import { + WELCOME_COMPOSER_BANNER_DISMISS_DURATION_SECONDS, + WELCOME_COMPOSER_BANNER_HIDE_BUFFER_MS, + WELCOME_COMPOSER_BANNER_SUCCESS_SETTLE_MS, + WELCOME_PERSONA_ROTATION_MS, + type WelcomeComposerBannerState, +} from "@/features/channels/ui/WelcomeComposerBanner"; + +/** + * Manages the Welcome-channel composer hint banner's state machine. + * + * Tracks which channels have been completed within the session so the banner + * stays hidden on re-entry. Exposes three transitions: + * - `completeBanner`: agent-mention path — plays the "Nice work." success + * animation before auto-dismissing. + * - `dismissBanner`: manual X-button path — immediately begins the slide-down + * dismiss animation. + */ +export function useWelcomeComposerBanner( + activeChannelId: string | null, + isActiveWelcomeChannel: boolean, +): { + bannerState: WelcomeComposerBannerState; + completeBanner: () => void; + dismissBanner: () => void; +} { + const completedChannelIdsRef = React.useRef(new Set()); + const dismissTimerRef = React.useRef(null); + const hideTimerRef = React.useRef(null); + const [bannerState, setBannerState] = + React.useState("prompt"); + + const clearTimers = React.useCallback(() => { + if (dismissTimerRef.current !== null) { + window.clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = null; + } + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }, []); + + React.useEffect(() => () => clearTimers(), [clearTimers]); + + React.useEffect(() => { + clearTimers(); + if ( + activeChannelId && + isActiveWelcomeChannel && + completedChannelIdsRef.current.has(activeChannelId) + ) { + setBannerState("hidden"); + return; + } + setBannerState("prompt"); + }, [activeChannelId, clearTimers, isActiveWelcomeChannel]); + + const scheduleHide = React.useCallback(() => { + hideTimerRef.current = window.setTimeout( + () => { + setBannerState("hidden"); + hideTimerRef.current = null; + }, + WELCOME_COMPOSER_BANNER_DISMISS_DURATION_SECONDS * 1000 + + WELCOME_COMPOSER_BANNER_HIDE_BUFFER_MS, + ); + }, []); + + const completeBanner = React.useCallback(() => { + if (!activeChannelId || !isActiveWelcomeChannel) { + return; + } + + clearTimers(); + completedChannelIdsRef.current.add(activeChannelId); + setBannerState("complete"); + dismissTimerRef.current = window.setTimeout(() => { + setBannerState("dismissing"); + dismissTimerRef.current = null; + scheduleHide(); + }, WELCOME_PERSONA_ROTATION_MS + WELCOME_COMPOSER_BANNER_SUCCESS_SETTLE_MS); + }, [activeChannelId, clearTimers, isActiveWelcomeChannel, scheduleHide]); + + const dismissBanner = React.useCallback(() => { + if (!activeChannelId || !isActiveWelcomeChannel) { + return; + } + + clearTimers(); + completedChannelIdsRef.current.add(activeChannelId); + setBannerState("dismissing"); + scheduleHide(); + }, [activeChannelId, clearTimers, isActiveWelcomeChannel, scheduleHide]); + + return { bannerState, completeBanner, dismissBanner }; +} From fb3b3da76f80a8d904bf506e814ceaf229398e09 Mon Sep 17 00:00:00 2001 From: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Sat, 8 Aug 2026 12:16:45 -0400 Subject: [PATCH 2/3] test(desktop): update welcome banner layout assertions for in-flow positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expectWelcomeComposerBannerLayout tested geometry from the old absolute/bottom-full design where the banner's bottom extended past the composer's top edge. With the guidance layer now in normal flow, the banner sits entirely above the composer (no overlap) and the guidance backdrop bottom is strictly less than composerBox.y rather than approximately equal to it. Remove the z-index comparison — no longer meaningful once the guidance layer dropped its z-[-1] stacking context. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/tests/e2e/onboarding.spec.ts | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index e5217da3d3..0437b2df82 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -226,23 +226,15 @@ async function expectWelcomeComposerBannerLayout(page: Page) { await composer.getByTestId("welcome-composer-guide-banner").count(), ).toBe(0); expect(bannerBox.y).toBeLessThan(composerBox.y); - expect(bannerBox.y + bannerBox.height).toBeGreaterThan(composerBox.y); + // Banner is in normal flow above the composer, no overlap. + expect(bannerBox.y + bannerBox.height).toBeLessThanOrEqual(composerBox.y); expect(Math.abs(dockBackdropBox.y - composerBox.y)).toBeLessThanOrEqual(1); expect(guidanceBackdropBox.y).toBeLessThanOrEqual(bannerBox.y); - expect( - Math.abs( - guidanceBackdropBox.y + guidanceBackdropBox.height - composerBox.y, - ), - ).toBeLessThanOrEqual(1); - const [guidanceZIndex, backdropZIndex] = await Promise.all([ - guidanceLayer.evaluate((element) => - Number(window.getComputedStyle(element).zIndex), - ), - page - .getByTestId("composer-dock-backdrop") - .evaluate((element) => Number(window.getComputedStyle(element).zIndex)), - ]); - expect(guidanceZIndex).toBeLessThan(backdropZIndex); + // The guidance backdrop extends bottom-3 (12px) short of the banner's bottom, + // visually connecting up to the composer. + expect(guidanceBackdropBox.y + guidanceBackdropBox.height).toBeLessThan( + composerBox.y, + ); expect( await page .getByTestId("channel-composer-overlay") From b9fe344b7be464b2b07cba3c433e986009c3022a Mon Sep 17 00:00:00 2001 From: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Sat, 8 Aug 2026 12:37:58 -0400 Subject: [PATCH 3/3] fix(desktop): suppress X during settingUp; add dismiss E2E coverage; fix CI layout assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes folded into one push per review feedback: 1. Wes P1: suppress the dismiss X while settingUp is true. The kickoff stage characters are absolutely positioned above the composer; allowing dismiss while they are present would hide the banner but leave the stage floating with nothing under it until the 90s timeout. The setup window is transient so suppressing the X there is the minimal correct fix; no teardown coupling needed. 2. E2E coverage (Wes): two new onboarding.spec.ts tests exercise the dismiss path that was previously untested: - clicking X removes the guidance surface (banner + guidance layer gone) - dismiss persists on channel re-entry (completedChannelIdsRef holds) 3. CI fix: expectWelcomeComposerBannerLayout asserted dockBackdropBox.y ≈ composerBox.y, which was correct when the guidance layer was absolute bottom-full (dock only contained the composer). Now that the guidance layer is in-flow inside composer-dock, the backdrop's absolute inset-y-0 spans guidance-layer-top → bottom, so the correct assertion is dockBackdropBox.y ≈ guidanceLayerBox.y. Added guidanceLayerBox measurement to the helper and updated the assertion accordingly. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../channels/ui/WelcomeComposerBanner.tsx | 2 +- desktop/tests/e2e/onboarding.spec.ts | 62 ++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx index 46aaa04081..f050dd4b9f 100644 --- a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx +++ b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx @@ -416,7 +416,7 @@ export function WelcomeComposerBanner({ )} - {state === "prompt" && onDismiss ? ( + {state === "prompt" && onDismiss && !settingUp ? (