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
Original file line number Diff line number Diff line change
@@ -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(
<ConversationMinimap messages={few} activeId={null} onSelect={vi.fn()} />,
);

expect(container).toBeEmptyDOMElement();
});

it("renders one marker per message and jumps to the clicked one", () => {
const onSelect = vi.fn();
render(
<ConversationMinimap
messages={messages}
activeId={null}
onSelect={onSelect}
/>,
);

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(
<ConversationMinimap
messages={messages}
activeId="u3"
onSelect={vi.fn()}
/>,
);

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",
);
});
});
103 changes: 103 additions & 0 deletions packages/ui/src/features/sessions/components/ConversationMinimap.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav
aria-label="Conversation minimap"
className="pointer-events-none absolute inset-y-3 right-2.5 z-10 flex w-4 items-center"
>
<div
className="relative h-full w-full"
// Short conversations get a compact centered rail instead of markers pinned to the
// pane's far corners; long ones use the full height.
style={{ maxHeight: messages.length * MARKER_SPACING }}
>
{messages.map((message, index) => {
const isActive = message.id === activeId;
return (
<Tooltip key={message.id}>
<TooltipTrigger
render={
<button
type="button"
aria-label={`Jump to message ${index + 1} of ${messages.length}: ${message.preview}`}
aria-current={isActive ? "true" : undefined}
onClick={() => onSelect(message.id)}
className="group -translate-y-1/2 pointer-events-auto absolute right-0 flex h-3.5 w-full cursor-pointer items-center justify-end focus-visible:outline focus-visible:outline-(--accent-9)"
style={{
top: `${(index / (messages.length - 1)) * 100}%`,
}}
>
<span
className={cn(
"h-[3px] rounded-full transition-[width,background-color] duration-150",
isActive
? "w-4 bg-(--accent-9)"
: "w-2.5 bg-(--gray-a6) group-hover:w-4 group-hover:bg-(--gray-a9)",
)}
/>
</button>
}
/>
<TooltipContent side="left" className="max-w-72">
{message.preview}
</TooltipContent>
</Tooltip>
);
})}
</div>
</nav>
);
}
32 changes: 32 additions & 0 deletions packages/ui/src/features/sessions/components/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | null>(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) => {
Expand Down Expand Up @@ -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"
Expand All @@ -521,6 +546,13 @@ export function ConversationView({
scrollX={scrollX}
/>
</SessionTaskIdProvider>
{!compact && (
<ConversationMinimap
messages={minimapMessages}
activeId={minimapActiveId}
onSelect={handleJumpToMessage}
/>
)}
{showScrollButton && (
<Box className="absolute right-6 bottom-4 z-10">
<Tooltip>
Expand Down
29 changes: 29 additions & 0 deletions packages/ui/src/features/sessions/components/VirtualizedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ interface VirtualizedListProps<T> {
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
Expand Down Expand Up @@ -57,6 +63,7 @@ function VirtualizedListInner<T>(
itemStyle,
footer,
onScrollStateChange,
onFirstVisibleRowChange,
keepMounted,
scrollX = true,
}: VirtualizedListProps<T>,
Expand All @@ -71,6 +78,9 @@ function VirtualizedListInner<T>(
const settleRafRef = useRef<number | null>(null);
const onScrollStateChangeRef = useRef(onScrollStateChange);
onScrollStateChangeRef.current = onScrollStateChange;
const onFirstVisibleRowChangeRef = useRef(onFirstVisibleRowChange);
onFirstVisibleRowChangeRef.current = onFirstVisibleRowChange;
const lastFirstVisibleRowRef = useRef(-1);

const hasFooter = footer != null;

Expand Down Expand Up @@ -227,6 +237,25 @@ function VirtualizedListInner<T>(
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.
Expand Down
Loading
Loading