# How Pi Web's Chat Minimap Renders and Navigates Large Sessions

> Discover how Pi Web's Chat Minimap component renders and navigates large sessions. Explore its four coordinated stages for instant access to any turn in thousands of messages.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: internals
- Published: 2026-08-14

---

**The Chat Minimap component visualizes an entire chat session in a compact 36px vertical rail, using four coordinated stages—turn extraction, node layout, rendering, and navigation sync—to let users instantly jump to any turn even in conversations with thousands of messages.**

Pi Web's chat interface handles extended conversations where scrolling becomes impractical. The minimap solves this by providing a persistent, interactive overview that stays synchronized with the main view. This article breaks down the architecture implemented in [`components/ChatMinimap.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatMinimap.tsx) according to the Pi Web source code.

## Turn Extraction and DOM Measurement

The minimap begins by identifying every navigable point in the conversation. The `measureNodes` function (lines 31‑66 in *ChatMinimap.tsx*) scans the rendered message list and records the vertical position of each **turn**—a user message combined with any assistant replies that follow it.

```tsx
// Simplified pattern from measureNodes
const measureNodes = useCallback(() => {
  const container = scrollContainer.current;
  if (!container) return;
  
  const newNodes: NodeInfo[] = [];
  
  messageRefs.current.forEach((ref, index) => {
    if (!ref) return;
    const rect = ref.getBoundingClientRect();
    const scrollOffset = rect.top - containerRect.top + container.scrollTop;
    
    // Record user turn position
    newNodes.push({
      type: 'turn',
      top: scrollOffset,
      messageIndex: index,
      assistantPreviews: extractPreviews(ref)
    });
  });
  
  setNodes(newNodes);
}, [messageRefs, scrollContainer]);

```

This measurement runs on a **~150ms throttle** to balance responsiveness with performance. The function handles both static `messages` and any `streamingMessage` currently being received, ensuring the minimap reflects the live conversation state.

## Node Layout and Visual Spacing

Once turns are measured, `layoutNodes` (lines 2‑27) distributes them across the minimap's available height. The algorithm enforces two key constraints:

- **Maximum gap (`MAX_NODE_GAP`)**: Prevents sparse sessions from stretching nodes too far apart
- **Even distribution**: Calculates `topRatio` (0.0–1.0) for each node's position

```tsx
// From layoutNodes logic
const layoutNodes = (nodes: NodeInfo[], totalHeight: number): PositionedNode[] => {
  const availableHeight = totalHeight - 2 * MINIMAP_PADDING;
  const nodeGap = Math.min(
    availableHeight / (nodes.length - 1),
    MAX_NODE_GAP
  );
  
  return nodes.map((node, i) => ({
    ...node,
    topRatio: MINIMAP_PADDING + i * nodeGap / totalHeight
  }));
};

```

This guarantees consistent visual density regardless of conversation length—a thousand-turn session looks as navigable as a ten-turn one.

## Rendering the Minimap Rail and Previews

The minimap renders as a **36px fixed-width rail** (`MINIMAP_WIDTH`) positioned alongside the chat. Each turn becomes a small circle whose appearance changes based on interaction state:

| State | Visual treatment |
|-------|----------------|
| Default | Small dot at `topRatio` position |
| Nearest to cursor | Scaled-up circle with stronger opacity |
| Active (in viewport) | Highlighted color matching the current turn |

When hovering, a **preview panel** appears showing:

- Truncated user message text
- An "A" button to jump directly to the assistant reply
- `AssistantOutline` sub-component rendering markdown headings from that turn

