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

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 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.

// 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
// 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 to sanitize and truncate assistant content for display.

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

Click and Drag Navigation

// 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:

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 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 handle markdown processing, while splitFinalAssistantBlocks from lib/message-display.ts generates the plain-text snippets shown in the panel.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →