diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index af127b60ab..cda7f06632 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -59,6 +59,7 @@ import { memo, type ReactNode, useEffect, + useLayoutEffect, useMemo, useRef, } from "react"; @@ -695,7 +696,17 @@ export function ChannelFeedView({ return merged; }, [tasks, systemMessages]); - if (isLoading && entries.length === 0 && pending.length === 0) { + const viewportRef = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is a trigger — switching channels or finishing the initial load swaps/completes the rows without a remount, so re-land at the latest message + useLayoutEffect(() => { + if (isLoading) return; + const viewport = viewportRef.current; + if (viewport) viewport.scrollTop = viewport.scrollHeight; + }, [channelId, isLoading]); + + // Wait for the complete feed: the scroller's initial end-scroll fires once, + // so mounting around partial rows would land it short of the latest message. + if (isLoading && pending.length === 0) { return (
@@ -714,7 +725,7 @@ export function ChannelFeedView({ - + {/* Horizontal padding is load-bearing: ThreadItem's actions float at the row's top-right corner (absolute, past the row edge). Without a gutter they hug the scroll container and get clipped. */} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 16a29e7fbf..d6c1f6bd27 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -69,18 +69,23 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const { tasks, isLoading: isLoadingFeed } = useChannelFeed( backendChannel?.id, ); - // Until the backend channel resolves there's no feed to ask for, and the feed - // query is disabled — which reports isLoading:false, indistinguishable from - // "this channel is empty". useBackendChannel reports loading for the whole - // identity-resolution window (settling if the resolve fails), so fold it in: - // we can't call a channel empty until we know which channel it is. - const isLoading = isLoadingChannels || isResolvingChannel || isLoadingFeed; // Marking this channel read lives in ChannelHeader (rendered by every channel // surface), so opening Artifacts or CONTEXT.md counts as reading it too. // Durable "PostHog agent" rows (CONTEXT.md being built, …) live on the // backend channel — the same id the feed tasks use, not the folder id. - const { messages: feedMessages } = useChannelFeedMessages(backendChannel?.id); + const { messages: feedMessages, isLoading: isLoadingMessages } = + useChannelFeedMessages(backendChannel?.id); + // Until the backend channel resolves there's no feed to ask for, and the feed + // query is disabled — which reports isLoading:false, indistinguishable from + // "this channel is empty". useBackendChannel reports loading for the whole + // identity-resolution window (settling if the resolve fails), so fold it in: + // we can't call a channel empty until we know which channel it is. + const isLoading = + isLoadingChannels || + isResolvingChannel || + isLoadingFeed || + isLoadingMessages; // The Slack-style "joined" opener, derived from the channel row so it renders // (and sorts first) even where the feed endpoint isn't deployed. const systemMessages = useMemo(() => {