Skip to content

feat: replace ref-based messages with component-based message bubbles #541

Description

@avoidwork

Summary

Replace the ref-based message array architecture in the TUI with a component-based system where each message is a standalone MessageBubble component managing its own state.

Motivation

The current architecture uses messagesRef (mutable array) + forceRender (useState counter) + array spreading + useEffect scroll-to-bottom. This fights React's model and causes:

  • Memory leaks from constant array allocation during streaming
  • Unnecessary re-renders of unchanged messages
  • Complex scroll management that doesn't work reliably
  • User messages don't appear until the assistant message streams in

The root problem: mutating a ref does not trigger React re-renders, so we work around it with forceRender and array spreading, which creates new references on every render and compounds the issue.

Proposed Solution

Replace the ref-based message array with a component-based architecture where each message is a standalone MessageBubble component managing its own state.

New Components

MessageBubble (src/tui/messageBubble.js)

  • Manages its own state: content, streaming, activeToolCall, toolCallDisplay, reasoningContent
  • Receives initial props from parent, then updates itself
  • Exposes an update() method for streaming events to call directly
  • Memoized rendering only re-renders when its own state changes

MessageList (src/tui/messageList.js)

  • Manages an array of MessageBubble component instances
  • Provides addMessage(), clear(), updateMessage() methods
  • Renders bubbles in a ScrollView
  • Handles MAX_RENDER_MESSAGES windowing

ConversationPanel (updated)

  • Simplified container just renders MessageList
  • No message data, no scroll logic
  • Delegates everything to MessageList

State Flow

App to MessageList.addMessage() creates MessageBubble instance
Streaming handler to MessageList.updateMessage(id, chunk) bubble updates itself
forceRender removed entirely
messagesRef removed entirely

Key Design Decisions

  1. Each bubble is a React component instance with its own useState and can update independently.
  2. MessageList holds component instances not data. The list is a React element array, not a data array.
  3. Streaming handler calls methods on bubbles not mutations on a shared array.
  4. No array spreading bubbles are added via React state, not array operations.
  5. Scroll management MessageList handles scroll-to-bottom internally via its own ref.

Implementation Steps

  1. Create MessageBubble Component with useState for content, streaming, toolCallDisplay, etc. Exposes update() method.
  2. Create MessageList Component managing array of MessageBubble instances with addMessage, updateMessage, clear methods.
  3. Update ConversationPanel to simplify to just render MessageList.
  4. Update App to remove messagesRef, forceRender, renderCount. Use messageListRef for MessageList.
  5. Update Tests to use new component API.

Alternatives Considered

  • useEffect with scroll-to-bottom on message change requires array spreading to trigger the effect, which allocates a new array on every render. Still fights React model.
  • ControlledScrollView with scrollOffset state adds state management complexity. Still requires array spreading.
  • Keep ref-based approach with optimizations. The current approach is fundamentally at odds with React rendering model. Optimizations are band-aids on a structural problem.

Additional Context

A detailed plan file exists at memory/plan-message-bubbles.md with full architecture details, risk assessment, and success criteria.


Audit Findings

  • src/tui/app.js:38messagesRef = useRef([]) is the source of truth for messages. Needs to be replaced with a MessageList instance.
  • src/tui/app.js:39renderCount, forceRender is the workaround for ref mutations not triggering re-renders. Will be removed entirely.
  • src/tui/app.js:47scrollRef = useRef(null) is passed to ConversationPanel. Will be removed; scroll management moves into MessageList.
  • src/tui/conversationPanel.js — Currently 7579 bytes with scroll logic, refs, and message rendering. Will be simplified to a thin wrapper around MessageList.
  • src/tui/messages.js — Contains getRoleLabel, formatMessage, isStreamingMessage, countMessageLines, getToolCallLines. These utilities will be reused by the new MessageBubble component.
  • No existing message bubble component — MessageBubble is currently just a React.memo wrapper inside conversationPanel.js. Will be extracted into its own file (src/tui/messageBubble.js) with internal state management.
  • Integration points — Streaming handler in app.js (createStreamingCallback) currently mutates messagesRef.current directly. Will need to call MessageList.updateMessage() instead.
  • Test files — tests/unit/tui/ will need updates to mock the new component API instead of mocking setMessages or messagesRef.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions