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={