@@ -34,12 +34,13 @@ import {
3434 ThreadItemRepliesMeta ,
3535 ThreadItemTimestamp ,
3636} from "@posthog/quill" ;
37- import { formatRelativeTimeShort } from "@posthog/shared" ;
37+ import { formatRelativeTimeShort , getLocalDayDiff } from "@posthog/shared" ;
3838import type { Task , TaskRunStatus } from "@posthog/shared/domain-types" ;
3939import { isTerminalStatus } from "@posthog/shared/domain-types" ;
4040import { getUserInitials } from "@posthog/ui/features/auth/userInitials" ;
4141import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon" ;
4242import { mentionChipClass } from "@posthog/ui/features/canvas/components/MentionText" ;
43+ import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages" ;
4344import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData" ;
4445import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread" ;
4546import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay" ;
@@ -116,9 +117,7 @@ function ordinal(n: number): string {
116117// year when it differs) further back so older separators stay unambiguous.
117118function dayLabel ( iso : string , now : Date ) : string {
118119 const date = new Date ( iso ) ;
119- const startOfDay = ( d : Date ) =>
120- new Date ( d . getFullYear ( ) , d . getMonth ( ) , d . getDate ( ) ) . getTime ( ) ;
121- const days = Math . round ( ( startOfDay ( now ) - startOfDay ( date ) ) / 86_400_000 ) ;
120+ const days = getLocalDayDiff ( date , now ) ;
122121 if ( days <= 0 ) return "Today" ;
123122 if ( days === 1 ) return "Yesterday" ;
124123 const weekday = date . toLocaleDateString ( undefined , { weekday : "long" } ) ;
@@ -265,9 +264,9 @@ function TaskCard({ task, onOpen }: { task: Task; onOpen: () => void }) {
265264 nav never disagree (generating spinner, needs-permission, cloud
266265 status colors, PR state). */ }
267266 < TaskTabIcon task = { task } size = { 14 } />
268- < Text size = "2" weight = "medium" className = "line-clamp-2" >
267+ < span className = "line-clamp-2 font-medium text-sm " >
269268 { task . title || "Untitled task" }
270- </ Text >
269+ </ span >
271270 </ div >
272271 < TaskStatusBadge display = { statusDisplay } />
273272 </ div >
@@ -379,7 +378,7 @@ const FeedItem = memo(function FeedItem({
379378 onOpenThread : ( task : Task ) => void ;
380379} ) {
381380 return (
382- < ThreadItem className = "rounded-none py-4 pr-8 hover:bg-fill-hover/50" >
381+ < ThreadItem className = "rounded-none py-1 pr-8 hover:bg-fill-hover/50" >
383382 < ThreadItemGutter >
384383 < Avatar >
385384 < AvatarFallback >
@@ -472,31 +471,116 @@ function FeedRow({
472471 ) ;
473472}
474473
474+ // A card-less feed row for a synthetic announcement. Rows with an `author`
475+ // render as that user (initials avatar + name — e.g. "Adam L · joined mobile");
476+ // the rest render as "PostHog / Agent" (context lifecycle updates). Same chrome
477+ // as a task row, minus the task card and reply footer.
478+ function SystemFeedRow ( { message } : { message : ChannelFeedSystemMessage } ) {
479+ return (
480+ < ChatMessageScrollerItem messageId = { message . id } >
481+ < ThreadItem className = "rounded-none py-1 pr-8" >
482+ < ThreadItemGutter >
483+ < Avatar >
484+ < AvatarFallback >
485+ { message . author ? (
486+ getUserInitials ( message . author )
487+ ) : (
488+ < RobotIcon size = { 16 } />
489+ ) }
490+ </ AvatarFallback >
491+ </ Avatar >
492+ </ ThreadItemGutter >
493+ < ThreadItemContent className = "min-w-0" >
494+ < ThreadItemHeader >
495+ < ThreadItemAuthor >
496+ { message . author ? userDisplayName ( message . author ) : "PostHog" }
497+ </ ThreadItemAuthor >
498+ { ! message . author && < Badge variant = "info" > Agent</ Badge > }
499+ < ThreadItemTimestamp dateTime = { message . createdAt } >
500+ { formatRelativeTimeShort ( message . createdAt ) }
501+ </ ThreadItemTimestamp >
502+ </ ThreadItemHeader >
503+ < ThreadItemBody className = "wrap-break-word text-muted-foreground" >
504+ { message . text }
505+ </ ThreadItemBody >
506+ </ ThreadItemContent >
507+ </ ThreadItem >
508+ </ ChatMessageScrollerItem >
509+ ) ;
510+ }
511+
512+ // A single feed entry, either a real task card or a synthetic system row, tagged
513+ // with the timestamp used to interleave the two.
514+ type FeedEntry =
515+ | { kind : "task" ; id : string ; createdAt : string ; task : Task }
516+ | {
517+ kind : "system" ;
518+ id : string ;
519+ createdAt : string ;
520+ message : ChannelFeedSystemMessage ;
521+ } ;
522+
475523// The Slack-style channel feed: every task kicked off in the channel, oldest
476524// first, rendered as a kickoff message + task card. Multiplayer — the list is
477- // team-visible and polls for teammates' cards and status flips.
525+ // team-visible and polls for teammates' cards and status flips. Synthetic
526+ // "PostHog agent" system rows (context lifecycle) are interleaved by timestamp.
478527export function ChannelFeedView ( {
479528 tasks,
529+ systemMessages,
480530 isLoading,
481531 emptyState,
532+ intro,
482533 onOpenTask,
483534 onOpenThread,
484535} : {
485536 tasks : Task [ ] ;
537+ systemMessages ?: ChannelFeedSystemMessage [ ] ;
486538 isLoading : boolean ;
487539 emptyState ?: React . ReactNode ;
540+ /** Rendered pinned above the first entry — the Slack-style channel intro
541+ * (name, creation line, onboarding card). When set, the feed renders even
542+ * with no entries instead of falling back to `emptyState`. */
543+ intro ?: ReactNode ;
488544 onOpenTask : ( task : Task ) => void ;
489545 onOpenThread : ( task : Task ) => void ;
490546} ) {
491- if ( isLoading && tasks . length === 0 ) {
547+ // Merge tasks + system rows into one chronological list. ISO timestamps sort
548+ // lexically, so a plain string compare is chronological. Announcements are
549+ // posted 1ms before the task they describe; if the backend truncates that
550+ // sub-second offset the timestamps tie, so break ties system-row-first to
551+ // keep the announcement above its card.
552+ const entries = useMemo < FeedEntry [ ] > ( ( ) => {
553+ const merged : FeedEntry [ ] = [
554+ ...tasks . map ( ( task ) => ( {
555+ kind : "task" as const ,
556+ id : task . id ,
557+ createdAt : task . created_at ,
558+ task,
559+ } ) ) ,
560+ ...( systemMessages ?? [ ] ) . map ( ( message ) => ( {
561+ kind : "system" as const ,
562+ id : message . id ,
563+ createdAt : message . createdAt ,
564+ message,
565+ } ) ) ,
566+ ] ;
567+ merged . sort (
568+ ( a , b ) =>
569+ a . createdAt . localeCompare ( b . createdAt ) ||
570+ ( a . kind === b . kind ? 0 : a . kind === "system" ? - 1 : 1 ) ,
571+ ) ;
572+ return merged ;
573+ } , [ tasks , systemMessages ] ) ;
574+
575+ if ( isLoading && entries . length === 0 ) {
492576 return (
493577 < div className = "flex flex-1 items-center justify-center" >
494578 < Spinner />
495579 </ div >
496580 ) ;
497581 }
498582
499- if ( tasks . length === 0 ) {
583+ if ( entries . length === 0 && ! intro ) {
500584 return < div className = "flex-1 overflow-y-auto" > { emptyState } </ div > ;
501585 }
502586
@@ -510,25 +594,30 @@ export function ChannelFeedView({
510594 the row's top-right corner (absolute, past the row edge). Without a
511595 gutter they hug the scroll container and get clipped. */ }
512596 < ChatMessageScrollerContent className = "mx-auto w-full gap-0 py-4" >
513- { tasks . map ( ( task , index ) => {
514- const previous = tasks [ index - 1 ] ;
597+ { intro }
598+ { entries . map ( ( entry , index ) => {
599+ const previous = entries [ index - 1 ] ;
515600 const showDayMarker =
516601 ! previous ||
517- dayKey ( previous . created_at ) !== dayKey ( task . created_at ) ;
602+ dayKey ( previous . createdAt ) !== dayKey ( entry . createdAt ) ;
518603 return (
519- < Fragment key = { task . id } >
604+ < Fragment key = { entry . id } >
520605 { showDayMarker && (
521606 < ChatMarker variant = "separator" >
522607 < ChatMarkerContent >
523- { dayLabel ( task . created_at , now ) }
608+ { dayLabel ( entry . createdAt , now ) }
524609 </ ChatMarkerContent >
525610 </ ChatMarker >
526611 ) }
527- < FeedRow
528- task = { task }
529- onOpenTask = { onOpenTask }
530- onOpenThread = { onOpenThread }
531- />
612+ { entry . kind === "task" ? (
613+ < FeedRow
614+ task = { entry . task }
615+ onOpenTask = { onOpenTask }
616+ onOpenThread = { onOpenThread }
617+ />
618+ ) : (
619+ < SystemFeedRow message = { entry . message } />
620+ ) }
532621 </ Fragment >
533622 ) ;
534623 } ) }
0 commit comments