diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs index 84bbbd5224..22a3cfe2c9 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -456,6 +456,7 @@ test("timeline-intro-surface: skeleton suppresses intro while loading", () => { selectTimelineIntroSurface({ hasChannelIntro: true, hasDirectMessageIntro: false, + hasReachedChannelStart: true, isSkeletonVisible: true, }), null, @@ -475,17 +476,31 @@ test("timeline-intro-surface: intro may coexist with the message list", () => { selectTimelineIntroSurface({ hasChannelIntro: true, hasDirectMessageIntro: false, + hasReachedChannelStart: true, isSkeletonVisible: false, }), "channel-intro", ); }); +test("timeline-intro-surface: channel intro waits for oldest-history boundary", () => { + assert.equal( + selectTimelineIntroSurface({ + hasChannelIntro: true, + hasDirectMessageIntro: false, + hasReachedChannelStart: false, + isSkeletonVisible: false, + }), + null, + ); +}); + test("timeline-intro-surface: direct-message intro wins over channel intro", () => { assert.equal( selectTimelineIntroSurface({ hasChannelIntro: true, hasDirectMessageIntro: true, + hasReachedChannelStart: false, isSkeletonVisible: false, }), "direct-message-intro", @@ -497,6 +512,7 @@ test("timeline-intro-surface: no intro without an intro model", () => { selectTimelineIntroSurface({ hasChannelIntro: false, hasDirectMessageIntro: false, + hasReachedChannelStart: true, isSkeletonVisible: false, }), null, diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts index 8116a78dfe..9e3ac823c5 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.ts +++ b/desktop/src/features/messages/lib/timelineSnapshot.ts @@ -225,10 +225,12 @@ export type TimelineIntroSurface = export function selectTimelineIntroSurface({ hasChannelIntro, hasDirectMessageIntro, + hasReachedChannelStart, isSkeletonVisible, }: { hasChannelIntro: boolean; hasDirectMessageIntro: boolean; + hasReachedChannelStart: boolean; isSkeletonVisible: boolean; }): TimelineIntroSurface { if (isSkeletonVisible) { @@ -237,7 +239,7 @@ export function selectTimelineIntroSurface({ if (hasDirectMessageIntro) { return "direct-message-intro"; } - if (hasChannelIntro) { + if (hasChannelIntro && hasReachedChannelStart) { return "channel-intro"; } return null; diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index e53fbdcd50..417e5adc6a 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -212,25 +212,11 @@ const MessageTimelineBase = React.forwardRef< liveCount: messages.length, }); const showTimelineSkeleton = timelineBodySurface === "skeleton"; - const timelineIntroSurface = selectTimelineIntroSurface({ - hasChannelIntro: channelIntro !== null && directMessageIntro === null, - hasDirectMessageIntro: directMessageIntro !== null, - isSkeletonVisible: showTimelineSkeleton, - }); - const showDirectMessageIntro = - timelineIntroSurface === "direct-message-intro"; - const showChannelIntro = timelineIntroSurface === "channel-intro"; - const activeDirectMessageIntro = showDirectMessageIntro - ? directMessageIntro - : null; - const activeChannelIntro = showChannelIntro ? channelIntro : null; - const showIntro = showDirectMessageIntro || showChannelIntro; - const showGenericEmpty = timelineBodySurface === "empty" && !showIntro; - const showMessageList = timelineBodySurface === "list"; const { highlightedMessageId, isAtBottom, + hasReachedTop, newMessageCount, onScroll, scrollToBottom, @@ -250,6 +236,24 @@ const MessageTimelineBase = React.forwardRef< targetMessageId, }); + const timelineIntroSurface = selectTimelineIntroSurface({ + hasChannelIntro: channelIntro !== null && directMessageIntro === null, + hasDirectMessageIntro: directMessageIntro !== null, + hasReachedChannelStart: + (!hasOlderMessages && hasReachedTop) || messages.length === 0, + isSkeletonVisible: showTimelineSkeleton, + }); + const showDirectMessageIntro = + timelineIntroSurface === "direct-message-intro"; + const showChannelIntro = timelineIntroSurface === "channel-intro"; + const activeDirectMessageIntro = showDirectMessageIntro + ? directMessageIntro + : null; + const activeChannelIntro = showChannelIntro ? channelIntro : null; + const showIntro = showDirectMessageIntro || showChannelIntro; + const showGenericEmpty = timelineBodySurface === "empty" && !showIntro; + const showMessageList = timelineBodySurface === "list"; + React.useImperativeHandle( ref, () => ({ diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index ec67c71386..93ab10dbfa 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -9,6 +9,7 @@ import type { TimelineMessage } from "@/features/messages/types"; * rounding from the layout engine. */ const AT_BOTTOM_THRESHOLD_PX = 32; +const AT_TOP_THRESHOLD_PX = 32; type AnchorState = | { kind: "at-bottom" } @@ -51,6 +52,8 @@ type UseAnchoredScrollResult = { onScroll: () => void; /** True when the user is within `AT_BOTTOM_THRESHOLD_PX` of the bottom. */ isAtBottom: boolean; + /** True once the user has reached the top of the currently loaded timeline. */ + hasReachedTop: boolean; /** Number of new messages that have arrived while the user is not at the * bottom. Cleared when the user returns to the bottom. */ newMessageCount: number; @@ -76,6 +79,18 @@ function isAtBottomNow(container: HTMLDivElement) { ); } +function isAtTopNow(container: HTMLDivElement) { + return container.scrollTop <= AT_TOP_THRESHOLD_PX; +} + +function isAtTimelineStartNow(container: HTMLDivElement) { + const scrollableDistance = Math.max( + 0, + container.scrollHeight - container.clientHeight, + ); + return scrollableDistance <= AT_TOP_THRESHOLD_PX || isAtTopNow(container); +} + /** * Pick an anchor for the current scroll position. * @@ -228,6 +243,7 @@ export function useAnchoredScroll({ // layout effect below so the read is consistent with what's in the DOM. const messagesRef = React.useRef(messages); const [isAtBottom, setIsAtBottom] = React.useState(true); + const [hasReachedTop, setHasReachedTop] = React.useState(false); const [newMessageCount, setNewMessageCount] = React.useState(0); const [highlightedMessageId, setHighlightedMessageId] = React.useState< string | null @@ -252,6 +268,7 @@ export function useAnchoredScroll({ React.useLayoutEffect(() => { anchorRef.current = { kind: "at-bottom" }; setIsAtBottom(true); + setHasReachedTop(false); setNewMessageCount(0); setHighlightedMessageId(null); hasInitializedRef.current = false; @@ -355,6 +372,7 @@ export function useAnchoredScroll({ anchorRef.current = computeAnchor(container); const atBottom = anchorRef.current.kind === "at-bottom"; setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom)); + setHasReachedTop((current) => current || isAtTimelineStartNow(container)); if (atBottom) { setNewMessageCount(0); } @@ -402,6 +420,7 @@ export function useAnchoredScroll({ scrollToBottomImperative("auto"); } hasInitializedRef.current = true; + setHasReachedTop((current) => current || isAtTimelineStartNow(container)); prevLastMessageIdRef.current = messages[messages.length - 1]?.id; prevMessageCountRef.current = messages.length; return; @@ -452,6 +471,7 @@ export function useAnchoredScroll({ prevLastMessageIdRef.current = lastMessage?.id; prevMessageCountRef.current = messages.length; + setHasReachedTop((current) => current || isAtTimelineStartNow(container)); }, [ isFetchingOlder, isLoading, @@ -617,6 +637,7 @@ export function useAnchoredScroll({ return { onScroll, isAtBottom, + hasReachedTop, newMessageCount, highlightedMessageId, scrollToBottom: scrollToBottomImperative, diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 692d578956..28c4249d48 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -793,21 +793,100 @@ test("empty channel shows intro actions", async ({ page }) => { ); }); -test("channel with messages shows content", async ({ page }) => { +test("short channel with messages shows intro actions on open", async ({ + page, +}) => { + const channelName = `short-intro-${Date.now()}`; + const message = "Only message in a short channel"; + await page.goto("/"); + await page.getByRole("button", { name: "Create a channel" }).click(); + await page.getByTestId("create-channel-name").fill(channelName); + await page.getByTestId("create-channel-submit").click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelName); + + await page.getByTestId("message-input").fill(message); + await page.getByTestId("send-message").click(); + await expect(page.getByTestId("message-timeline")).toContainText(message); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.getByTestId(`channel-${channelName}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelName); + await expect(page.getByTestId("message-timeline")).toContainText(message); await expect(page.getByTestId("message-channel-intro")).toBeVisible(); await expect(page.getByTestId("message-channel-intro")).toContainText( "This is the beginning of the regular channel.", ); await expect( - page.getByTestId("channel-intro-action-create-channel"), + page.getByTestId("channel-intro-action-create-agent"), + ).toBeVisible(); + await expect( + page.getByTestId("channel-intro-action-add-people"), + ).toBeVisible(); +}); + +test("scrollable channel with recent messages hides intro actions until top", async ({ + page, +}) => { + const channelName = `long-intro-${Date.now()}`; + const messages = Array.from( + { length: 24 }, + (_, index) => `Scrollable channel message ${index + 1}`, + ); + + await page.goto("/"); + await page.getByRole("button", { name: "Create a channel" }).click(); + await page.getByTestId("create-channel-name").fill(channelName); + await page.getByTestId("create-channel-submit").click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelName); + + for (const message of messages) { + await page.getByTestId("message-input").fill(message); + await page.getByTestId("send-message").click(); + await expect(page.getByTestId("message-timeline")).toContainText(message); + } + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.getByTestId(`channel-${channelName}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelName); + await expect(page.getByTestId("message-channel-intro")).toHaveCount(0); + await expect( + page.getByTestId("channel-intro-action-create-agent"), ).toHaveCount(0); + await expect(page.getByTestId("channel-intro-action-add-people")).toHaveCount( + 0, + ); + await expect(page.getByTestId("message-timeline")).toContainText( + messages[messages.length - 1], + ); + + await page.getByTestId("message-timeline").evaluate((element) => { + element.scrollTop = 0; + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await expect(page.getByTestId("message-channel-intro")).toBeVisible(); await expect( page.getByTestId("channel-intro-action-create-agent"), ).toBeVisible(); + await expect( + page.getByTestId("channel-intro-action-add-people"), + ).toBeVisible(); +}); + +test("channel with messages shows content", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("message-channel-intro")).toHaveCount(0); + await expect( + page.getByTestId("channel-intro-action-create-channel"), + ).toHaveCount(0); + await expect( + page.getByTestId("channel-intro-action-create-agent"), + ).toHaveCount(0); await expect(page.getByTestId("welcome-composer-guide-banner")).toHaveCount( 0, ); @@ -815,8 +894,6 @@ test("channel with messages shows content", async ({ page }) => { await expect(page.getByTestId("message-timeline")).toContainText( "Welcome to #general", ); - await expectSameLeftInset(page, "message-channel-intro", "message-row"); - await expectIntroBalancedAroundDayDivider(page, "message-channel-intro"); }); test("shows and clears activity indicators for active channel agents", async ({