Skip to content

Commit 04b8b76

Browse files
adamleithpclaude
andcommitted
feat(channel): quill ThreadItem feed with day separators, smoother scroll
- Rebuild feed rows on quill's ThreadItem primitives (gutter avatar, header, body, hover action bar) instead of hand-rolled ChatMessage markup. - Group rows by day with a ChatMarker separator: "Today" / "Yesterday", then a weekday + ordinal ("Monday 5th"), adding month/year further back. - Cut scroll jank: memoize rows so task-list polls don't re-render the whole feed, gate each row's 15s reply-teaser poll behind an in-view observer, and tune contain-intrinsic-size to the real ~13rem row height. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fc12024 commit 04b8b76

1 file changed

Lines changed: 109 additions & 24 deletions

File tree

packages/ui/src/features/canvas/components/ChannelFeedView.tsx

Lines changed: 109 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
5154
import { 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+
100136
interface 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

Comments
 (0)