Skip to content
Closed
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
276 changes: 276 additions & 0 deletions src/tui/messageList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
/**
* MessageList component — manages a Map of MessageBubble component instances.
* Provides imperative methods (addMessage, updateMessage, clear) and handles
* scroll-to-bottom internally via its own ref.
*
* Using a Map (keyed by bubble ID) instead of an array ensures that
* imperatively-added streaming bubbles survive the sync from the
* messages prop, which is the source of truth for persisted messages.
*
* @module messageList
*/

import { randomUUID } from "node:crypto";
import React, { useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from "react";
import { Box } from "ink";
import { ScrollView } from "ink-scroll-view";
import MessageBubble from "./messageBubble.js";
import { logger } from "../logger.js";

/**
* Maximum number of messages to render in the React tree.
* Limits rendering to the last N messages to prevent performance
* degradation with very long conversations.
* @constant {number}
*/
const MAX_RENDER_MESSAGES = 100;

/**
* Scroll throttle interval in milliseconds during active streaming.
* Reduces scroll-to-bottom frequency by ~90% while maintaining smooth UX.
* @constant {number}
*/
const SCROLL_THROTTLE_MS = 100;

/**
* MessageList component — manages MessageBubble instances and handles scrolling.
*
* @param {Object} props
* @param {React.Ref} [props.scrollRef] - Optional external scroll ref passed from parent
*/
export const MessageList = forwardRef(function MessageList({ scrollRef: externalScrollRef, height, messages }, ref) {
const [bubbles, setBubbles] = useState(new Map());
const internalScrollRef = useRef(null);
const scrollRef = externalScrollRef || internalScrollRef;
const previousMessageCount = useRef(0);
const previousContentHashRef = useRef(0);
const lastScrollTimeRef = useRef(0);
const isUserScrollingRef = useRef(false);
const bubbleRefs = useRef({});
const { stdout } = React.useContext(React.createContext({}));
const prevMessageCountRef = useRef(0);

logger.info({ hasMessages: !!messages, messageCount: messages?.length }, "[MessageList] component rendered");

// Sync persisted messages from prop into the bubble Map.
// Uses index-based IDs (msg-0, msg-1, ...) as the key so the
// sync can find and update existing entries without wiping
// imperatively-added streaming bubbles.
useEffect(() => {
logger.info({ messageCount: messages?.length, prevCount: prevMessageCountRef.current }, "[MessageList] useEffect fired");
const currentCount = messages?.length ?? 0;
if (currentCount === prevMessageCountRef.current) {
logger.info("[MessageList] count unchanged, returning");
return;
}
prevMessageCountRef.current = currentCount;

logger.info({ messageCount: currentCount }, "[MessageList] messages length changed");
if (currentCount === 0) {
setBubbles(new Map());
return;
}

setBubbles((prev) => {
const next = new Map(prev);
messages.forEach((msg, idx) => {
const key = `msg-${idx}`;
const existing = next.get(key);
if (existing) {
// Update existing synced bubble in place
next.set(key, { ...existing, ...msg });
} else {
// New synced message — add it
next.set(key, {
id: key,
role: msg.role,
content: msg.content || "",
assistantName: msg.assistantName || "Assistant",
time: msg.time || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
streaming: msg.streaming,
reasoningContent: msg.reasoningContent,
activeToolCall: msg.activeToolCall,
toolCallDisplay: msg.toolCallDisplay,
});
}
});
logger.info({ bubbleCount: next.size }, "[MessageList] bubbles synced");
return next;
});
}, [messages?.length]);

// We need access to stdout for resize handling — get it from the Ink context
// Since we can't directly import useStdout here without breaking the module,
// we'll handle resize via a different approach
useEffect(() => {
// Handle terminal resize by remeasuring content heights
const resizeHandler = () => {
if (scrollRef.current && stdout?.isTTY && !process.env.CI) {
scrollRef.current.remeasure();
}
};

// Listen for resize events on the process
process.on("resize", resizeHandler);
return () => {
process.off("resize", resizeHandler);
};
}, [stdout, scrollRef]);

/**
* Add a new message bubble to the list.
* @param {string} role - Message role: "user", "assistant", or "system"
* @param {string} content - Initial message content
* @param {Object} [options] - Additional options
* @param {string} [options.assistantName] - Name to display for assistant
* @param {string} [options.time] - Timestamp string
* @returns {string} The ID of the newly created bubble
*/
const addMessage = useCallback((role, content, options = {}) => {
const id = randomUUID();
const newBubble = {
id,
role,
content: content || "",
assistantName: options.assistantName || "Assistant",
time: options.time || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
};

setBubbles((prev) => {
const next = new Map(prev);
next.set(id, newBubble);
return next;
});
return id;
}, []);

/**
* Update an existing message bubble by ID.
* @param {string} id - The bubble ID to update
* @param {Object} updates - State updates to apply
* @param {string} [updates.content] - New content text
* @param {boolean} [updates.streaming] - Streaming state
* @param {string} [updates.toolCallDisplay] - Tool call display text
* @param {string} [updates.reasoningContent] - Reasoning/thinking content
* @param {Object} [updates.activeToolCall] - Active tool call {name: string}
*/
const updateMessage = useCallback((id, updates) => {
const bubbleRef = bubbleRefs.current[id];
if (bubbleRef && bubbleRef.update) {
bubbleRef.update(updates);
}

// Also update the bubble data in state for rendering consistency
setBubbles((prev) => {
const existing = prev.get(id);
if (!existing) return prev;
const next = new Map(prev);
next.set(id, { ...existing, ...updates });
return next;
});
}, []);

/**
* Clear all message bubbles from the list.
*/
const clear = useCallback(() => {
setBubbles(new Map());
bubbleRefs.current = {};
}, []);

// Expose imperative methods via ref
useImperativeHandle(ref, () => ({
addMessage,
updateMessage,
clear,
}), [addMessage, updateMessage, clear]);

// Detect manual scroll-up: when user scrolls away from bottom,
// suppress auto-scroll until they return to bottom or streaming completes.
useEffect(() => {
if (!scrollRef.current) return;

const checkScrollPosition = () => {
if (!scrollRef.current) return;
const maxScroll = scrollRef.current.getMaxScrollOffset?.() || 0;
const currentScroll = scrollRef.current.getScrollOffset?.() || 0;
const atBottom = maxScroll - currentScroll < 2; // 2 char tolerance

if (!atBottom) {
isUserScrollingRef.current = true;
} else {
isUserScrollingRef.current = false;
}
};

checkScrollPosition();
}, [bubbles, scrollRef]);

// Auto-scroll to bottom on new content
useEffect(() => {
if (!scrollRef.current) return;

const bubbleArr = Array.from(bubbles.values());
const lastBubble = bubbleArr[bubbleArr.length - 1];
const isStreaming = lastBubble?.streaming === true;
const streamingContentLen = isStreaming ? (lastBubble?.content || "").length : 0;
const contentHash = bubbleArr.length + streamingContentLen;

const wasScrolling =
bubbleArr.length > previousMessageCount.current ||
(isStreaming && contentHash !== previousContentHashRef.current);

if (!wasScrolling) return;

// If user manually scrolled up, suppress auto-scroll
if (isUserScrollingRef.current && !isStreaming) return;

const now = Date.now();
const timeSinceLastScroll = now - lastScrollTimeRef.current;

// Throttle scroll-to-bottom during active streaming (100ms interval)
if (isStreaming && timeSinceLastScroll < SCROLL_THROTTLE_MS) {
previousContentHashRef.current = contentHash;
return;
}

const scrollHandle = () => {
if (scrollRef.current) {
scrollRef.current.scrollToBottom();
previousMessageCount.current = bubbleArr.length;
lastScrollTimeRef.current = Date.now();
}
};
previousContentHashRef.current = contentHash;
const timer = setTimeout(scrollHandle, 0);
return () => clearTimeout(timer);
}, [bubbles]);

// Windowing: only render the last MAX_RENDER_MESSAGES bubbles
const bubbleArr = Array.from(bubbles.values());
const startIdx = Math.max(0, bubbleArr.length - MAX_RENDER_MESSAGES);
const visibleBubbles = bubbleArr.slice(startIdx);

return React.createElement(
Box,
{ key: "message-list", flexDirection: "column", flexGrow: 1 },
React.createElement(
ScrollView,
{ ref: scrollRef, key: "scroll", focus: false, height: height || 1 },
visibleBubbles.map((bubble) =>
React.createElement(MessageBubble, {
key: bubble.id,
ref: (el) => {
if (el) bubbleRefs.current[bubble.id] = el;
},
role: bubble.role,
content: bubble.content,
assistantName: bubble.assistantName,
time: bubble.time,
}),
),
),
);
});

export default MessageList;