# How the Chat Minimap Component Interacts with the Message List for Navigation

> Discover how the Chat Minimap component navigates the Message List by reading DOM positions and manipulating scroll ratios. Learn about efficient navigation without DOM mutation.

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

---

**The ChatMinimap component enables navigation by reading DOM positions through shared refs provided by ChatWindow, converting click coordinates to scroll ratios, and manipulating the scrollContainer's scrollTop property without mutating the message list DOM.**

The `agegr/pi-web` repository implements a sophisticated chat interface where the **ChatMinimap** component provides click-and-drag navigation across long conversations. This React-based minimap visualizes the entire message list as a compressed vertical map, allowing users to jump to any part of the chat instantly. Understanding how the chat minimap component interacts with the message list for navigation reveals a clean architecture based on shared references and ratio-based calculations rather than direct DOM manipulation.

## Shared References and Data Architecture

The interaction between the minimap and message list relies on four critical pieces of shared state passed as props from `ChatWindow` to `ChatMinimap`.

### The Scroll Container Bridge

Both components share a single `scrollContainer` ref pointing to the scrollable `<div>` that wraps the message list. In [`components/ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatWindow.tsx), this is created via `useAgentSession` and passed to the minimap:

```typescript
// ChatWindow.tsx lines 95-102
const scrollContainerRef = useRef<HTMLDivElement>(null);
// ...
<ChatMinimap
  scrollContainer={scrollContainerRef}
  // ...
/>

```

This `RefObject<HTMLDivElement>` allows the minimap to read `scrollHeight`, `clientHeight`, and `scrollTop` properties while enabling programmatic scrolling through the same container that the message list uses.

### Message Reference Arrays

The `messageRefs` prop provides direct access to each message's DOM element. Created via the `useMessageRefs(count)` hook exported from [`components/ChatMinimap.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatMinimap.tsx) (lines 400-405), this array is populated by `ChatWindow` using the `attachVisibleRef` function:

```typescript
// ChatWindow.tsx lines 31-34
const attachVisibleRef = (idx: number, currentRefIdx: number) => (el: HTMLDivElement | null) => {
  if (el) messageRefs.current[currentRefIdx] = el;
};

```

Each rendered `MessageView` wraps its root element in a `<div ref={attachVisibleRef(idx, refIndex)}>`, storing the DOM node in the shared array for position calculations.

## The Navigation Pipeline

The chat minimap component translates user interactions into scroll movements through a four-stage pipeline that maintains synchronization with the message list.

### 1. Reference Wiring in ChatWindow

Before navigation can occur, `ChatWindow` establishes the connection between React components and DOM nodes. It creates a stable reference array sized to the visible message count:

```typescript
// Inside ChatWindow.tsx
const visibleMessages = messages.filter(m => m.role === "user" || m.role === "assistant");
const messageRefs = useMessageRefs(visibleMessages.length);

```

When rendering each message, the component attaches the ref callback:

```typescript
const renderMessage = (idx: number, { attachRef = true } = {}) => {
  const view = <MessageView ... />;
  if (!attachRef) return view;
  return <div ref={attachVisibleRef(idx, currentRefIdx)}>{view}</div>;
};

```

### 2. Measuring Node Positions

The `measureNodes()` function in [`components/ChatMinimap.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatMinimap.tsx) (lines 14-35) calculates where each message sits relative to the total scrollable area. Triggered by `ResizeObserver` and message updates, it converts absolute pixel positions to ratios:

```typescript
const measureNodes = () => {
  const container = scrollContainer.current;
  const totalH = container.scrollHeight;
  const containerRect = container.getBoundingClientRect();
  
  const newNodes: NodeInfo[] = [];
  messageRefs.current.forEach((el, index) => {
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const top = rect.top - containerRect.top + container.scrollTop;
    newNodes.push({
      topRatio: top / totalH,
      heightRatio: rect.height / totalH,
      msg: allMessages[index],
      index
    });
  });
  setNodes(newNodes);
};

```

These **ratios (0–1)** describe message positions independent of viewport size, enabling the minimap to render correctly at any scale.

### 3. Converting Ratios to Scroll Positions

The `scrollToMinimapRatio` function translates minimap click coordinates back into absolute scroll positions. Located in [`components/ChatMinimap.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatMinimap.tsx) (lines 80-87), it accounts for the viewport height ratio to prevent over-scrolling:

```typescript
const scrollToMinimapRatio = (viewportTopRatio: number) => {
  const el = scrollContainer.current;
  const scrollable = el.scrollHeight - el.clientHeight;
  const clamped = Math.max(0, Math.min(1 - viewportRatio, viewportTopRatio));
  el.scrollTop = (clamped / (1 - viewportRatio)) * scrollable;
};

```

This calculation ensures that clicking the bottom of the minimap scrolls to the end of the message list, while respecting the visible viewport size.

### 4. Handling User Interactions

The `handleMouseDown` function (lines 89-113) initiates the drag navigation. It records the initial click ratio, computes an offset for "grab point" behavior, and sets up mouse move listeners:

```typescript
const handleMouseDown = (e: React.MouseEvent) => {
  const rect = minimapRef.current.getBoundingClientRect();
  const clickRatio = (e.clientY - rect.top) / rect.height;
  const offset = clickRatio - scrollRatio;
  
  const onMove = (moveEvent: MouseEvent) => {
    const moveRatio = (moveEvent.clientY - rect.top) / rect.height;
    scrollToMinimapRatio(moveRatio - offset);
  };
  
  // Mouse up cleanup...
};

```

While dragging, continuous calls to `scrollToMinimapRatio` update `scrollContainer.scrollTop`, causing the message list to scroll smoothly without the minimap directly manipulating message DOM nodes.

## Visual Feedback Mechanisms

Beyond navigation, the chat minimap component interacts with the message list to provide contextual visual feedback.

### Viewport Indicator Rendering

The `updateScroll` function (lines 85-99) maintains `scrollRatio` and `viewportRatio` state based on the scroll container's current position:

```typescript
const updateScroll = () => {
  const el = scrollContainer.current;
  const ratio = el.scrollTop / (el.scrollHeight - el.clientHeight);
  setScrollRatio(ratio);
  setViewportRatio(el.clientHeight / el.scrollHeight);
};

```

These values drive a semi-transparent rectangle (`viewportBoxTop` and `viewportBoxHeight`) that visually indicates which portion of the conversation is currently visible in the message list.

### Tooltip Preview System

On hover, the minimap calculates the nearest message node (lines 52-56) and displays a preview tooltip. The component uses an iterative de-overlapping algorithm (lines 22-44) to prevent tooltip collisions while showing message content from the `messages` prop array.

## Implementation Code Examples

**Wiring the ChatWindow container:**

```tsx
// components/ChatWindow.tsx
const visibleMessages = messages.filter(m => m.role === "user" || m.role === "assistant");
const messageRefs = useMessageRefs(visibleMessages.length);
const scrollContainerRef = useRef<HTMLDivElement>(null);

// In render:
<div ref={scrollContainerRef} className="overflow-y-auto">
  {visibleMessages.map((msg, idx) => (
    <div key={msg.id} ref={attachVisibleRef(idx, idx)}>
      <MessageView message={msg} />
    </div>
  ))}
</div>

{!isMobile && (
  <ChatMinimap
    messages={messages}
    streamingMessage={streamState.streamingMessage}
    scrollContainer={scrollContainerRef}
    messageRefs={messageRefs}
  />
)}

```

**Ratio-based scroll calculation:**

```typescript
// From components/ChatMinimap.tsx
const scrollToMinimapRatio = (viewportTopRatio: number) => {
  const el = scrollContainer.current;
  const scrollable = el.scrollHeight - el.clientHeight;
  const clamped = Math.max(0, Math.min(1 - viewportRatio, viewportTopRatio));
  el.scrollTop = (clamped / (1 - viewportRatio)) * scrollable;
};

```

## Summary

- **Shared refs architecture**: The minimap receives `scrollContainer` and `messageRefs` from `ChatWindow` to read positions without DOM queries.
- **Ratio-based calculations**: All positions are normalized to 0-1 ratios, enabling responsive minimap rendering regardless of container size.
- **Non-mutating navigation**: The minimap only reads from `messageRefs` and writes to `scrollContainer.scrollTop`, never modifying message list DOM directly.
- **Event-driven synchronization**: `ResizeObserver` and scroll listeners keep the minimap viewport indicator synchronized with the actual message list position.
- **Clean separation**: `ChatWindow` manages the message DOM and refs; `ChatMinimap` handles visualization and navigation logic independently.

## Frequently Asked Questions

### How does ChatMinimap access message positions without querying the DOM directly?

`ChatMinimap` receives pre-populated refs through the `messageRefs` prop, which `ChatWindow` fills via the `attachVisibleRef` callback during render. This eliminates the need for `document.querySelector` calls and ensures React maintains ownership of the DOM references.

### What happens when new messages arrive while the minimap is visible?

When the `messages` array updates, `ChatWindow` recreates the `messageRefs` array with the new length using `useMessageRefs`. The minimap detects these changes through its `useEffect` dependencies and triggers `measureNodes()` to recalculate positions, adding new dots to the minimap visualization immediately.

### Why does the minimap use ratios instead of absolute pixel values?

Ratios (values between 0 and 1) allow the minimap to scale to any container height while maintaining accurate navigation. Since `scrollHeight` changes based on content and viewport size, storing `topRatio` and `heightRatio` ensures that clicking a specific point on the minimap always maps to the same logical position in the conversation, regardless of window resizing or message expansion.

### How is the scroll synchronization kept performant during drag operations?

The drag handler uses the existing `scrollContainer` ref to write directly to the native `scrollTop` property, bypassing React's render cycle during the drag. State updates for visual feedback (like the viewport indicator) are throttled or handled via CSS transforms, while the actual scrolling occurs through direct DOM manipulation for 60fps performance.