diff --git a/packages/views/channels/components/channel-message-list.tsx b/packages/views/channels/components/channel-message-list.tsx index e3657dab15..487153fb81 100644 --- a/packages/views/channels/components/channel-message-list.tsx +++ b/packages/views/channels/components/channel-message-list.tsx @@ -1,6 +1,6 @@ "use client"; -import { Fragment, useEffect, useMemo, useRef } from "react"; +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useWorkspaceId } from "@multica/core/hooks"; import { @@ -8,6 +8,7 @@ import { memberListOptions, } from "@multica/core/workspace/queries"; import { channelMessagesOptions } from "@multica/core/channels"; +import { ChevronDown } from "lucide-react"; import { MessageRow } from "./message-row"; import { useT } from "../../i18n"; @@ -92,14 +93,32 @@ export function ChannelMessageList({ const dividerRef = useRef(null); const initialScrollDoneRef = useRef<{ channelId: string | null }>({ channelId: null }); const prevCountRef = useRef(messages.length); + const isAtBottomRef = useRef(true); + const [hasNewMessages, setHasNewMessages] = useState(false); // Channel switch resets initial-scroll bookkeeping so the new channel // gets its own anchor decision (divider into view OR scroll to bottom). useEffect(() => { initialScrollDoneRef.current = { channelId: null }; prevCountRef.current = 0; + isAtBottomRef.current = true; + setHasNewMessages(false); }, [channelId]); + const handleScroll = () => { + const el = containerRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100; + isAtBottomRef.current = atBottom; + if (atBottom) setHasNewMessages(false); + }; + + const scrollToBottom = () => { + const el = containerRef.current; + if (el) el.scrollTop = el.scrollHeight; + setHasNewMessages(false); + }; + // First render with messages: anchor to divider if we have one, else // scroll to bottom. Subsequent message arrivals fall through to the // "tail" effect below. @@ -118,14 +137,19 @@ export function ChannelMessageList({ prevCountRef.current = messages.length; }, [channelId, messages.length, dividerBeforeIndex]); - // Tail behavior: on new messages after initial scroll, jump to bottom. - // (Phase 2 polish would gate this on "user was already at bottom" so a - // user reading older history isn't yanked down. Keeping it simple now.) + // Tail behavior: on new messages after initial scroll, preserve scroll + // position unless the user is already following the bottom. useEffect(() => { if (initialScrollDoneRef.current.channelId !== channelId) return; if (messages.length > prevCountRef.current) { const el = containerRef.current; - if (el) el.scrollTop = el.scrollHeight; + if (el) { + if (isAtBottomRef.current) { + el.scrollTop = el.scrollHeight; + } else { + setHasNewMessages(true); + } + } } prevCountRef.current = messages.length; }, [channelId, messages.length]); @@ -145,40 +169,53 @@ export function ChannelMessageList({ ); } return ( -
- {messages.map((m, i) => { - const prev = i > 0 ? messages[i - 1] : null; - // The unread divider visually breaks a group, so a continuation - // immediately after the divider should re-introduce the avatar - // header — feels weird otherwise. Hence the `dividerBeforeIndex` - // check inside the predicate. - const isContinuation = - !!prev && - dividerBeforeIndex !== i && - prev.author_type === m.author_type && - prev.author_id === m.author_id && - new Date(m.created_at).getTime() - new Date(prev.created_at).getTime() < - GROUP_CONTINUATION_MS; - return ( - - {dividerBeforeIndex === i ? ( - $.messages.new_messages_aria)} - label={t(($) => $.messages.new_messages)} +
+
+ {messages.map((m, i) => { + const prev = i > 0 ? messages[i - 1] : null; + // The unread divider visually breaks a group, so a continuation + // immediately after the divider should re-introduce the avatar + // header — feels weird otherwise. Hence the `dividerBeforeIndex` + // check inside the predicate. + const isContinuation = + !!prev && + dividerBeforeIndex !== i && + prev.author_type === m.author_type && + prev.author_id === m.author_id && + new Date(m.created_at).getTime() - new Date(prev.created_at).getTime() < + GROUP_CONTINUATION_MS; + return ( + + {dividerBeforeIndex === i ? ( + $.messages.new_messages_aria)} + label={t(($) => $.messages.new_messages)} + /> + ) : null} + - ) : null} - - - ); - })} + + ); + })} +
+ {hasNewMessages && ( +
+ +
+ )}
); } diff --git a/packages/views/locales/en/channels.json b/packages/views/locales/en/channels.json index 0b181c2d0a..5146903a8e 100644 --- a/packages/views/locales/en/channels.json +++ b/packages/views/locales/en/channels.json @@ -122,6 +122,7 @@ "empty": "No messages yet. Be the first to say hello.", "new_messages": "New messages", "new_messages_aria": "New messages", + "new_messages_below": "New messages ↓", "deleted_placeholder": "[message deleted]", "agent_label": "agent", "edited": "(edited)", diff --git a/packages/views/locales/zh-Hans/channels.json b/packages/views/locales/zh-Hans/channels.json index 95f9358a70..b79af3c2ac 100644 --- a/packages/views/locales/zh-Hans/channels.json +++ b/packages/views/locales/zh-Hans/channels.json @@ -122,6 +122,7 @@ "empty": "还没有消息。来打个招呼吧。", "new_messages": "新消息", "new_messages_aria": "新消息", + "new_messages_below": "新消息 ↓", "deleted_placeholder": "[消息已删除]", "agent_label": "智能体", "edited": "(已编辑)",