diff --git a/packages/ui/src/features/sessions/components/ConversationMinimap.test.tsx b/packages/ui/src/features/sessions/components/ConversationMinimap.test.tsx new file mode 100644 index 0000000000..70acfe13fd --- /dev/null +++ b/packages/ui/src/features/sessions/components/ConversationMinimap.test.tsx @@ -0,0 +1,94 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ConversationItem } from "./buildConversationItems"; +import { + ConversationMinimap, + type MinimapMessage, + toMinimapMessages, +} from "./ConversationMinimap"; + +function userMessage(id: string, content: string): ConversationItem { + return { type: "user_message", id, content, timestamp: 0 }; +} + +describe("toMinimapMessages", () => { + it("keeps only user messages, in conversation order", () => { + const items: ConversationItem[] = [ + userMessage("u1", "first"), + { type: "turn_cancelled", id: "t1" }, + userMessage("u2", "second"), + ]; + + expect(toMinimapMessages(items)).toEqual([ + { id: "u1", preview: "first" }, + { id: "u2", preview: "second" }, + ]); + }); + + it("collapses whitespace and truncates long content for the preview", () => { + const [message] = toMinimapMessages([ + userMessage("u1", `line one\n\nline two ${"x".repeat(200)}`), + ]); + + expect(message.preview.startsWith("line one line two")).toBe(true); + expect(message.preview.endsWith("…")).toBe(true); + // 80 preview chars plus the ellipsis. + expect(message.preview).toHaveLength(81); + }); +}); + +describe("ConversationMinimap", () => { + const messages: MinimapMessage[] = [ + { id: "u1", preview: "first message" }, + { id: "u2", preview: "second message" }, + { id: "u3", preview: "third message" }, + ]; + + it.each([ + { name: "no messages", few: [] as MinimapMessage[] }, + { name: "a single message", few: messages.slice(0, 1) }, + ])("renders nothing with $name", ({ few }) => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders one marker per message and jumps to the clicked one", () => { + const onSelect = vi.fn(); + render( + , + ); + + const nav = screen.getByRole("navigation", { + name: "Conversation minimap", + }); + const markers = within(nav).getAllByRole("button"); + expect(markers).toHaveLength(3); + + fireEvent.click(markers[1]); + expect(onSelect).toHaveBeenCalledExactlyOnceWith("u2"); + }); + + it("marks the anchored message with aria-current", () => { + render( + , + ); + + const markers = screen.getAllByRole("button"); + expect(markers[2]).toHaveAttribute("aria-current", "true"); + expect(markers[0]).not.toHaveAttribute("aria-current"); + expect(markers[2]).toHaveAccessibleName( + "Jump to message 3 of 3: third message", + ); + }); +}); diff --git a/packages/ui/src/features/sessions/components/ConversationMinimap.tsx b/packages/ui/src/features/sessions/components/ConversationMinimap.tsx new file mode 100644 index 0000000000..341e7f240e --- /dev/null +++ b/packages/ui/src/features/sessions/components/ConversationMinimap.tsx @@ -0,0 +1,103 @@ +import { cn, Tooltip, TooltipContent, TooltipTrigger } from "@posthog/quill"; +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; + +export interface MinimapMessage { + id: string; + preview: string; +} + +const PREVIEW_MAX_LENGTH = 80; + +/** User messages reduced to what a minimap marker needs: id + one-line preview. */ +export function toMinimapMessages(items: ConversationItem[]): MinimapMessage[] { + const result: MinimapMessage[] = []; + for (const item of items) { + if (item.type !== "user_message") continue; + const singleLine = item.content.replace(/\s+/g, " ").trim(); + result.push({ + id: item.id, + preview: + singleLine.length <= PREVIEW_MAX_LENGTH + ? singleLine + : `${singleLine.slice(0, PREVIEW_MAX_LENGTH)}…`, + }); + } + return result; +} + +/** Vertical budget per marker; the rail compresses below this once it hits full height. */ +const MARKER_SPACING = 16; + +interface ConversationMinimapProps { + messages: MinimapMessage[]; + /** Message the view is currently anchored to; its marker is accented. */ + activeId?: string | null; + onSelect: (id: string) => void; +} + +/** + * Clickable minimap of the user's messages, docked to the thread's right edge (just left of the + * scrollbar). One marker per user message, spread top-to-bottom in conversation order — like + * editor minimap annotations, position maps to "how far into the conversation", not pixel + * offsets. Hover previews the message; click scrolls to it. + * + * Rendered as an overlay inside the thread's positioning context (same convention as the + * scroll-to-bottom button): `pointer-events-none` everywhere except the markers, so the empty + * strip never blocks the content underneath. + */ +export function ConversationMinimap({ + messages, + activeId, + onSelect, +}: ConversationMinimapProps) { + // With one message there is nowhere to jump; skip the rail entirely. + if (messages.length < 2) return null; + + return ( + + ); +} diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 5a049e930a..ef81493580 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -15,6 +15,10 @@ import type { ConversationItem, TurnContext, } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { + ConversationMinimap, + toMinimapMessages, +} from "@posthog/ui/features/sessions/components/ConversationMinimap"; import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar"; import { PROMPT_RECALL_HINT_KEY, @@ -261,6 +265,26 @@ export function ConversationView({ usePromptRecallSource(userMessages, promptRecallRef); + const minimapMessages = useMemo(() => toMinimapMessages(items), [items]); + + // "You are here" for the minimap: the last user message at or above the top + // of the viewport (the message whose turn is on screen). Derived from the + // list's first-visible-row pings; state only changes when scrolling crosses + // a user message, so this re-renders far less often than it fires. + const [minimapActiveId, setMinimapActiveId] = useState(null); + const userMessagesRef = useRef(userMessages); + userMessagesRef.current = userMessages; + const handleFirstVisibleRowChange = useCallback((rowIndex: number) => { + let active: string | null = null; + for (const message of userMessagesRef.current) { + const messageRow = + itemIdToRowIndexRef.current.get(message.id) ?? message.index; + if (messageRow > rowIndex) break; + active = message.id; + } + setMinimapActiveId(active); + }, []); + // Grouped rows != items, so scroll by the row the message landed in (same // mapping search uses), falling back to the raw item index. const scrollToUserMessage = useCallback((id: string, itemIndex: number) => { @@ -513,6 +537,7 @@ export function ConversationView({ getItemKey={getRowKey} renderItem={renderRow} onScrollStateChange={handleScrollStateChange} + onFirstVisibleRowChange={handleFirstVisibleRowChange} keepMounted={rowKeepMounted} className="absolute inset-0 bg-background" itemClassName="mx-auto px-2 py-1.5" @@ -521,6 +546,13 @@ export function ConversationView({ scrollX={scrollX} /> + {!compact && ( + + )} {showScrollButton && ( diff --git a/packages/ui/src/features/sessions/components/VirtualizedList.tsx b/packages/ui/src/features/sessions/components/VirtualizedList.tsx index ef5ae0e174..5a7fc38319 100644 --- a/packages/ui/src/features/sessions/components/VirtualizedList.tsx +++ b/packages/ui/src/features/sessions/components/VirtualizedList.tsx @@ -22,6 +22,12 @@ interface VirtualizedListProps { itemStyle?: CSSProperties; footer?: ReactNode; onScrollStateChange?: (isAtBottom: boolean) => void; + /** + * Reports the topmost row in view whenever it changes while scrolling. + * Drives position indicators (the minimap's active marker) without making + * every scroll frame a parent re-render. + */ + onFirstVisibleRowChange?: (rowIndex: number) => void; keepMounted?: readonly number[]; /** * Allow horizontal scrolling of the list viewport. Defaults to true. Narrow @@ -57,6 +63,7 @@ function VirtualizedListInner( itemStyle, footer, onScrollStateChange, + onFirstVisibleRowChange, keepMounted, scrollX = true, }: VirtualizedListProps, @@ -71,6 +78,9 @@ function VirtualizedListInner( const settleRafRef = useRef(null); const onScrollStateChangeRef = useRef(onScrollStateChange); onScrollStateChangeRef.current = onScrollStateChange; + const onFirstVisibleRowChangeRef = useRef(onFirstVisibleRowChange); + onFirstVisibleRowChangeRef.current = onFirstVisibleRowChange; + const lastFirstVisibleRowRef = useRef(-1); const hasFooter = footer != null; @@ -227,6 +237,25 @@ function VirtualizedListInner( isAtBottomRef.current = false; } + if (onFirstVisibleRowChangeRef.current) { + // Virtual items include overscan rows above the viewport; the first row + // actually in view is the first whose bottom edge is past scrollTop. + let firstVisible = -1; + for (const v of virtualizer.getVirtualItems()) { + if (v.end > scrollTop) { + firstVisible = v.index; + break; + } + } + if ( + firstVisible !== -1 && + firstVisible !== lastFirstVisibleRowRef.current + ) { + lastFirstVisibleRowRef.current = firstVisible; + onFirstVisibleRowChangeRef.current(firstVisible); + } + } + if (!initializedRef.current) return; // Suppress intermediate "not at bottom" pings while a programmatic // scrollToEnd is still settling after row remeasure. diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 75a79c707a..e6a2972c90 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -37,6 +37,10 @@ import { useSmoothedText } from "@posthog/ui/features/editor/components/useSmoot import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { + ConversationMinimap, + toMinimapMessages, +} from "@posthog/ui/features/sessions/components/ConversationMinimap"; import { ChatMarkdown, ChatStreamingMarkdown, @@ -746,6 +750,26 @@ const ThreadRow = memo(function ThreadRow({ ); }); +/** + * Right-edge minimap of the user's messages. Highlights the engine's current anchor (the user + * message whose turn is on screen) and jumps via `scrollToMessage` — the same primitives the + * sticky header uses. Like the header, only this small component subscribes to per-scroll + * visibility state, so rows never re-render while scrolling. + */ +function ThreadMinimap({ items }: { items: ConversationItem[] }) { + const { scrollToMessage } = useChatMessageScroller(); + const { currentAnchorId } = useChatMessageScrollerVisibility(); + const messages = useMemo(() => toMinimapMessages(items), [items]); + + return ( + + ); +} + /** * Keeps the view pinned to the bottom from prompt submit until the user scrolls away. * @@ -928,6 +952,7 @@ function ThreadScrollBody({ footer, keyboardFocusedMessageId, onUserInteract, + showMinimap = true, }: { items: ConversationItem[]; rows: TurnRow[]; @@ -937,6 +962,8 @@ function ThreadScrollBody({ keyboardFocusedMessageId?: string | null; /** Clears keyboard-focused message state on any pointer interaction with the thread. */ onUserInteract?: () => void; + /** Narrow embeds (command center, canvas panel) drop the minimap rail. */ + showMinimap?: boolean; }) { const keyedRows = useMemo(() => { let userTurn = 0; @@ -955,6 +982,7 @@ function ThreadScrollBody({ > + {showMinimap && } (DIFF_WORKER_FACTORY); const diffsPoolOptions = useMemo( @@ -1170,6 +1201,7 @@ function ChatThreadRenderer({ renderItem={renderItem} keyboardFocusedMessageId={keyboardFocusedMessageId} onUserInteract={clearKeyboardFocus} + showMinimap={!compact} footer={