Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
15 changes: 13 additions & 2 deletions packages/ui/src/features/canvas/components/ChannelFeedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
memo,
type ReactNode,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
Expand Down Expand Up @@ -722,7 +723,17 @@ export function ChannelFeedView({
return merged;
}, [tasks, systemMessages]);

if (isLoading && entries.length === 0 && pending.length === 0) {
const viewportRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-1 items-center justify-center">
<Spinner />
Expand All @@ -741,7 +752,7 @@ export function ChannelFeedView({
<ChatMessageScrollerProvider defaultScrollPosition="end">
<FollowOwnPost latestPendingId={latestPendingId} />
<ChatMessageScroller className="min-h-0 flex-1">
<ChatMessageScrollerViewport>
<ChatMessageScrollerViewport ref={viewportRef}>
{/* 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. */}
Expand Down
19 changes: 12 additions & 7 deletions packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Loading