Hide channel intro actions until history start (#1151)

Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-06-19 16:09:39 -04:00
committed by GitHub
co-authored by npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
parent 0c5dbf3452
commit 7aa6daa496
5 changed files with 140 additions and 20 deletions
@@ -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,
@@ -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;
@@ -218,25 +218,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,
@@ -256,6 +242,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,
() => ({
@@ -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<TimelineMessage[]>(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,
+81 -4
View File
@@ -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-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"),
).toBeVisible();
).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 ({