From a0a78f799c36a4ad8c40d133739686d0df14bac9 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 09:37:14 -0400 Subject: [PATCH 1/6] Hold channel loading state until the feed is complete so it opens at the latest message The channel scroller's one-shot initial end-scroll fired against partial content (the synthesized "joined" opener renders as soon as the backend channel resolves, before the task cards arrive), and the engine never re-scrolls for late rows unless autoScroll forces it. Fold the feed-messages loading state into the gate and hold the spinner while any feed data is still loading, so the scroller mounts once with complete data and lands at the most recent message. Generated-By: PostHog Code Task-Id: d887d524-7d87-4d7a-b38c-b20289937ad1 --- .../canvas/components/ChannelFeedView.tsx | 7 +++++- .../canvas/components/WebsiteChannelHome.tsx | 22 +++++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index af127b60ab..8cb80f76d0 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -695,7 +695,12 @@ export function ChannelFeedView({ return merged; }, [tasks, systemMessages]); - if (isLoading && entries.length === 0 && pending.length === 0) { + // Hold the loading state even when some entries already exist (the "joined" + // opener renders as soon as the channel resolves, before the task cards): + // mounting the scroller around partial content spends its one-shot initial + // end-scroll on a near-empty list, and it never re-scrolls when the real + // cards arrive. Pending kickoffs bail out so an optimistic post shows now. + if (isLoading && pending.length === 0) { return (
diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 16a29e7fbf..3ad5fc3e80 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -69,18 +69,26 @@ 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. Messages + // loading is included so the scroller mounts once with the complete feed and + // its one-shot initial end-scroll lands at the latest message (it never + // re-fires for late-arriving rows). + 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(() => { From f6f2bb882e541a83e96e93d56d419c20c151afcc Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 10:05:53 -0400 Subject: [PATCH 2/6] Key the feed scroller by channel so revisits land at the latest message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel-to-channel navigation swaps the feed content without a route remount, so the scroller provider persisted and its one-shot defaultScrollPosition never re-applied — a revisited channel kept the previous channel's scroll offset (often the top). Keying the provider by channelId fresh-mounts it per channel, re-firing the initial end-scroll. Generated-By: PostHog Code Task-Id: d887d524-7d87-4d7a-b38c-b20289937ad1 --- .../ui/src/features/canvas/components/ChannelFeedView.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 8cb80f76d0..be49bbc56f 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -716,7 +716,11 @@ export function ChannelFeedView({ const latestPendingId = pending[pending.length - 1]?.id; return ( - + // Keyed by channel: switching channels swaps the content without a route + // remount, but the scroller only applies defaultScrollPosition once per + // mount — an un-keyed provider would keep the previous channel's scroll + // offset instead of landing at the latest message. + From d4253b36f345f5825e656d193f6c8ee64cb58dea Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 10:15:01 -0400 Subject: [PATCH 3/6] Scroll to latest on channel switch instead of remounting the scroller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keying the provider by channelId re-fired the initial end-scroll but rebuilt the entire feed subtree (rows, observers, reply polling) on every switch, making channel changes feel sluggish. Replace the key with a headless LandAtLatestOnChannelChange component that imperatively calls scrollToEnd in a layout effect when the channel id changes — same commit as the swapped rows, before paint, no remount. Generated-By: PostHog Code Task-Id: d887d524-7d87-4d7a-b38c-b20289937ad1 --- .../canvas/components/ChannelFeedView.tsx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index be49bbc56f..968154d89a 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"; @@ -628,6 +629,24 @@ function FollowOwnPost({ latestPendingId }: { latestPendingId?: string }) { return null; } +// Land at the latest message when the feed swaps to another channel: the route +// doesn't remount on a param change, so the provider's one-shot +// defaultScrollPosition only covers the channel it mounted with. Imperative +// rather than keying the provider — a remount would rebuild the entire feed +// subtree on every switch. Layout effect so the jump happens in the same +// commit as the swapped rows, before paint. Renders nothing. +function LandAtLatestOnChannelChange({ channelId }: { channelId: string }) { + const { scrollToEnd } = useChatMessageScroller(); + const prevRef = useRef(channelId); + useLayoutEffect(() => { + if (channelId !== prevRef.current) { + prevRef.current = channelId; + scrollToEnd({ behavior: "auto" }); + } + }, [channelId, scrollToEnd]); + return null; +} + // A single feed entry, either a real task card or a synthetic system row, tagged // with the timestamp used to interleave the two. type FeedEntry = @@ -716,11 +735,8 @@ export function ChannelFeedView({ const latestPendingId = pending[pending.length - 1]?.id; return ( - // Keyed by channel: switching channels swaps the content without a route - // remount, but the scroller only applies defaultScrollPosition once per - // mount — an un-keyed provider would keep the previous channel's scroll - // offset instead of landing at the latest message. - + + From 8db09ef869baead53f3601621005752094f671d6 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 10:26:10 -0400 Subject: [PATCH 4/6] Simplify channel-switch scroll to an inline viewport ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the LandAtLatestOnChannelChange headless component. The same behavior is a ref on the scroller viewport plus one layout effect keyed on channelId that sets scrollTop = scrollHeight — the engine composes external viewport refs, and first mounts are still covered by defaultScrollPosition. Generated-By: PostHog Code Task-Id: d887d524-7d87-4d7a-b38c-b20289937ad1 --- .../canvas/components/ChannelFeedView.tsx | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 968154d89a..487e9dad13 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -629,24 +629,6 @@ function FollowOwnPost({ latestPendingId }: { latestPendingId?: string }) { return null; } -// Land at the latest message when the feed swaps to another channel: the route -// doesn't remount on a param change, so the provider's one-shot -// defaultScrollPosition only covers the channel it mounted with. Imperative -// rather than keying the provider — a remount would rebuild the entire feed -// subtree on every switch. Layout effect so the jump happens in the same -// commit as the swapped rows, before paint. Renders nothing. -function LandAtLatestOnChannelChange({ channelId }: { channelId: string }) { - const { scrollToEnd } = useChatMessageScroller(); - const prevRef = useRef(channelId); - useLayoutEffect(() => { - if (channelId !== prevRef.current) { - prevRef.current = channelId; - scrollToEnd({ behavior: "auto" }); - } - }, [channelId, scrollToEnd]); - return null; -} - // A single feed entry, either a real task card or a synthetic system row, tagged // with the timestamp used to interleave the two. type FeedEntry = @@ -714,6 +696,16 @@ export function ChannelFeedView({ return merged; }, [tasks, systemMessages]); + // Open every channel at its latest message. The scroller handles the first + // mount itself (defaultScrollPosition), but switching channels only swaps + // the rows — no remount — so jump to the bottom before paint whenever the + // channel changes. No-ops while the loading state has the viewport unmounted. + const viewportRef = useRef(null); + useLayoutEffect(() => { + const viewport = viewportRef.current; + if (viewport) viewport.scrollTop = viewport.scrollHeight; + }, [channelId]); + // Hold the loading state even when some entries already exist (the "joined" // opener renders as soon as the channel resolves, before the task cards): // mounting the scroller around partial content spends its one-shot initial @@ -736,10 +728,9 @@ export function ChannelFeedView({ return ( - - + {/* 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. */} From d3ad50478ce597f08947e9f97546805d82707cdb Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 10:32:26 -0400 Subject: [PATCH 5/6] Fix Biome exhaustive-deps error and trim comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's biome ci errored on the scroll effect's [channelId] dependency (not referenced in the body — it's the re-run trigger). Suppress with the repo's biome-ignore idiom and cut the added comments down to the essentials. Generated-By: PostHog Code Task-Id: d887d524-7d87-4d7a-b38c-b20289937ad1 --- .../features/canvas/components/ChannelFeedView.tsx | 12 +++--------- .../canvas/components/WebsiteChannelHome.tsx | 5 +---- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 487e9dad13..269cac7fbe 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -696,21 +696,15 @@ export function ChannelFeedView({ return merged; }, [tasks, systemMessages]); - // Open every channel at its latest message. The scroller handles the first - // mount itself (defaultScrollPosition), but switching channels only swaps - // the rows — no remount — so jump to the bottom before paint whenever the - // channel changes. No-ops while the loading state has the viewport unmounted. const viewportRef = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the trigger — switching channels swaps the rows without a remount, so re-land at the latest message useLayoutEffect(() => { const viewport = viewportRef.current; if (viewport) viewport.scrollTop = viewport.scrollHeight; }, [channelId]); - // Hold the loading state even when some entries already exist (the "joined" - // opener renders as soon as the channel resolves, before the task cards): - // mounting the scroller around partial content spends its one-shot initial - // end-scroll on a near-empty list, and it never re-scrolls when the real - // cards arrive. Pending kickoffs bail out so an optimistic post shows now. + // 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 (
diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 3ad5fc3e80..d6c1f6bd27 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -80,10 +80,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { // 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. Messages - // loading is included so the scroller mounts once with the complete feed and - // its one-shot initial end-scroll lands at the latest message (it never - // re-fires for late-arriving rows). + // we can't call a channel empty until we know which channel it is. const isLoading = isLoadingChannels || isResolvingChannel || From d3189944caa70236647bd27fc9f293c32c4ca024 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 10:50:31 -0400 Subject: [PATCH 6/6] Re-land at latest when the initial load completes around a pending kickoff Greptile P1: submitting a kickoff mid-load mounts the scroller around partial content via the pending escape hatch, consuming the one-shot end scroll; the remaining rows then arrive with no re-scroll. Extend the scroll effect to also fire when isLoading settles, so the feed lands at the latest message in that path too. Generated-By: PostHog Code Task-Id: d887d524-7d87-4d7a-b38c-b20289937ad1 --- .../ui/src/features/canvas/components/ChannelFeedView.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 269cac7fbe..cda7f06632 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -697,11 +697,12 @@ export function ChannelFeedView({ }, [tasks, systemMessages]); const viewportRef = useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the trigger — switching channels swaps the rows without a remount, so re-land at the latest message + // 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]); + }, [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.