Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
150 changes: 127 additions & 23 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,11 @@ 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);
initialItemIdsRef.current ??= new Set(
conversationItems.filter((i) => i.type === "user_message").map((i) => i.id),
);
const initialItemIds = initialItemIdsRef.current;

const pendingPermissions = usePendingPermissionsForTask(taskId ?? "");
const pendingPermissionsCount = pendingPermissions.size;
Expand Down Expand Up @@ -235,6 +288,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 +300,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 +347,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 +379,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 +602,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 +624,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