Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d5dfb4e
feat(ui): add user-message marker rail to conversation scrollbar
pauldambra Jul 21, 2026
79ed5bb
test(ui): add Storybook scenes for the scrollbar marker rail
pauldambra Jul 21, 2026
d2cccae
ci: remove the React Doctor workflow
pauldambra Jul 21, 2026
7bb18ef
fix(ui): sort imports and a11y-harden the scrollbar rail markers
pauldambra Jul 21, 2026
b4085a4
fix(ui): make markers visible in the ScrollableConversation story
pauldambra Jul 21, 2026
313f9ac
fix(ui): bound scrollbar rail story viewport
pauldambra Jul 21, 2026
d6a42b4
chore(visual): update storybook baselines
posthog[bot] Jul 21, 2026
a68a955
fix(ui): show human-only conversation rail markers
pauldambra Jul 21, 2026
c735eb2
fix(ui): measure rail markers from human messages only
pauldambra Jul 21, 2026
40fe711
style(ui): biome-format MessageScrollbarRail story
pauldambra Jul 21, 2026
24ea8de
chore(visual): update storybook baselines
posthog[bot] Jul 22, 2026
39e9a76
fix(ui): address conversation rail React Doctor findings
pauldambra Jul 22, 2026
96104ca
fix(ui): space estimated message rail markers
pauldambra Jul 22, 2026
c76c868
chore(ci): restore React Doctor workflow
pauldambra Jul 22, 2026
8d82637
fix(ui): measure rail rows in one DOM pass, coalesce per frame
pauldambra Jul 22, 2026
ce8554a
fix(ui): invalidate stale message rail measurements
pauldambra Jul 22, 2026
e54289c
fix(ui): collect conversation marker IDs in one pass
pauldambra Jul 22, 2026
2bdc3b8
chore(visual): update storybook baselines
posthog[bot] Jul 22, 2026
5d9ffb2
Merging 2bdc3b8f34a84a86ff7d8bb626564a2b9489ae69 into trunk-temp/pr-3…
trunk-io[bot] Jul 22, 2026
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
8 changes: 8 additions & 0 deletions apps/code/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,14 @@ snapshots:
hash: v1.k4693efd2.602ee87d6d7a745d4b09d9e49bd12a70e52299b78ca97a48b5ca73a01532d801.F_1XKDzCxzMm_opEt9FvzczBb_2n-vyvOyGFXHGJmTk
features-sessions-conversationview--with-pending-prompt--light:
hash: v1.k4693efd2.1636dfc3ee0dd9d28065166f2fff0193f2d499692c74749d3afa7585aae898b5.YxTPa4CsqI2YuGLUdck8340ITZQrpna_C6z-SgII4Ws
features-sessions-scrollbarrail--pure--dark:
hash: v1.k4693efd2.6baec21b1942848a3a376d2b90ca106b2c1b94de74382b4cd05673695d2649ee.UDDPtLDf1J2E3d3Oiy9is77Nm5xRSIfB7q5z0uUZRSQ
features-sessions-scrollbarrail--pure--light:
hash: v1.k4693efd2.33f02943fdbb1ea9774881f8909b580ab03ba02749614c533af1bf51dd817dca.ieP2bhDz0U_PTB4M6Zplvit0XlSTFiUn083GYBFNuC0
features-sessions-scrollbarrail--scrollable-conversation--dark:
hash: v1.k4693efd2.203b62b52d6365d6ec222f7529c35b3f46b17cd07ca792a8ce2de594af4d589b.3dfpnVM8yxor1PNO5aPk0pxn5THZjK4otA6mbz3XhtA
features-sessions-scrollbarrail--scrollable-conversation--light:
hash: v1.k4693efd2.fa7ef9cbdb09aef6108efcd3606cee32d889b3ad3b8f8be1ac637aed4e80cc72.0XcIKzM-sZHVG9hTZkAHdnBIPSrBiKmjq56QRQ6nasc
features-sessions-toolcallblock--create-new-file--dark:
hash: v1.k4693efd2.8847f96037682460aba47f0e0f5113037918235fc98596933f2d1d06fd6f8f92.SHetexoVOtozsZGZQMcVbmpBlbk7M5D5Q0R_TJvPnyc
features-sessions-toolcallblock--create-new-file--light:
Expand Down
157 changes: 133 additions & 24 deletions packages/ui/src/features/sessions/components/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import type { CollapseMode } from "@posthog/ui/features/sessions/components/new-
import { createIncrementalThreadGrouper } from "@posthog/ui/features/sessions/components/new-thread/incrementalThreadGrouping";
import { ToolCallGroupChip } from "@posthog/ui/features/sessions/components/new-thread/ToolCallGroupChip";
import { SessionFooter } from "@posthog/ui/features/sessions/components/SessionFooter";
import { MessageScrollbarRail } from "@posthog/ui/features/sessions/components/scrollbar-rail/MessageScrollbarRail";
import { useMessageRailMarkers } from "@posthog/ui/features/sessions/components/scrollbar-rail/useMessageRailMarkers";
import {
type RenderItem,
SessionUpdateView,
Expand Down Expand Up @@ -73,8 +75,8 @@ import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import { useHotkeys } from "react-hotkeys-hook";

Expand Down Expand Up @@ -106,6 +108,57 @@ export interface ConversationViewProps {
promptRecallRef?: RefObject<PromptRecallHandler | null>;
}

interface ConversationViewState {
showScrollButton: boolean;
jumpPickerOpen: boolean;
keyboardFocusedMessageId: string | null;
scrollElements: {
scrollEl: HTMLElement | null;
contentEl: HTMLElement | null;
};
}

type ConversationViewAction =
| { type: "set-show-scroll-button"; value: boolean }
| { type: "set-jump-picker-open"; value: boolean }
| { type: "set-keyboard-focused-message"; value: string | null }
| {
type: "set-scroll-elements";
value: ConversationViewState["scrollElements"];
};

const INITIAL_CONVERSATION_VIEW_STATE: ConversationViewState = {
showScrollButton: false,
jumpPickerOpen: false,
keyboardFocusedMessageId: null,
scrollElements: { scrollEl: null, contentEl: null },
};

function conversationViewReducer(
state: ConversationViewState,
action: ConversationViewAction,
): ConversationViewState {
switch (action.type) {
case "set-show-scroll-button":
return state.showScrollButton === action.value
? state
: { ...state, showScrollButton: action.value };
case "set-jump-picker-open":
return state.jumpPickerOpen === action.value
? state
: { ...state, jumpPickerOpen: action.value };
case "set-keyboard-focused-message":
return state.keyboardFocusedMessageId === action.value
? state
: { ...state, keyboardFocusedMessageId: action.value };
case "set-scroll-elements":
return state.scrollElements.scrollEl === action.value.scrollEl &&
state.scrollElements.contentEl === action.value.contentEl
? state
: { ...state, scrollElements: action.value };
}
}

export function ConversationView({
events,
isPromptPending,
Expand Down Expand Up @@ -134,7 +187,10 @@ export function ConversationView({

const listRef = useRef<VirtualizedListHandle>(null);
const isAtBottomRef = useRef(true);
const [showScrollButton, setShowScrollButton] = useState(false);
const [viewState, dispatchViewState] = useReducer(
conversationViewReducer,
INITIAL_CONVERSATION_VIEW_STATE,
);
const debugLogsCloudRuns = useSettingsStore((s) => s.debugLogsCloudRuns);
const showDebugLogs = debugLogsCloudRuns;

Expand Down Expand Up @@ -169,14 +225,16 @@ export function ConversationView({
}
const firstUserMessageId = firstUserMessageIdRef.current;

const [initialItemIds] = useState(
() =>
new Set(
conversationItems
.filter((i) => i.type === "user_message")
.map((i) => i.id),
),
);
const initialItemIdsRef = useRef<Set<string> | null>(null);
if (initialItemIdsRef.current === null) {
initialItemIdsRef.current = new Set<string>();
for (const item of conversationItems) {
if (item.type === "user_message") {
initialItemIdsRef.current.add(item.id);
}
}
}
const initialItemIds = initialItemIdsRef.current;

const pendingPermissions = usePendingPermissionsForTask(taskId ?? "");
const pendingPermissionsCount = pendingPermissions.size;
Expand Down Expand Up @@ -235,6 +293,10 @@ export function ConversationView({
id != null ? itemIdToRowIndexRef.current.get(id) : undefined;
listRef.current?.scrollToIndex(rowIdx ?? index);
},
// Search doesn't read scroll/content geometry, so these forward to the real
// handle purely to satisfy the (now-extended) VirtualizedListHandle shape.
getScrollElement: () => listRef.current?.getScrollElement() ?? null,
getContentElement: () => listRef.current?.getContentElement() ?? null,
});

const search = useConversationSearch({
Expand All @@ -243,10 +305,8 @@ export function ConversationView({
listRef: searchListRef,
});

const [jumpPickerOpen, setJumpPickerOpen] = useState(false);
const [keyboardFocusedMessageId, setKeyboardFocusedMessageId] = useState<
string | null
>(null);
const { jumpPickerOpen, keyboardFocusedMessageId, scrollElements } =
viewState;

const userMessages = useMemo(() => {
const result: Array<{ id: string; index: number; content: string }> = [];
Expand Down Expand Up @@ -292,15 +352,22 @@ export function ConversationView({
if (!nextMessage) return;

useSettingsStore.getState().markHintLearned(PROMPT_RECALL_HINT_KEY);
setKeyboardFocusedMessageId(nextMessage.id);
dispatchViewState({
type: "set-keyboard-focused-message",
value: nextMessage.id,
});
scrollToUserMessage(nextMessage.id, nextMessage.index);
},
[keyboardFocusedMessageId, userMessages, scrollToUserMessage],
);

useHotkeys(
SHORTCUTS.MESSAGE_JUMP,
() => setJumpPickerOpen((prev) => !prev),
() =>
dispatchViewState({
type: "set-jump-picker-open",
value: !jumpPickerOpen,
}),
THREAD_HOTKEY_OPTIONS,
);

Expand All @@ -317,34 +384,73 @@ export function ConversationView({
);

const clearKeyboardFocus = useCallback(() => {
setKeyboardFocusedMessageId(null);
dispatchViewState({ type: "set-keyboard-focused-message", value: null });
}, []);

const handleJumpToMessage = useCallback(
(id: string) => {
const message = userMessages.find((entry) => entry.id === id);
if (!message) return;
setKeyboardFocusedMessageId(id);
dispatchViewState({ type: "set-keyboard-focused-message", value: id });
scrollToUserMessage(id, message.index);
},
[userMessages, scrollToUserMessage],
);

// The scrollbar marker rail needs the VirtualizedList's scroll + content
// elements. Resolve them from the list ref callback so the rail renders as
// soon as the imperative handle is attached, without mount-time state setup.
const setListRef = useCallback((handle: VirtualizedListHandle | null) => {
listRef.current = handle;
dispatchViewState({
type: "set-scroll-elements",
value: {
scrollEl: handle?.getScrollElement() ?? null,
contentEl: handle?.getContentElement() ?? null,
},
});
}, []);

const railUserMessages = useMemo(
() =>
userMessages.map((m) => ({
id: m.id,
content: m.content,
index: m.index,
})),
[userMessages],
);
const railMarkers = useMessageRailMarkers({
contentEl: scrollElements.contentEl,
scrollEl: scrollElements.scrollEl,
userMessages: railUserMessages,
onJump: (id) => {
const message = userMessages.find((entry) => entry.id === id);
if (!message) return;
dispatchViewState({ type: "set-keyboard-focused-message", value: id });
scrollToUserMessage(id, message.index);
},
activeId: keyboardFocusedMessageId,
});

const handleScrollStateChange = useCallback((isAtBottom: boolean) => {
isAtBottomRef.current = isAtBottom;
setShowScrollButton(!isAtBottom);
dispatchViewState({
type: "set-show-scroll-button",
value: !isAtBottom,
});
}, []);

const scrollToBottom = useCallback(() => {
listRef.current?.scrollToBottom();
setShowScrollButton(false);
dispatchViewState({ type: "set-show-scroll-button", value: false });
}, []);

useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden && isAtBottomRef.current) {
listRef.current?.scrollToBottom();
setShowScrollButton(false);
dispatchViewState({ type: "set-show-scroll-button", value: false });
}
};

Expand Down Expand Up @@ -501,14 +607,16 @@ export function ConversationView({

<MessageJumpPicker
open={jumpPickerOpen}
onOpenChange={setJumpPickerOpen}
onOpenChange={(open) =>
dispatchViewState({ type: "set-jump-picker-open", value: open })
}
items={items}
onJumpToMessage={handleJumpToMessage}
/>

<SessionTaskIdProvider taskId={taskId}>
<VirtualizedList<ThreadRow>
ref={listRef}
ref={setListRef}
items={threadRows}
getItemKey={getRowKey}
renderItem={renderRow}
Expand All @@ -521,7 +629,8 @@ export function ConversationView({
scrollX={scrollX}
/>
</SessionTaskIdProvider>
{showScrollButton && (
<MessageScrollbarRail markers={railMarkers} />
{viewState.showScrollButton && (
<Box className="absolute right-6 bottom-4 z-10">
<Tooltip>
<TooltipTrigger
Expand Down
12 changes: 12 additions & 0 deletions packages/ui/src/features/sessions/components/VirtualizedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ interface VirtualizedListProps<T> {
export interface VirtualizedListHandle {
scrollToBottom: () => void;
scrollToIndex: (index: number) => void;
/** The scrolling element (the `overflow-y-auto` viewport). Exposed so a
* scrollbar marker rail can read `scrollTop`/`scrollHeight` and refresh on
* scroll. `null` until the list mounts. */
getScrollElement: () => HTMLDivElement | null;
/** The inner content element whose height == the virtual total size. Rows
* (and their `data-conversation-item-id` stamps) live inside this, so a
* marker rail measures row offsets against it. `null` until the list mounts. */
getContentElement: () => HTMLDivElement | null;
}

const AT_BOTTOM_THRESHOLD = 50;
Expand Down Expand Up @@ -63,6 +71,7 @@ function VirtualizedListInner<T>(
ref: React.ForwardedRef<VirtualizedListHandle>,
) {
const parentRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const footerRef = useRef<HTMLDivElement>(null);
const initializedRef = useRef(false);
const isAtBottomRef = useRef(true);
Expand Down Expand Up @@ -156,6 +165,8 @@ function VirtualizedListInner<T>(
isAtBottomRef.current = false;
virtualizer.scrollToIndex(index, { align: "center" });
},
getScrollElement: () => parentRef.current,
getContentElement: () => contentRef.current,
}),
[virtualizer, settleAtEnd],
);
Expand Down Expand Up @@ -258,6 +269,7 @@ function VirtualizedListInner<T>(
style={{ scrollbarGutter: "stable" }}
>
<div
ref={contentRef}
style={{
height: totalSize,
position: "relative",
Expand Down
Loading
Loading