fix(desktop): welcome banner overlap and missing dismiss control (#5330)

## Problem

The `WelcomeComposerGuidanceLayer` in the `#Welcome` channel was
positioned with `absolute inset-x-0 bottom-full z-[-1]` — outside the
`composerWrapperRef` measurement boundary. `useComposerHeightPadding`
observes `composerWrapperRef`'s block size to set `paddingBottom` on the
timeline scroll container, but the absolutely-positioned layer didn't
contribute to that size. The banner sat directly on top of the newest
message, blocking the thread affordance on that message, and had no
manual dismiss control.

## Fix

**Overlap**: Changed `WelcomeComposerGuidanceLayer` from `absolute
inset-x-0 bottom-full z-[-1]` to `relative` (in normal flow). As a
normal-flow child of `composer-dock`, the layer's 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.

**Dismiss**: Added an `X` close button
(`data-testid="welcome-composer-dismiss-button"`) on the prompt state.
Clicking fires `onDismiss`, which drives `dismissing → hidden`
immediately (same slide-down animation as the auto-dismiss path) and
marks the channel ID as completed in the session ref so the banner does
not reappear on channel re-entry within the session.

**Refactor**: Extracted the banner state machine (refs, timers,
`useEffect`s, and callbacks) from `ChannelPane.tsx` into
`useWelcomeComposerBanner.ts`. This keeps `ChannelPane.tsx` well under
the 1000-line file-size ratchet and makes the state machine
independently testable.

## Changed files

- `desktop/src/features/channels/ui/WelcomeComposerBanner.tsx` —
`WelcomeComposerGuidanceLayer` positioning fix; `onDismiss` prop;
dismiss button; `overflow-hidden` / `mb-0` / `flex-1` cleanup
- `desktop/src/features/channels/ui/ChannelPane.tsx` — remove inline
banner state machine, use `useWelcomeComposerBanner` hook, pass
`onDismiss`
- `desktop/src/features/channels/ui/useWelcomeComposerBanner.ts` — new
hook owning all banner state

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-08 13:00:09 -04:00
committed by GitHub
parent fbf89e3bed
commit f029deafae
4 changed files with 209 additions and 99 deletions

View File

@@ -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<HTMLDivElement>(null);
const messageTimelineRef = React.useRef<MessageTimelineHandle>(null);
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
const completedWelcomeBannerChannelIdsRef = React.useRef(new Set<string>());
const welcomeComposerDismissTimerRef = React.useRef<number | null>(null);
const welcomeComposerHideTimerRef = React.useRef<number | null>(null);
const [welcomeComposerBannerState, setWelcomeComposerBannerState] =
React.useState<WelcomeComposerBannerState>("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 ? (
<WelcomeComposerGuidanceLayer
onDismiss={handleDismissWelcomeBanner}
settingUp={welcomeKickoffSettingUp}
state={welcomeComposerBannerState}
>

View File

@@ -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" ? (
<motion.span
animate="animate"
className="min-w-0"
className="min-w-0 flex-1"
data-animation-target="success-copy"
initial="initial"
key="complete-copy"
@@ -387,7 +393,7 @@ export function WelcomeComposerBanner({
) : settingUp ? (
<motion.span
animate="animate"
className="min-w-0"
className="min-w-0 flex-1"
data-testid="welcome-composer-setting-up-copy"
exit="exit"
initial="initial"
@@ -399,7 +405,7 @@ export function WelcomeComposerBanner({
) : (
<motion.span
animate="animate"
className="min-w-0"
className="min-w-0 flex-1"
exit="exit"
initial="initial"
key="prompt-copy"
@@ -410,6 +416,17 @@ export function WelcomeComposerBanner({
</motion.span>
)}
</AnimatePresence>
{state === "prompt" && onDismiss && !settingUp ? (
<button
aria-label="Dismiss hint"
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="welcome-composer-dismiss-button"
onClick={onDismiss}
type="button"
>
<X aria-hidden className="h-3 w-3" />
</button>
) : null}
</motion.div>
</motion.div>
</AnimatePresence>
@@ -422,22 +439,22 @@ type WelcomeComposerGuidanceLayerProps = WelcomeComposerBannerProps & {
export function WelcomeComposerGuidanceLayer({
children,
onDismiss,
settingUp,
state,
}: WelcomeComposerGuidanceLayerProps) {
return (
<div
className="absolute inset-x-0 bottom-full z-[-1]"
data-testid="welcome-composer-guidance-layer"
>
<div className="relative">
<ComposerDockGlassBackdrop
className="absolute inset-x-5 top-0 bottom-3 z-0 rounded-t-2xl"
testId="welcome-composer-guidance-backdrop"
/>
{children}
<WelcomeComposerBanner settingUp={settingUp} state={state} />
</div>
<div className="relative" data-testid="welcome-composer-guidance-layer">
<ComposerDockGlassBackdrop
className="absolute inset-x-5 top-0 bottom-3 z-0 rounded-t-2xl"
testId="welcome-composer-guidance-backdrop"
/>
{children}
<WelcomeComposerBanner
onDismiss={onDismiss}
settingUp={settingUp}
state={state}
/>
</div>
);
}

View File

@@ -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<string>());
const dismissTimerRef = React.useRef<number | null>(null);
const hideTimerRef = React.useRef<number | null>(null);
const [bannerState, setBannerState] =
React.useState<WelcomeComposerBannerState>("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 };
}

View File

@@ -207,6 +207,7 @@ async function expectWelcomeComposerBannerLayout(page: Page) {
.locator("div")
.boundingBox();
const guidanceLayer = page.getByTestId("welcome-composer-guidance-layer");
const guidanceLayerBox = await guidanceLayer.boundingBox();
const guidanceBackdrop = page.getByTestId(
"welcome-composer-guidance-backdrop",
);
@@ -217,6 +218,7 @@ async function expectWelcomeComposerBannerLayout(page: Page) {
!personaMentionBox ||
!composerBox ||
!dockBackdropBox ||
!guidanceLayerBox ||
!guidanceBackdropBox
) {
throw new Error("Could not measure welcome composer banner layout");
@@ -226,23 +228,20 @@ 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);
expect(Math.abs(dockBackdropBox.y - composerBox.y)).toBeLessThanOrEqual(1);
// Banner is in normal flow above the composer, no overlap.
expect(bannerBox.y + bannerBox.height).toBeLessThanOrEqual(composerBox.y);
// The dock backdrop is absolute inset-y-0 inside composer-dock, which now
// contains the guidance layer + composer in flow, so its top aligns with the
// guidance layer top (not the composer top).
expect(Math.abs(dockBackdropBox.y - guidanceLayerBox.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")
@@ -3170,6 +3169,59 @@ test("finishing onboarding creates starter channels and focuses welcome-everyone
await expectWelcomeComposerBannerCompletesAfterPersonaMention(page);
});
test("welcome-everywhere banner: X dismiss removes the guidance surface", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
const banner = page.getByTestId("welcome-composer-guide-banner");
const guidanceLayer = page.getByTestId("welcome-composer-guidance-layer");
const dismissButton = page.getByTestId("welcome-composer-dismiss-button");
// Banner and guidance layer are visible in the prompt state.
await expect(banner).toBeVisible();
await expect(guidanceLayer).toBeVisible();
await expect(dismissButton).toBeVisible();
await dismissButton.click();
// After dismiss the entire guidance surface must be gone.
await expect(banner).toHaveCount(0, { timeout: 2_000 });
await expect(guidanceLayer).toHaveCount(0);
});
test("welcome-everywhere banner: dismiss persists after channel re-entry", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await completeProfileOnboarding(page);
const banner = page.getByTestId("welcome-composer-guide-banner");
await expect(banner).toBeVisible();
await page.getByTestId("welcome-composer-dismiss-button").click();
await expect(banner).toHaveCount(0, { timeout: 2_000 });
// Leave the Welcome channel.
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toContainText("general");
await expect(banner).toHaveCount(0);
// Return — banner must stay hidden.
await page.getByTestId("channel-welcome-everyone").click();
await expect(page.getByTestId("chat-title")).toContainText("Welcome");
await expect(banner).toHaveCount(0);
});
test("initial profile read failures still hold incomplete users in onboarding", async ({
page,
}) => {