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..f050dd4b9f 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 && !settingUp ? ( + + ) : 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 }; +} diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index e5217da3d3..5e0e6a4fa2 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -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, }) => {