fix(timeline): render channel intro header flush at the top of the list

The channel intro block (the `#` avatar, channel title, "beginning of…"
line, and Create agent / Add people cards) was bottom-pinned: it lived in
a `min-h-full` flex column with `mt-auto`, which Slack-style pushes it to
the viewport bottom when the list is short. tho wants it flush at the
actual TOP of the virtualized list, since it's an in-list header — not a
floating element.

Gate the push-down on `topAlignIntro = showChannelIntro` and turn it off
for the channel-intro case in 4 spots: the outer wrapper `min-h-full`, the
SkeletonReveal `className` + `contentClassName` `min-h-full`, and the
intro div's own `mt-auto`. Natural top-down flow then places the header at
the top. The DM intro and the generic empty state keep their existing
bottom-pin behavior.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-15 22:27:27 -07:00
co-authored by Taylor Ho
parent 01b292c5ef
commit 78f81e89f4
@@ -326,6 +326,13 @@ export const MessageTimeline = React.memo(function MessageTimeline({
const showChannelIntro =
!isLoading && channelIntro !== null && directMessageIntro === null;
const showIntro = showDirectMessageIntro || showChannelIntro;
// The channel intro is an in-list HEADER: it must sit FLUSH at the top of the
// virtualized list (the `#` avatar / title / "beginning of…" / action cards),
// not float bottom-pinned in the viewport like the DM intro / empty state. So
// for the channel-intro case we drop the `min-h-full` fill + the intro's
// `mt-auto`, letting natural top-down flow place it at the top. Everything
// else (DM intro, generic empty) keeps the existing bottom-pin behavior.
const topAlignIntro = showChannelIntro;
const showGenericEmpty =
!isLoading &&
deferredMessages.length === 0 &&
@@ -355,7 +362,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
className={cn(
"flex w-full flex-col gap-2",
channelChrome.contentPadding,
(showIntro || showGenericEmpty) && "min-h-full",
(showIntro || showGenericEmpty) && !topAlignIntro && "min-h-full",
)}
>
<div ref={topSentinelRef} aria-hidden className="h-px" />
@@ -369,12 +376,16 @@ export const MessageTimeline = React.memo(function MessageTimeline({
<SkeletonReveal
className={cn(
"min-h-[18rem]",
(showIntro || showGenericEmpty) && "min-h-full",
(showIntro || showGenericEmpty) &&
!topAlignIntro &&
"min-h-full",
showMessageList && !showIntro && "mt-auto",
)}
contentClassName={cn(
"flex flex-col gap-2",
(showIntro || showGenericEmpty) && "min-h-full",
(showIntro || showGenericEmpty) &&
!topAlignIntro &&
"min-h-full",
)}
loading={isLoading}
skeleton={<TimelineSkeleton rows={timelineSkeletonRows} />}
@@ -402,7 +413,12 @@ export const MessageTimeline = React.memo(function MessageTimeline({
{showChannelIntro ? (
<div
className="mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
className={cn(
"mb-0.5 flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left",
// Channel intro is the in-list header → flush to the top, no
// bottom-pin. (mt-auto would push it to the viewport bottom.)
!topAlignIntro && "mt-auto",
)}
data-testid="message-channel-intro"
>
<div