The preview implementation spans lines 602‑744 in *ChatMinimap.tsx*, using `markdownPreviewRemarkPlugins` from [`lib/markdown.ts`](https://github.com/agegr/pi-web/blob/main/lib/markdown.ts) to sanitize and truncate assistant content for display.

## Navigation and Scroll Synchronization

The minimap supports multiple navigation modes, all coordinated through **ratio-based positioning**:

### Click and Drag Navigation

```tsx
// From handleMouseDown and related handlers
const handleMouseDown = (e: React.MouseEvent) => {
  const rect = railRef.current.getBoundingClientRect();
  const mouseRatio = (e.clientY - rect.top) / rect.height;
  const nearestNode = findNearestNode(mouseRatio);
  
  scrollToNode(nearestNode);
  setIsDragging(true);
};

```

Dragging continuously updates the target node via `findNearestNode`, providing immediate visual feedback while the user scrubs through the conversation.

### Active Node Locking

To prevent flicker during navigation, the component uses `activeNodeLockRef` with a **1600ms duration** (`NAVIGATION_ACTIVE_LOCK_MS`). While locked, `syncActiveNode` (lines 758‑776) ignores scroll events and preserves the highlight on the destination node until the animation completes.

### Pending Navigation for Hidden History

Large sessions may have unloaded message history. When a user clicks a turn that isn't yet rendered:

1. The request is stored in `pendingNavigationRef`
2. `onRevealHistory` callback loads additional messages
3. After `measureNodes` runs on the expanded set, the pending navigation automatically executes

This deferred navigation ensures the minimap remains functional even with virtualized or paginated chat content.

## Integration Example

To embed the minimap in a custom chat interface, use the `useMessageRefs` hook and pass the resulting ref array:

```tsx
import { ChatMinimap, useMessageRefs } from "@/components/ChatMinimap";

function ChatWindow({ messages, streamingMessage }) {
  const scrollContainerRef = useRef<HTMLDivElement>(null);
  const messageRefs = useMessageRefs(messages.length);
  
  return (
    <div className="relative flex">
      <div 
        ref={scrollContainerRef}
        className="flex-1 overflow-y-auto"
      >
        {messages.map((msg, i) => (
          <Message 
            key={msg.id}
            ref={messageRefs.current[i]}
            content={msg.content}
          />
        ))}
      </div>
      
      <ChatMinimap
        messages={messages}
        streamingMessage={streamingMessage}
        scrollContainer={scrollContainerRef}
        messageRefs={messageRefs}
        onRevealHistory={() => loadMoreHistory()}
      />
    </div>
  );
}

```

## Key Configuration Constants

| Constant | Value | Purpose |
|----------|-------|---------|
| `MINIMAP_WIDTH` | 36px | Fixed rail width |
| `MINIMAP_PADDING` | 4px | Internal edge spacing |
| `MAX_NODE_GAP` | 48px | Maximum vertical space between nodes |
| `NAVIGATION_ACTIVE_LOCK_MS` | 1600ms | Lock duration preventing highlight flicker |
| `PREVIEW_HIDE_DELAY` | 300ms | Hover-out delay before preview closes |

Modify these in [`ChatMinimap.tsx`](https://github.com/agegr/pi-web/blob/main/ChatMinimap.tsx) to adapt the minimap to different design requirements.

## Summary

- **Turn extraction** via `measureNodes` captures every user-assistant pair with ~150ms throttled DOM measurement
- **Node layout** with `topRatio` calculation ensures consistent visual density across any conversation length
- **Dual rendering** provides both the permanent rail overview and contextual hover previews via `AssistantOutline`
- **Robust navigation** combines ratio-based targeting, active node locking, and pending navigation for unloaded content
- **Self-contained architecture** keeps all state and synchronization within the component, requiring only message refs and a scroll container from the parent

## Frequently Asked Questions

### How does the minimap handle extremely long conversations with thousands of messages?

The minimap uses **ratio-based positioning** rather than absolute pixels, so node spacing remains consistent regardless of total scroll height. The `layoutNodes` function caps gaps at `MAX_NODE_GAP` and distributes turns evenly, ensuring the rail never exceeds viewport height. For virtualized content, `pendingNavigationRef` defers jumps until target messages are rendered.

### What's the difference between `scrollToNode`, `scrollToAssistant`, and `scrollToHeading`?

**`scrollToNode`** jumps to the user message of a turn. **`scrollToAssistant`** scrolls to a specific assistant preview within that turn (when multiple previews exist). **`scrollToHeading`** navigates to a markdown heading inside an assistant's response, useful for long structured answers. All three respect the active node lock to prevent sync conflicts.

### Why does the minimap need `messageRefs` passed from the parent?

The minimap must measure actual **rendered DOM positions** to compute accurate scroll offsets. Since React refs can't be forwarded through multiple component layers cleanly, `useMessageRefs` creates a stable ref array that the parent attaches to message components and the minimap reads for measurement.

### How can I customize the preview panel's appearance?

The preview rendering occurs in the main JSX return (lines 602‑744). You can modify the `AssistantOutline` sub-component or replace the preview structure entirely—the `markdownPreviewRemarkPlugins` from [`lib/markdown.ts`](https://github.com/agegr/pi-web/blob/main/lib/markdown.ts) handle markdown processing, while `splitFinalAssistantBlocks` from [`lib/message-display.ts`](https://github.com/agegr/pi-web/blob/main/lib/message-display.ts) generates the plain-text snippets shown in the panel.