Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 137 additions & 23 deletions packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import {
TooltipTrigger,
useChatMessageScroller,
useChatMessageScrollerScrollable,
useChatMessageScrollerVisibility,
} from "@posthog/quill";
import type { AcpMessage, AgentConversationEvent } from "@posthog/shared";
import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared";
Expand Down Expand Up @@ -623,7 +622,13 @@ function ThreadItemBody({
* non-virtualized thread stays cheap. The pinned header is the separate overlay, not the rows.
*
* An {@link AgentTurn} renders as a single muted card wrapping its items with tight spacing; a user
* message stays a standalone anchored row.
* message stays a standalone row.
*
* No row is a `scrollAnchor`: the engine treats an anchored row as one it owns the scroll position
* for and pins every anchor it has not pinned before to the top of the viewport on the next content
* change — so a single row swap anywhere in a settled thread scrolls it back to an earlier prompt,
* one prompt per swap. {@link ThreadTurnAnchor} tracks the current turn without handing the engine
* that control.
*/
const ThreadRow = memo(function ThreadRow({
item,
Expand Down Expand Up @@ -675,7 +680,7 @@ const ThreadRow = memo(function ThreadRow({
return (
<ChatMessageScrollerItem
messageId={item.id}
scrollAnchor={item.type === "user_message"}
scrollAnchor={false}
className="mx-auto w-full py-1 empty:hidden"
style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
>
Expand All @@ -688,6 +693,17 @@ const ThreadRow = memo(function ThreadRow({
);
});

/** The scroll viewport owning `node`, found from any element rendered inside the scroller. */
function findScrollerViewport(node: Element | null): HTMLElement | null {
return (
node
?.closest('[data-slot="chat-message-scroller"]')
?.querySelector<HTMLElement>(
'[data-slot="chat-message-scroller-viewport"]',
) ?? null
);
}

/**
* Keeps the view pinned to the bottom from prompt submit until the user scrolls away.
*
Expand All @@ -701,9 +717,17 @@ const ThreadRow = memo(function ThreadRow({
* commit that leaves content below the fold re-issues `scrollToEnd` to recapture follow.
*
* User scroll intent (wheel, touch, pointer, keys — same signals the engine listens to) disarms
* the pin; the next submit or the scroll-to-bottom button re-engages following.
* the pin; the next submit or the scroll-to-bottom button re-engages following. Arming is owned by
* the caller so the button can re-engage it — reaching the bottom once is worthless while a reply is
* still streaming rows in below.
*/
function ThreadAutoFollow({ items }: { items: ConversationItem[] }) {
function ThreadAutoFollow({
items,
armedRef,
}: {
items: ConversationItem[];
armedRef: RefObject<boolean>;
}) {
const { scrollToEnd } = useChatMessageScroller();
const { end } = useChatMessageScrollerScrollable();
const lastItem = items.at(-1);
Expand All @@ -713,7 +737,6 @@ function ThreadAutoFollow({ items }: { items: ConversationItem[] }) {
[items],
);
const prevCountRef = useRef(userMessageCount);
const armedRef = useRef(false);
const probeRef = useRef<HTMLSpanElement>(null);

useLayoutEffect(() => {
Expand All @@ -723,12 +746,10 @@ function ThreadAutoFollow({ items }: { items: ConversationItem[] }) {
if (lastItem?.type !== "user_message") return;
armedRef.current = true;
scrollToEnd({ behavior: "auto" });
}, [userMessageCount, lastItem, scrollToEnd]);
}, [userMessageCount, lastItem, scrollToEnd, armedRef]);

useEffect(() => {
const viewport = probeRef.current
?.closest('[data-slot="chat-message-scroller"]')
?.querySelector('[data-slot="chat-message-scroller-viewport"]');
const viewport = findScrollerViewport(probeRef.current);
if (!viewport) return;
const disarm = () => {
armedRef.current = false;
Expand All @@ -742,7 +763,7 @@ function ThreadAutoFollow({ items }: { items: ConversationItem[] }) {
viewport.removeEventListener(event, disarm);
}
};
}, []);
}, [armedRef]);

// biome-ignore lint/correctness/useExhaustiveDependencies: re-check on every streamed change — `end` alone doesn't re-notify while it stays true across commits.
useEffect(() => {
Expand Down Expand Up @@ -871,28 +892,114 @@ function ThreadKeyboardNav({
}

/**
* Keeps {@link ThreadScrollResume} current while the non-virtualized body is mounted, so the
* windowed body can pick up where this one left off if the thread crosses the threshold
* mid-session. Both values come from engine state the scroller already tracks — at-bottom from
* `scrollable.end` (true while content extends below the fold), and the anchored user message from
* the same visibility state the sticky header reads — so nothing here listens to scroll. Writes go
* to a ref: the recorder must never make the thread re-render.
* Records at-bottom into {@link ThreadScrollResume} while the non-virtualized body is mounted, so the
* windowed body can pick up where this one left off if the thread crosses the threshold mid-session.
* It reads engine state the scroller already tracks (`scrollable.end` is true while content extends
* below the fold), so nothing here listens to scroll; {@link ThreadTurnAnchor} fills in the anchor
* half. Writes go to a ref: the recorder must never make the thread re-render.
*/
function ThreadScrollStateRecorder({
stateRef,
}: {
stateRef: RefObject<ThreadScrollResume>;
}) {
const { end } = useChatMessageScrollerScrollable();
const { currentAnchorId } = useChatMessageScrollerVisibility();

useEffect(() => {
stateRef.current = { atBottom: !end, anchorId: currentAnchorId ?? null };
}, [end, currentAnchorId, stateRef]);
stateRef.current = { ...stateRef.current, atBottom: !end };
}, [end, stateRef]);

return null;
}

/**
* Tracks the user turn the reader is parked on — the last prompt whose top has passed the peek line,
* the same rule {@link computeStickyAnchor} applies to the windowed body's measured rows — and
* renders the minimap with it.
*
* The engine reports this for rows marked `scrollAnchor`, but marking a row also hands the engine the
* scroll position for it (see {@link ThreadRow}), so the anchor is measured here instead. Only this
* component re-renders as the reader moves; the rows never do.
*/
function ThreadTurnAnchor({
items,
resumeStateRef,
}: {
items: ConversationItem[];
resumeStateRef: RefObject<ThreadScrollResume>;
}) {
const [anchorId, setAnchorId] = useState<string | null>(null);
const probeRef = useRef<HTMLSpanElement>(null);
const measureRef = useRef<(() => void) | null>(null);
const promptIds = useMemo(() => {
const ids = new Set<string>();
for (const item of items) {
if (item.type === "user_message") ids.add(item.id);
}
return ids;
}, [items]);
const promptIdsRef = useRef(promptIds);
useEffect(() => {
promptIdsRef.current = promptIds;
}, [promptIds]);

useEffect(() => {
const viewport = findScrollerViewport(probeRef.current);
const rows = viewport?.querySelector<HTMLElement>(
'[data-slot="chat-message-scroller-content"]',
);
if (!viewport || !rows) return;
let frame = 0;
const measure = () => {
frame = 0;
const line =
viewport.getBoundingClientRect().top + SCROLL_PREVIOUS_ITEM_PEEK;
let current: string | null = null;
// Rows are content's direct children (walking the subtree would visit every node inside every
// turn card) and sit in ascending vertical order, so scanning back from the newest one finds
// the anchor without measuring the thread above it — at the live tail that is the first read.
for (let i = rows.children.length - 1; i >= 0; i--) {
const row = rows.children[i];
if (!(row instanceof HTMLElement)) continue;
const id = row.dataset.messageId;
if (!id || !promptIdsRef.current.has(id)) continue;
const rect = row.getBoundingClientRect();
// A collapsed row (`empty:hidden`) measures as a zero rect at the origin, which would read
// as a row sitting above the line.
if (rect.height === 0) continue;
if (rect.top > line) continue;
current = id;
break;
}
setAnchorId(current);
resumeStateRef.current = { ...resumeStateRef.current, anchorId: current };
};
const schedule = () => {
if (frame === 0) frame = requestAnimationFrame(measure);
};
measureRef.current = schedule;
measure();
viewport.addEventListener("scroll", schedule, { passive: true });
return () => {
measureRef.current = null;
cancelAnimationFrame(frame);
viewport.removeEventListener("scroll", schedule);
};
}, [resumeStateRef]);

// biome-ignore lint/correctness/useExhaustiveDependencies: appended rows move every row below them, so the anchor is stale until it is re-measured.
useEffect(() => {
measureRef.current?.();
}, [items]);

return (
<>
<span ref={probeRef} className="hidden" aria-hidden="true" />
<MessageMinimap items={items} anchorId={anchorId} />
</>
);
}

/** The scroll body, under the Provider so the overlay + scroll-button hooks can read engine state. */
function ThreadScrollBody({
items,
Expand Down Expand Up @@ -922,15 +1029,20 @@ function ThreadScrollBody({
}));
}, [rows]);

const followArmedRef = useRef(false);
const armFollow = useCallback(() => {
followArmedRef.current = true;
}, []);

// `group/thread` so the footer's hover-reveal (opacity-50 → 100 on group-hover) tracks the thread,
// mirroring the legacy ConversationView container.
return (
<ChatMessageScroller
className="group/thread"
onPointerDownCapture={onUserInteract}
>
<MessageMinimap items={items} />
<ThreadAutoFollow items={items} />
<ThreadTurnAnchor items={items} resumeStateRef={resumeStateRef} />
<ThreadAutoFollow items={items} armedRef={followArmedRef} />
<ThreadScrollStateRecorder stateRef={resumeStateRef} />
<ChatMessageScrollerViewport>
<ChatMessageScrollerContent
Expand All @@ -956,7 +1068,9 @@ function ThreadScrollBody({
)}
</ChatMessageScrollerContent>
</ChatMessageScrollerViewport>
<ChatMessageScrollerButton />
{/* Re-arms auto-follow as well as scrolling: without it the button lands you at the bottom of
a reply that is still streaming, and the next row pushes the bottom back out of reach. */}
<ChatMessageScrollerButton onClick={armFollow} />
</ChatMessageScroller>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
useChatMessageScroller,
useChatMessageScrollerVisibility,
} from "@posthog/quill";
import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems";
import {
Expand Down Expand Up @@ -91,8 +90,8 @@ function MinimapMenuItem({
* into view and leaves the list open to jump again. Replaces the floating "jump to your message"
* pill, which only ever offered the single anchored turn.
*
* Only this component subscribes to the scroller's per-scroll visibility state, so the message rows
* never re-render as the highlight moves.
* Each body tracks the current turn itself and passes it in, so this component re-renders as the
* highlight moves while the message rows never do.
*/
export function MessageMinimap({
items,
Expand All @@ -105,16 +104,11 @@ export function MessageMinimap({
* `scrollToMessage` only reaches rows that exist in the DOM. Omitted for the plain body.
*/
onJump?: (id: string) => void;
/**
* Current turn for the windowed body, which tracks its own anchor — the engine's visibility state
* only sees mounted rows. Omitted for the plain body.
*/
anchorId?: string | null;
/** The turn the reader is parked on, highlighted in the rail and the list. */
anchorId: string | null;
}) {
const visibility = useChatMessageScrollerVisibility();
const { scrollToMessage } = useChatMessageScroller();
const jump = onJump ?? scrollToMessage;
const currentAnchorId = anchorId ?? visibility.currentAnchorId;
const [open, setOpen] = useState(false);
// Base UI returns focus to the trigger when the menu closes. Without this guard the resulting
// focus event would immediately re-open the menu the user just dismissed or selected from.
Expand Down Expand Up @@ -183,9 +177,7 @@ export function MessageMinimap({
style={{ width: `${entry.widthPct}%` }}
className={cn(
"h-[2px] shrink-0 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none",
entry.id === currentAnchorId
? "bg-(--accent-9)"
: "bg-(--gray-8)",
entry.id === anchorId ? "bg-(--accent-9)" : "bg-(--gray-8)",
)}
/>
))}
Expand All @@ -207,8 +199,8 @@ export function MessageMinimap({
<MinimapMenuItem
key={entry.id}
entry={entry}
isCurrent={entry.id === currentAnchorId}
itemRef={entry.id === currentAnchorId ? activeItemRef : undefined}
isCurrent={entry.id === anchorId}
itemRef={entry.id === anchorId ? activeItemRef : undefined}
onSelect={jump}
/>
))}
Expand Down
Loading