@@ -12,6 +12,8 @@ import {
1212 Badge ,
1313 Card ,
1414 CardContent ,
15+ ChatMarker ,
16+ ChatMarkerContent ,
1517 ChatMessageScroller ,
1618 ChatMessageScrollerButton ,
1719 ChatMessageScrollerContent ,
@@ -48,8 +50,9 @@ import {
4850 type SidebarPrState ,
4951 useTaskPrStatus ,
5052} from "@posthog/ui/features/sidebar/useTaskPrStatus" ;
53+ import { useInView } from "@posthog/ui/primitives/hooks/useInView" ;
5154import { Text } from "@radix-ui/themes" ;
52- import { type ReactNode , useMemo } from "react" ;
55+ import { Fragment , memo , type ReactNode , useMemo } from "react" ;
5356
5457// Feed rows poll their reply counts slower than the open thread panel — the
5558// shared query key means an open panel naturally speeds the row up too.
@@ -97,6 +100,39 @@ function statusBadge(status: TaskRunStatus) {
97100 ) ;
98101}
99102
103+ // Local calendar-day identity, so tasks created on the same day share a heading
104+ // regardless of time. Uses local getters (not the UTC ISO) so the split lands
105+ // on the viewer's midnight.
106+ function dayKey ( iso : string ) : string {
107+ const d = new Date ( iso ) ;
108+ return `${ d . getFullYear ( ) } -${ d . getMonth ( ) } -${ d . getDate ( ) } ` ;
109+ }
110+
111+ function ordinal ( n : number ) : string {
112+ const suffix = [ "th" , "st" , "nd" , "rd" ] ;
113+ const rem = n % 100 ;
114+ return `${ n } ${ suffix [ ( rem - 20 ) % 10 ] ?? suffix [ rem ] ?? suffix [ 0 ] } ` ;
115+ }
116+
117+ // The day-separator label: "Today" / "Yesterday" for the recent days, then a
118+ // weekday + ordinal ("Monday 5th") within the week, adding the month (and the
119+ // year when it differs) further back so older separators stay unambiguous.
120+ function dayLabel ( iso : string , now : Date ) : string {
121+ const date = new Date ( iso ) ;
122+ const startOfDay = ( d : Date ) =>
123+ new Date ( d . getFullYear ( ) , d . getMonth ( ) , d . getDate ( ) ) . getTime ( ) ;
124+ const days = Math . round ( ( startOfDay ( now ) - startOfDay ( date ) ) / 86_400_000 ) ;
125+ if ( days <= 0 ) return "Today" ;
126+ if ( days === 1 ) return "Yesterday" ;
127+ const weekday = date . toLocaleDateString ( undefined , { weekday : "long" } ) ;
128+ const day = ordinal ( date . getDate ( ) ) ;
129+ if ( days < 7 ) return `${ weekday } ${ day } ` ;
130+ const month = date . toLocaleDateString ( undefined , { month : "long" } ) ;
131+ const year =
132+ date . getFullYear ( ) === now . getFullYear ( ) ? "" : `, ${ date . getFullYear ( ) } ` ;
133+ return `${ weekday } , ${ month } ${ day } ${ year } ` ;
134+ }
135+
100136interface TaskStatusDisplay {
101137 // The run/environment badge ("Local", "Completed", "In progress", …).
102138 base : ReactNode ;
@@ -331,7 +367,7 @@ function RepliesRow({
331367
332368 return (
333369 < ThreadItemReplies onClick = { onOpenThread } className = "mt-1" >
334- < AvatarGroup size = "xs" stacked >
370+ < AvatarGroup size = "xs" >
335371 { authors . map ( ( author , index ) => (
336372 < Avatar key = { author ?. uuid ?? index } size = "xs" >
337373 < AvatarFallback > { getUserInitials ( author ) } </ AvatarFallback >
@@ -348,20 +384,22 @@ function RepliesRow({
348384 ) ;
349385}
350386
351- function FeedItem ( {
387+ const FeedItem = memo ( function FeedItem ( {
352388 task,
389+ inView,
353390 onOpenTask,
354391 onOpenThread,
355392} : {
356393 task : Task ;
394+ inView : boolean ;
357395 onOpenTask : ( task : Task ) => void ;
358396 onOpenThread : ( task : Task ) => void ;
359397} ) {
360398 const prompt = useMemo ( ( ) => promptText ( task ) , [ task ] ) ;
361399 const isAgent = ! task . created_by || task . origin_product !== "user_created" ;
362400
363401 return (
364- < ThreadItem className = "py-4 pr-8 hover:bg-fill-hover/10 " >
402+ < ThreadItem className = "rounded-none py-4 pr-8 hover:bg-fill-hover/50 " >
365403 < ThreadItemGutter >
366404 < Avatar >
367405 < AvatarFallback >
@@ -392,7 +430,15 @@ function FeedItem({
392430 </ ThreadItemBody >
393431
394432 < TaskCard task = { task } onOpen = { ( ) => onOpenTask ( task ) } />
395- < RepliesRow taskId = { task . id } onOpenThread = { ( ) => onOpenThread ( task ) } />
433+ { /* Off-screen rows drop the reply teaser so a long feed isn't running a
434+ 15s poll timer per row; the wide inView margin mounts it well before
435+ the row scrolls into view, so nothing pops in. */ }
436+ { inView && (
437+ < RepliesRow
438+ taskId = { task . id }
439+ onOpenThread = { ( ) => onOpenThread ( task ) }
440+ />
441+ ) }
396442 </ ThreadItemContent >
397443
398444 { /* Actions anchor to the row's top-right corner; a top tooltip there
@@ -416,6 +462,40 @@ function FeedItem({
416462 </ ThreadItemActions >
417463 </ ThreadItem >
418464 ) ;
465+ } ) ;
466+
467+ // One feed row: owns the scroller item (the `content-visibility` boundary, so
468+ // its box is always laid out and safe to observe) and reports whether it is
469+ // near the viewport, letting `FeedItem` shed off-screen polling.
470+ function FeedRow ( {
471+ task,
472+ onOpenTask,
473+ onOpenThread,
474+ } : {
475+ task : Task ;
476+ onOpenTask : ( task : Task ) => void ;
477+ onOpenThread : ( task : Task ) => void ;
478+ } ) {
479+ const [ ref , inView ] = useInView < HTMLDivElement > ( { rootMargin : "1200px 0px" } ) ;
480+ return (
481+ < ChatMessageScrollerItem
482+ ref = { ref }
483+ messageId = { task . id }
484+ // Rows already get `content-visibility:auto` from quill, but its default
485+ // `contain-intrinsic-size` (10rem) under-reserves a feed row (message +
486+ // task card + replies ≈ 13rem), so off-screen rows collapse too small and
487+ // the scrollbar jumps as they paint in. A closer estimate keeps scrolling
488+ // stable; `auto` still remembers each row's real height after first paint.
489+ className = "[contain-intrinsic-size:auto_13rem]"
490+ >
491+ < FeedItem
492+ task = { task }
493+ inView = { inView }
494+ onOpenTask = { onOpenTask }
495+ onOpenThread = { onOpenThread }
496+ />
497+ </ ChatMessageScrollerItem >
498+ ) ;
419499}
420500
421501// The Slack-style channel feed: every task kicked off in the channel, oldest
@@ -446,6 +526,8 @@ export function ChannelFeedView({
446526 return < div className = "flex-1 overflow-y-auto" > { emptyState } </ div > ;
447527 }
448528
529+ const now = new Date ( ) ;
530+
449531 return (
450532 < ChatMessageScrollerProvider defaultScrollPosition = "end" >
451533 < ChatMessageScroller className = "min-h-0 flex-1" >
@@ -454,25 +536,28 @@ export function ChannelFeedView({
454536 the row's top-right corner (absolute, past the row edge). Without a
455537 gutter they hug the scroll container and get clipped. */ }
456538 < ChatMessageScrollerContent className = "mx-auto w-full gap-0 py-4" >
457- { tasks . map ( ( task ) => (
458- < ChatMessageScrollerItem
459- key = { task . id }
460- messageId = { task . id }
461- // Rows already get `content-visibility:auto` from quill, but its
462- // default `contain-intrinsic-size` (10rem) under-reserves a feed
463- // row (message + task card + replies ≈ 13rem), so off-screen
464- // rows collapse too small and the scrollbar jumps as they paint
465- // in. A closer estimate keeps scrolling stable; `auto` still
466- // remembers each row's real height after its first render.
467- className = "[contain-intrinsic-size:auto_13rem] hover:bg-fill-hover/50"
468- >
469- < FeedItem
470- task = { task }
471- onOpenTask = { onOpenTask }
472- onOpenThread = { onOpenThread }
473- />
474- </ ChatMessageScrollerItem >
475- ) ) }
539+ { tasks . map ( ( task , index ) => {
540+ const previous = tasks [ index - 1 ] ;
541+ const showDayMarker =
542+ ! previous ||
543+ dayKey ( previous . created_at ) !== dayKey ( task . created_at ) ;
544+ return (
545+ < Fragment key = { task . id } >
546+ { showDayMarker && (
547+ < ChatMarker variant = "separator" >
548+ < ChatMarkerContent >
549+ { dayLabel ( task . created_at , now ) }
550+ </ ChatMarkerContent >
551+ </ ChatMarker >
552+ ) }
553+ < FeedRow
554+ task = { task }
555+ onOpenTask = { onOpenTask }
556+ onOpenThread = { onOpenThread }
557+ />
558+ </ Fragment >
559+ ) ;
560+ } ) }
476561 </ ChatMessageScrollerContent >
477562 </ ChatMessageScrollerViewport >
478563 < ChatMessageScrollerButton />
0 commit comments