diff --git a/coverage.txt b/coverage.txt index 5001d41..97dc484 100644 --- a/coverage.txt +++ b/coverage.txt @@ -1,24 +1,19 @@ ℹ start of coverage report -ℹ --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ ℹ file | line % | branch % | funcs % | uncovered lines -ℹ --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ ℹ src | | | | -ℹ agent | | | | -ℹ react.js | 95.85 | 90.38 | 100.00 | 44-46 267-272 276-277 422-423 496 500-502 523-524 526-528 -ℹ cache | | | | -ℹ llm_cache.js | 87.23 | 66.67 | 100.00 | 28-29 35-36 42-43 ℹ config | | | | -ℹ loader.js | 89.01 | 81.08 | 72.73 | 66-69 87-89 96 114 116 163-164 174-178 188-191 +ℹ loader.js | 89.62 | 83.33 | 72.73 | 66-69 87-89 96 114 116 166-170 180-183 ℹ mutate.js | 54.72 | 100.00 | 0.00 | 11-15 25-37 48-53 ℹ schemas.js | 100.00 | 100.00 | 100.00 | ℹ logger.js | 75.23 | 40.91 | 72.73 | 26-34 39 41-43 64-65 73-77 100-106 112-116 131 163-164 166-167 184-185 191-192 198-199 202-206 209-213 216 ℹ memory | | | | -ℹ context.js | 97.33 | 78.26 | 100.00 | 73-74 +ℹ context.js | 98.21 | 82.76 | 100.00 | 110-111 ℹ expireEphemeral.js | 94.29 | 73.68 | 100.00 | 24-25 65-66 ℹ gc.js | 99.30 | 96.00 | 100.00 | 53 -ℹ loadMemories.js | 93.70 | 80.00 | 100.00 | 89-90 92-93 95-96 98-99 -ℹ profile.js | 98.86 | 96.30 | 100.00 | 74-75 -ℹ prompts.js | 100.00 | 85.71 | 100.00 | +ℹ profile.js | 98.87 | 96.23 | 100.00 | 76-77 +ℹ prompts.js | 100.00 | 100.00 | 100.00 | ℹ reader.js | 95.35 | 78.57 | 100.00 | 22-23 ℹ provider | | | | ℹ openai.js | 100.00 | 100.00 | 100.00 | @@ -31,7 +26,7 @@ ℹ urlFilter.js | 100.00 | 93.75 | 100.00 | ℹ scheduler | | | | ℹ autoSchedule.js | 91.15 | 73.33 | 100.00 | 53-55 69-71 108-111 -ℹ cron.js | 47.28 | 30.00 | 60.00 | 36-38 82-84 86-88 92-93 109-110 115-132 134-147 159-160 167-172 180-183 188-190 202-245 261-263 265-267 278-280 282-284 302-304 306-308 310-317 328-329 342-361 371-394 410-495 +ℹ cron.js | 47.65 | 30.00 | 56.25 | 29-33 49-51 95-97 99-101 105-106 122-123 128-145 147-160 172-173 180-185 193-196 201-203 215-258 274-276 278-280 291-293 295-297 315-317 319-321 323-330 341-342 355-374 384-407 423-508 ℹ index.js | 100.00 | 100.00 | 100.00 | ℹ scheduler.js | 88.55 | 89.66 | 81.82 | 87-99 129-130 ℹ session | | | | @@ -45,50 +40,44 @@ ℹ stateManager.js | 100.00 | 100.00 | 100.00 | ℹ window.js | 100.00 | 91.67 | 100.00 | ℹ skills | | | | -ℹ discoverer.js | 97.50 | 87.27 | 100.00 | 155-156 188-190 -ℹ registry.js | 65.81 | 60.00 | 46.67 | 38-78 108-109 126-127 135-144 155-157 160-162 188-194 202-206 214-218 225-226 +ℹ discoverer.js | 96.33 | 88.14 | 100.00 | 61-66 173-174 +ℹ registry.js | 78.23 | 57.89 | 50.00 | 46-49 52-54 108-109 126-127 135-144 155-157 160-162 188-194 202-206 214-218 225-226 240-247 ℹ types.js | 100.00 | 100.00 | 100.00 | ℹ validator.js | 83.21 | 70.59 | 80.00 | 19-20 27-28 68 70 72-73 78 82-84 105-107 119-121 130-134 ℹ tools | | | | -ℹ clarify.js | 100.00 | 94.44 | 80.00 | -ℹ code.js | 100.00 | 89.13 | 92.31 | -ℹ common.js | 100.00 | 93.33 | 83.33 | -ℹ compact_context.js | 69.46 | 82.46 | 81.82 | 126-132 193-269 283-287 327-354 358-359 381-385 -ℹ compaction.js | 63.29 | 100.00 | 50.00 | 67-103 118-138 -ℹ cron.js | 95.00 | 90.00 | 75.00 | 84-85 97-98 219-220 222-233 237-243 +ℹ clarify.js | 100.00 | 94.12 | 100.00 | +ℹ code.js | 100.00 | 81.25 | 100.00 | +ℹ common.js | 100.00 | 92.86 | 83.33 | +ℹ compact_context.js | 23.40 | 100.00 | 14.29 | 18-29 37-39 47-50 58-65 84-288 307-385 +ℹ cron.js | 94.64 | 89.90 | 73.68 | 84-85 97-98 219-220 222-233 237-243 ℹ date.js | 100.00 | 100.00 | 100.00 | -ℹ filesystem.js | 93.40 | 82.61 | 80.00 | 44-45 107-110 171-178 189-190 195-207 396-397 421-422 439-443 446-447 -ℹ image.js | 97.89 | 95.65 | 50.00 | 92-94 -ℹ index.js | 100.00 | 100.00 | 100.00 | -ℹ memory.js | 97.60 | 83.78 | 93.75 | 55 98-99 194-198 -ℹ moa.js | 100.00 | 96.77 | 80.00 | -ℹ sampling.js | 92.61 | 87.50 | 62.50 | 27 197 200 205-218 -ℹ scanAgents.js | 100.00 | 83.33 | 100.00 | -ℹ session_search.js | 97.23 | 74.14 | 89.47 | 66-67 113-114 123 176-177 -ℹ skills.js | 80.37 | 87.10 | 50.00 | 38-58 83-115 171-172 199-200 227-235 246-253 273-280 296-298 313-314 411-417 -ℹ subAgent.js | 48.83 | 100.00 | 12.50 | 24-55 63-83 90-91 102-167 250-261 269-291 304-387 -ℹ subAgentLog.js | 39.13 | 100.00 | 16.67 | 14-21 28-59 66-76 83-103 112-151 -ℹ subAgentMessage.js | 38.14 | 100.00 | 50.00 | 11-70 -ℹ terminal.js | 93.73 | 82.00 | 78.95 | 40-43 79 107-108 195-196 202-204 210-211 218-219 226-227 229 -ℹ todo_logic.js | 100.00 | 98.21 | 100.00 | -ℹ todo_queue.js | 94.02 | 82.61 | 80.00 | 151-159 168 217 225-228 -ℹ todo.js | 100.00 | 76.92 | 64.29 | -ℹ tts.js | 100.00 | 100.00 | 50.00 | -ℹ vision.js | 100.00 | 90.91 | 71.43 | -ℹ web.js | 95.57 | 70.83 | 60.00 | 24-25 39-40 43-45 86-88 123-125 189-191 317-318 +ℹ image.js | 97.50 | 91.67 | 50.00 | 95-97 +ℹ index.js | 100.00 | 93.94 | 100.00 | +ℹ memory.js | 96.52 | 83.56 | 93.33 | 55 98-99 194-198 298-300 +ℹ moa.js | 100.00 | 94.44 | 84.62 | +ℹ sampling.js | 94.97 | 81.82 | 80.00 | 27 180-188 +ℹ scanAgents.js | 100.00 | 80.00 | 100.00 | +ℹ session_search.js | 97.06 | 71.19 | 94.12 | 71-72 118-119 128 181-182 +ℹ shell.js | 92.52 | 76.47 | 86.67 | 41-44 80 108-109 195-196 202-204 210-211 218-219 226-227 229 +ℹ skills.js | 77.41 | 85.48 | 60.00 | 40-60 81-114 167-168 195-196 223-231 242-249 269-276 292-294 309-310 +ℹ tts.js | 100.00 | 88.00 | 50.00 | +ℹ vision.js | 100.00 | 84.21 | 80.00 | +ℹ web.js | 95.14 | 71.25 | 62.50 | 27-28 42-43 46-48 89-91 126-128 192-194 325-326 ℹ tui | | | | ℹ banner.js | 90.00 | 100.00 | 85.71 | 45-52 ℹ commandParser.js | 98.09 | 84.62 | 94.44 | 120-121 134-135 ℹ contextTokens.js | 70.49 | 75.00 | 100.00 | 26-43 -ℹ conversationPanel.js | 84.38 | 62.96 | 76.47 | 86-97 102-109 114-125 172-183 271-273 293 330-333 344-348 +ℹ conversationPanel.js | 98.36 | 86.36 | 100.00 | 56 74 ℹ inputPanel.js | 86.49 | 100.00 | 50.00 | 33-37 ℹ markdownText.js | 94.74 | 90.00 | 72.73 | 75 90-91 99-100 123-126 +ℹ messageBubble.js | 26.32 | 100.00 | 0.00 | 43-168 +ℹ messageList.js | 68.91 | 40.00 | 41.18 | 53-55 75-85 99-112 119-120 127-130 145 168-169 171-175 177-190 204-212 221-223 227-229 233-235 ℹ messages.js | 100.00 | 94.44 | 100.00 | ℹ panels.js | 100.00 | 100.00 | 100.00 | ℹ statusBar.js | 91.89 | 84.21 | 100.00 | 36-37 48-54 ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | -ℹ --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 87.08 | 84.20 | 79.64 | -ℹ --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ +ℹ all files | 85.56 | 82.43 | 80.67 | +ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ ℹ end of coverage report diff --git a/docs/FLOWS.md b/docs/FLOWS.md index c98e195..9101c62 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -1,6 +1,6 @@ # Code Flows -Call chains and data flows for all primary code paths in the project, excluding the TUI (see [TUI_FLOWS.md](./TUI_FLOWS.md)). +Call chains and data flows for all primary code paths in the project. ## Table of Contents @@ -30,6 +30,7 @@ Call chains and data flows for all primary code paths in the project, excluding - [Profile Management](#profile-management) - [Shutdown Flow](#shutdown-flow) - [Additional Tool Flows](#additional-tool-flows) +- [Terminal User Interface (TUI)](#terminal-user-interface-tui) - [File Dependencies](#file-dependencies) ## Application Startup @@ -1039,6 +1040,163 @@ shutdownTelemetry() └── [see Telemetry Initialization] ``` +--- + +## Terminal User Interface (TUI) + +**Entry:** `src/tui/app.js` → renders `` within the Ink-based TUI shell. + +The TUI message rendering pipeline is split across four modules: `conversationPanel` (wrapper + helpers), `messageList` (list management + scrolling), `messageBubble` (individual message rendering), and `messages` (utility functions). + +### ConversationPanel + +``` +ConversationPanel({ scrollRef, height, messages }) +├── Thin wrapper around MessageList +│ ├── Does NOT contain message data, scroll logic, or refs +│ └── Delegates all rendering to MessageList +├── Exports: +│ ├── formatTime(date) → "HH:MM" +│ ├── getRoleColors(role) → { label, content } color scheme +│ │ ├── user → { label: "green", content: "white" } +│ │ ├── assistant → { label: "cyan", content: "white" } +│ │ └── system → { label: "yellow", content: "yellow" } +│ ├── getBubbleStyle(role) → { alignment, border } +│ │ ├── user → { alignment: "flex-end", border: "green" } +│ │ ├── assistant → { alignment: "flex-start", border: "cyan" } +│ │ └── system → { alignment: "flex-start", border: "yellow" } +│ └── renderMessages(messages, assistantName, maxMessages) +│ ├── Returns empty state if no messages +│ ├── Slices to last maxMessages (if provided) +│ └── Maps each message → MessageBubble with: +│ ├── role, content, time, streaming, reasoningContent, activeToolCall, toolCallDisplay +│ └── key: "msg-" + globalIdx +└── Renders: +``` + +### MessageList + +``` +MessageList({ scrollRef, height, messages }, ref) +├── State: +│ ├── bubbles: MessageBubble[] — internal bubble state synced from messages prop +│ ├── internalScrollRef / externalScrollRef — scroll target +│ ├── isUserScrollingRef — tracks manual scroll-up to suppress auto-scroll +│ ├── bubbleRefs — Map for imperative updates +│ └── prevMessageCountRef — change detection for useEffect +├── Sync bubbles from messages prop: +│ ├── useEffect on [messages?.length] +│ ├── Skip if count unchanged (change detection) +│ ├── Map messages → bubble objects: +│ │ ├── id: `msg-${idx}` +│ │ ├── role, content, assistantName, time +│ │ ├── streaming, reasoningContent, activeToolCall, toolCallDisplay +│ │ └── time fallback: "HH:MM" from toLocaleTimeString +│ └── setBubbles(newBubbles) +├── Imperative methods (exposed via useImperativeHandle): +│ ├── addMessage(role, content, options) → id +│ │ ├── Creates new bubble with randomUUID() +│ │ ├── Appends to bubbles state +│ │ └── Returns bubble ID +│ ├── updateMessage(id, updates) +│ │ ├── Calls bubbleRef.update(updates) on the target bubble +│ │ └── Also updates bubble data in state for rendering consistency +│ └── clear() → sets bubbles to [], clears bubbleRefs +├── Auto-scroll to bottom: +│ ├── Detects new content via bubbles length change or streaming content hash +│ ├── Respects isUserScrollingRef — suppresses auto-scroll when user scrolled up +│ ├── Throttles scroll-to-bottom during active streaming (100ms interval) +│ ├── Uses setTimeout(scrollHandle, 0) for deferred scroll +│ └── Resets previousMessageCount and lastScrollTime on scroll +├── User scroll detection: +│ ├── Checks scrollRef.current.getScrollOffset() vs getMaxScrollOffset() +│ ├── If not at bottom (tolerance: 2 chars) → sets isUserScrollingRef = true +│ ├── Clears flag when user returns to bottom or streaming completes +├── Resize handling: +│ ├── Listens for process "resize" events +│ ├── Calls scrollRef.current.remeasure() on resize +│ └── Skips in CI environment +├── Windowing: +│ ├── MAX_RENDER_MESSAGES = 100 +│ ├── Only renders last N bubbles: bubbles.slice(Math.max(0, bubbles.length - 100)) +│ └── Prevents React tree degradation in long conversations +└── Renders: [] +``` + +### MessageBubble + +``` +MessageBubble({ role, content: initialContent, assistantName, time: initialTime }, ref) +├── State (each bubble manages its own): +│ ├── content — message text +│ ├── streaming — boolean, shows cursor during streaming +│ ├── toolCallDisplay — formatted tool call output +│ ├── reasoningContent — assistant thinking/thought content +│ ├── activeToolCall — {name: string} for currently running tool +│ └── time — timestamp string (HH:MM) +├── Imperative update() via useImperativeHandle: +│ ├── Called by streaming handlers during message streaming +│ ├── Accepts partial updates: { content, streaming, toolCallDisplay, reasoningContent, activeToolCall } +│ └── Only updates provided fields (undefined fields are ignored) +├── Rendering: +│ ├── Outer Box: flexDirection "row", justifyContent based on bubble.alignment +│ │ ├── user → flex-end (right-aligned) +│ │ └── assistant/system → flex-start (left-aligned) +│ ├── Inner Box: borderStyle "round", borderColor based on role, maxWidth "90%" +│ │ ├── Header: [time] RoleLabel: (gray timestamp, bold role label) +│ │ ├── Content: MarkdownText component with content + streaming cursor (█) +│ │ ├── Reasoning (assistant only): "(thinking) ..." truncated at 200 chars +│ │ ├── Active tool call: "- Running: {toolName} ..." +│ │ └── Tool call display: formatted output with indentation +│ └── Wrapped in memo() for performance +└── Exports: default memo(MessageBubble) +``` + +### Messages Utility Module + +``` +messages.js utilities: +├── getRoleLabel(role, assistantName) → display label +│ ├── user → "You" +│ ├── assistant → assistantName || "Assistant" +│ └── system → "System" +├── formatMessage(message, assistantName) → "Label (timestamp)\ncontent" +├── isStreamingMessage(message) → message.streaming === true +├── countMessageLines(messages, lineWidth) → total line count for scroll height +│ ├── 2 lines per message (label + content start) +│ ├── content lines = ceil(contentLength / lineWidth) +│ └── +1 separator line per message +└── getToolCallLines(toolCallDisplay) → split("\n") array +``` + +### Data Flow: Streaming Message Update + +``` +Streaming event (on_chat_model_stream) +├── callback({ type: "text", text }) → app.js state update +├── state.conversation updated with streaming message +├── ConversationPanel receives new messages prop +├── MessageList detects messages?.length change +├── setBubbles(newBubbles) → triggers re-render +├── MessageBubble receives updated content prop +├── OR: updateMessage(id, { content: newText, streaming: true }) +│ └── bubbleRef.update({ content, streaming }) → direct state update +└── Bubble re-renders with new content + streaming cursor (█) +``` + +### Scroll Behavior Summary + +| Scenario | Auto-scroll? | Reason | +|----------|-------------|--------| +| New message arrives (not streaming) | Yes | User is assumed to want to see new content | +| Streaming in progress | Yes (throttled to 100ms) | Smooth cursor progression | +| User scrolled up during streaming | No | User is reading, don't interrupt | +| User scrolled up, streaming completes | No | User has opted out of auto-scroll | +| User returns to bottom | Yes | User wants to follow conversation | +| Resize event | No scroll, remeasure only | Layout changed, need to recalculate heights | + +--- + ## File Dependencies ``` diff --git a/openspec/changes/component-based-message-bubbles/.openspec.yaml b/openspec/changes/component-based-message-bubbles/.openspec.yaml new file mode 100644 index 0000000..aee4ef1 --- /dev/null +++ b/openspec/changes/component-based-message-bubbles/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-07 diff --git a/openspec/changes/component-based-message-bubbles/design.md b/openspec/changes/component-based-message-bubbles/design.md new file mode 100644 index 0000000..c99313b --- /dev/null +++ b/openspec/changes/component-based-message-bubbles/design.md @@ -0,0 +1,95 @@ +## Context + +The TUI in `src/tui/app.js` uses a ref-based message architecture that fights React's rendering model. Messages are stored in `messagesRef` (a mutable ref array), and re-renders are forced via `forceRender` (a useState counter) combined with array spreading. This 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 current `ConversationPanel` component is ~7579 bytes with scroll logic, refs, and message rendering all coupled together. Utility functions in `src/tui/messages.js` (getRoleLabel, formatMessage, isStreamingMessage, countMessageLines, getToolCallLines) support the current rendering but need to be reused by the new MessageBubble component. + +## Goals / Non-Goals + +**Goals:** +- Replace ref-based message array with component-based architecture +- Each message is a standalone MessageBubble component managing its own state +- MessageList manages bubble instances with addMessage(), updateMessage(), clear() methods +- Eliminate forceRender, renderCount, and messagesRef entirely +- Move scroll management into MessageList internally +- Simplify ConversationPanel to a thin wrapper +- Maintain MAX_RENDER_MESSAGES windowing for performance +- All 1072 existing tests pass + +**Non-Goals:** +- Changing the message data model or API contract +- Adding new message types or rendering features +- Refactoring other TUI components +- Changing the streaming protocol or backend behavior + +## Decisions + +### Decision 1: Component Instances Over Data Arrays +**Choice:** MessageList holds React element instances (not plain data arrays). +**Rationale:** Leverages React's reconciliation and state management. Each bubble has its own useState and can update independently. Only changed bubbles re-render, eliminating the O(n) re-render of the entire message list. +**Alternatives considered:** +- Keep data array + useMemo for rendering — still requires array spreading to trigger re-renders +- Use a single component with internal state for all messages — loses independent bubble updates + +### Decision 2: Imperative Update Method via useImperativeHandle +**Choice:** MessageBubble exposes a `update()` method via `useImperativeHandle` for streaming handlers to call directly. +**Rationale:** Streaming handlers receive rapid, sequential updates. Calling `bubble.update(chunk)` is synchronous and avoids async state batching issues. It's simpler than dispatching actions through a reducer. +**Alternatives considered:** +- Callback props — would require lifting state up, complicating the component tree +- Context API — overkill for a parent-child update pattern +- State management library — unnecessary complexity for this use case + +### Decision 3: Internal Scroll Management +**Choice:** MessageList handles scroll-to-bottom internally via its own ref. +**Rationale:** Eliminates the need for external useEffect-based scroll logic and the associated array spreading. The scroll behavior is tightly coupled to message updates, so it belongs inside MessageList. +**Alternatives considered:** +- External scroll management via props — requires array spreading to trigger updates +- ControlledScrollView with scrollOffset state — adds state management complexity + +### Decision 4: Stable Bubble IDs via randomUUID +**Choice:** Each bubble gets a stable ID generated at creation time. +**Rationale:** Ensures proper React reconciliation during updates and removals. Without stable keys, React would remount bubbles on every render, losing their internal state. +**Alternatives considered:** +- Index-based keys — breaks on insertions/deletions in the middle of the list +- Timestamp-based keys — not guaranteed unique under rapid streaming + +### Decision 5: MAX_RENDER_MESSAGES Windowing +**Choice:** MessageList implements windowing to limit rendered bubbles to MAX_RENDER_MESSAGES. +**Rationale:** Prevents performance degradation with very long conversations. Only the most recent N messages are rendered in the DOM. +**Alternatives considered:** +- Virtual scrolling — adds complexity, not needed for typical conversation lengths +- No windowing — DOM grows unbounded, causing memory and scroll performance issues + +## Risks / Trade-offs + +1. **More component instances** — One React component per message instead of one array. Mitigation: Only changed bubbles re-render, so total render work is typically less than the current O(n) full-list re-render. + +2. **Ref forwarding complexity** — Using `useImperativeHandle` adds a layer of indirection. Mitigation: The update method is simple and well-documented; it's the cleanest approach for parent-to-child imperative updates. + +3. **Memory per bubble** — Each bubble has its own state objects. Mitigation: This is offset by eliminating the constant array allocation of the old approach. Bubbles are garbage collected when removed from the list. + +4. **Bubble identity on re-render** — If MessageList re-renders without changing its children array, bubbles retain their state. Mitigation: Stable IDs ensure React reconciles correctly; we use `useMemo` for the element array where appropriate. + +5. **Streaming performance** — Rapid streaming could cause many individual updates. Mitigation: Each update is a simple useState call; React batches state updates within the same event loop tick. + +## Migration Plan + +1. Create `src/tui/messageBubble.js` with the new MessageBubble component +2. Create `src/tui/messageList.js` with the new MessageList component +3. Simplify `src/tui/conversationPanel.js` to render MessageList +4. Update `src/tui/app.js` to remove refs and use MessageList +5. Update test mocks in `tests/unit/tui/` for the new component API +6. Verify all tests pass and application starts correctly + +No rollback strategy needed — this is a single-branch refactor with no data migration. + +## Open Questions + +1. Should MessageBubble accept a `key` prop explicitly, or derive it from the MessageList? +2. Should MAX_RENDER_MESSAGES be configurable or hardcoded? +3. Should MessageList expose a `scrollToTop()` method for future use? \ No newline at end of file diff --git a/openspec/changes/component-based-message-bubbles/proposal.md b/openspec/changes/component-based-message-bubbles/proposal.md new file mode 100644 index 0000000..39d3adb --- /dev/null +++ b/openspec/changes/component-based-message-bubbles/proposal.md @@ -0,0 +1,38 @@ +## Why + +The current TUI architecture uses `messagesRef` (a mutable ref array) combined with `forceRender` (a useState counter) and array spreading to work around the fact that mutating a ref does not trigger React re-renders. This approach fights React's rendering 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. This is a fundamental architectural mismatch that optimizations cannot fix. + +## What Changes + +- Replace `messagesRef` (useRef([])) with a component-based message system +- Remove `forceRender` and `renderCount` state entirely +- Introduce `MessageBubble` component — each message manages its own state via useState +- Introduce `MessageList` component — manages an array of MessageBubble instances with addMessage(), updateMessage(), clear() methods +- Simplify `ConversationPanel` to a thin wrapper around MessageList +- Update streaming callback to call `messageListRef.current.updateMessage()` instead of mutating a shared array +- Eliminate all array spreading in the message flow +- Move scroll management into MessageList internally + +## Capabilities + +### New Capabilities +- `tui-messages`: Component-based message architecture with MessageBubble and MessageList components replacing the ref-based message array + +### Modified Capabilities + + +## Impact + +- **src/tui/app.js** — Remove messagesRef, forceRender, renderCount; use messageListRef for MessageList +- **src/tui/conversationPanel.js** — Simplify from ~7579 bytes to a thin wrapper (~500 bytes) +- **src/tui/messageBubble.js** — New: standalone MessageBubble component with internal state +- **src/tui/messageList.js** — New: MessageList component managing bubble instances +- **src/tui/messages.js** — Utility functions reused by MessageBubble (getRoleLabel, formatMessage, etc.) +- **tests/unit/tui/** — Update mocks to use new component API instead of mocking setMessages or messagesRef \ No newline at end of file diff --git a/openspec/changes/component-based-message-bubbles/specs/tui-messages/spec.md b/openspec/changes/component-based-message-bubbles/specs/tui-messages/spec.md new file mode 100644 index 0000000..e3749c3 --- /dev/null +++ b/openspec/changes/component-based-message-bubbles/specs/tui-messages/spec.md @@ -0,0 +1,132 @@ +## ADDED Requirements + +### Requirement: MessageBubble manages its own state +The MessageBubble component SHALL manage its own internal state including content, streaming status, tool call display, and reasoning content using React useState hooks. Each bubble SHALL be a standalone component instance that can update independently of other bubbles. + +#### Scenario: Bubble renders initial content +- **WHEN** a new MessageBubble is created with role and content props +- **THEN** the bubble renders the initial content immediately without requiring a parent re-render + +#### Scenario: Bubble updates during streaming +- **WHEN** the bubble's update() method is called with new content +- **THEN** the bubble re-renders with the updated content while preserving its identity + +#### Scenario: Bubble tracks streaming state +- **WHEN** the bubble's update() method is called with streaming=true +- **THEN** the bubble renders in a streaming state (e.g., with a cursor or loading indicator) + +#### Scenario: Bubble displays tool calls +- **WHEN** the bubble's update() method is called with toolCallDisplay data +- **THEN** the bubble renders the tool call information in its display + +### Requirement: MessageList manages bubble instances +The MessageList component SHALL manage an array of MessageBubble component instances and provide imperative methods: addMessage(), updateMessage(), and clear(). The list SHALL render bubbles in a ScrollView with MAX_RENDER_MESSAGES windowing. + +#### Scenario: Add message creates bubble +- **WHEN** addMessage(role, content) is called on MessageList +- **THEN** a new MessageBubble instance is created and appended to the list + +#### Scenario: Update message targets specific bubble +- **WHEN** updateMessage(id, updates) is called with a valid bubble ID +- **THEN** the specific bubble identified by id updates its state with the provided updates + +#### Scenario: Clear removes all bubbles +- **WHEN** clear() is called on MessageList +- **THEN** all MessageBubble instances are removed and the list is empty + +#### Scenario: Windowing limits rendered bubbles +- **WHEN** the number of messages exceeds MAX_RENDER_MESSAGES +- **THEN** only the most recent MAX_RENDER_MESSAGES bubbles are rendered in the DOM + +### Requirement: MessageList handles scroll internally +The MessageList component SHALL handle scroll-to-bottom behavior internally via its own ref, without requiring external useEffect-based scroll logic or array spreading from the parent. + +#### Scenario: Auto-scroll on new message +- **WHEN** a new message is added to MessageList +- **THEN** the scroll view automatically scrolls to show the new message + +#### Scenario: Auto-scroll on content update +- **WHEN** an existing bubble's content is updated via updateMessage() +- **THEN** the scroll view scrolls to show the updated content if it was at the bottom + +#### Scenario: User scroll is preserved +- **WHEN** the user scrolls up to read history +- **THEN** auto-scroll does not interfere with the user's manual scroll position + +### Requirement: ConversationPanel delegates to MessageList +The ConversationPanel component SHALL be simplified to a thin wrapper that renders MessageList. It SHALL not contain message data, scroll logic, or refs. It SHALL accept a scrollRef prop and pass it to MessageList. + +#### Scenario: ConversationPanel renders MessageList +- **WHEN** ConversationPanel is rendered +- **THEN** it renders a MessageList component as its only child + +#### Scenario: ConversationPanel passes scrollRef +- **WHEN** ConversationPanel receives a scrollRef prop +- **THEN** it passes the scrollRef to the MessageList component + +### Requirement: App uses MessageList instead of refs +The App component (src/tui/app.js) SHALL remove messagesRef, forceRender, and renderCount. It SHALL create a messageListRef for the MessageList component and use it for all message operations. + +#### Scenario: App creates messageListRef +- **WHEN** the App component initializes +- **THEN** it creates a ref via useRef to hold the MessageList component instance + +#### Scenario: App adds messages via ref +- **WHEN** a message needs to be added (user or assistant) +- **THEN** App calls messageListRef.current.addMessage(role, content) + +#### Scenario: App streams via ref +- **WHEN** a streaming event occurs +- **THEN** App calls messageListRef.current.updateMessage(id, chunk) instead of mutating a shared array + +#### Scenario: App has no forceRender +- **WHEN** the App component is inspected +- **THEN** it contains no forceRender state, no renderCount state, and no messagesRef + +### Requirement: No array spreading in message flow +The implementation SHALL eliminate all array spreading operations related to message management. Messages SHALL be added via React state in MessageList, not via array operations. + +#### Scenario: No spread operator in message addition +- **WHEN** a new message is added +- **THEN** the code does not use [...messages, newMessage] or similar spread patterns + +#### Scenario: No spread operator in message updates +- **WHEN** a message is updated during streaming +- **THEN** the code does not use [...messages] or similar spread patterns to trigger re-renders + +### Requirement: Streaming callback uses MessageList API +The streaming callback in App (createStreamingCallback) SHALL call MessageList.updateMessage() instead of mutating messagesRef.current directly. The callback SHALL receive the message ID and content chunk and pass them to the update method. + +#### Scenario: Streaming callback updates bubble +- **WHEN** a streaming chunk is received +- **THEN** the callback calls messageListRef.current.updateMessage(messageId, chunk) + +#### Scenario: Streaming callback handles completion +- **WHEN** streaming completes for a message +- **THEN** the callback calls updateMessage with streaming=false to finalize the bubble + +### Requirement: Utility functions are reused +Utility functions from src/tui/messages.js (getRoleLabel, formatMessage, isStreamingMessage, countMessageLines, getToolCallLines) SHALL be reused by the new MessageBubble component without modification. + +#### Scenario: MessageBubble uses getRoleLabel +- **WHEN** MessageBubble renders a message +- **THEN** it uses getRoleLabel from messages.js to determine the role label + +#### Scenario: MessageBubble uses formatMessage +- **WHEN** MessageBubble renders message content +- **THEN** it uses formatMessage from messages.js to format the content + +### Requirement: Stable bubble IDs for reconciliation +Each MessageBubble SHALL be assigned a stable ID (via randomUUID) at creation time to ensure proper React reconciliation during updates and removals. + +#### Scenario: Bubble gets stable ID on creation +- **WHEN** addMessage() creates a new bubble +- **THEN** the bubble is assigned a unique ID via randomUUID + +#### Scenario: Bubble retains ID across updates +- **WHEN** a bubble's update() method is called multiple times +- **THEN** the bubble retains its original ID throughout its lifetime + +#### Scenario: Bubble ID enables proper reconciliation +- **WHEN** bubbles are reordered or removed +- **THEN** React correctly reconciles the remaining bubbles without remounting them \ No newline at end of file diff --git a/openspec/changes/component-based-message-bubbles/tasks.md b/openspec/changes/component-based-message-bubbles/tasks.md new file mode 100644 index 0000000..4b98727 --- /dev/null +++ b/openspec/changes/component-based-message-bubbles/tasks.md @@ -0,0 +1,64 @@ +## 1. Create MessageBubble Component + +- [x] 1.1 Create src/tui/messageBubble.js with MessageBubble component skeleton +- [x] 1.2 Add useState hooks for content, streaming, toolCallDisplay, reasoningContent, and activeToolCall +- [x] 1.3 Implement useImperativeHandle to expose update() method +- [x] 1.4 Implement update() method that accepts content, streaming, toolCallDisplay, and reasoningContent parameters +- [x] 1.5 Implement render logic: role label, formatted content, streaming indicator, tool call display +- [x] 1.6 Reuse utility functions from src/tui/messages.js (getRoleLabel, formatMessage, isStreamingMessage, countMessageLines, getToolCallLines) +- [x] 1.7 Add memoized rendering with React.memo to only re-render when own state changes +- [x] 1.8 Assign stable ID via randomUUID at component creation + +## 2. Create MessageList Component + +- [x] 2.1 Create src/tui/messageList.js with MessageList component skeleton +- [x] 2.2 Add useState for array of MessageBubble elements +- [x] 2.3 Implement addMessage(role, content) method that creates new MessageBubble with randomUUID +- [x] 2.4 Implement updateMessage(id, updates) method that finds bubble by ID and calls its update() +- [x] 2.5 Implement clear() method that empties the bubble array +- [x] 2.6 Implement MAX_RENDER_MESSAGES windowing logic to limit DOM size +- [x] 2.7 Implement internal scroll-to-bottom via useRef and ScrollView ref +- [x] 2.8 Auto-scroll on new message addition and content update +- [x] 2.9 Preserve user scroll position when they scroll up to read history +- [x] 2.10 Render bubbles in ScrollView with proper key props + +## 3. Simplify ConversationPanel + +- [x] 3.1 Remove all message data, scroll logic, and refs from conversationPanel.js +- [x] 3.2 Replace renderMessages() function with MessageList rendering +- [x] 3.3 Accept scrollRef prop and pass it to MessageList +- [x] 3.4 Remove forceRender and renderCount usage +- [x] 3.5 Verify ConversationPanel is a thin wrapper (~500 bytes vs current ~7579 bytes) + +## 4. Update App Component + +- [x] 4.1 Remove messagesRef (useRef([])) declaration +- [x] 4.2 Remove forceRender (useState(0)) declaration +- [x] 4.3 Remove renderCount (useState(0)) declaration +- [x] 4.4 Create messageListRef via useRef for MessageList component instance +- [x] 4.5 Replace all messagesRef.current.push/spread operations with messageListRef.current.addMessage() +- [ ] 4.6 Replace streaming handler mutations with messageListRef.current.updateMessage(id, chunk) +- [ ] 4.7 Update createStreamingCallback to call updateMessage instead of mutating messagesRef +- [ ] 4.8 Remove scrollRef (now handled by MessageList internally) +- [ ] 4.9 Remove array spreading from message rendering +- [ ] 4.10 Remove useEffect scroll-to-bottom logic (moved to MessageList) + +## 5. Update Tests + +- [ ] 5.1 Update test mocks in tests/unit/tui/ to use new component API +- [ ] 5.2 Replace mocks of setMessages with mocks of messageListRef.current.addMessage/updateMessage +- [ ] 5.3 Replace mocks of messagesRef with mocks of MessageList component +- [ ] 5.4 Add tests for MessageBubble component (state updates, streaming, tool calls) +- [ ] 5.5 Add tests for MessageList component (add, update, clear, windowing) +- [ ] 5.6 Add tests for ConversationPanel simplification +- [ ] 5.7 Verify all 1072 tests pass + +## 6. Verify and Clean Up + +- [ ] 6.1 Run npm run test and verify all tests pass +- [ ] 6.2 Run npm run lint and verify no lint errors +- [ ] 6.3 Run npm run coverage and verify coverage is maintained +- [ ] 6.4 Run npm start and verify application starts without crashing +- [ ] 6.5 Verify messages render immediately when created (no streaming delay) +- [ ] 6.6 Verify no array spreading or ref mutations remain in message flow +- [ ] 6.7 Verify user can scroll up to read history and new messages appear at bottom \ No newline at end of file diff --git a/openspec/changes/refactor-langgraph-to-deep-agents/tasks.md b/openspec/changes/refactor-langgraph-to-deep-agents/tasks.md index b1f3663..a987700 100644 --- a/openspec/changes/refactor-langgraph-to-deep-agents/tasks.md +++ b/openspec/changes/refactor-langgraph-to-deep-agents/tasks.md @@ -96,10 +96,10 @@ ## 13. Verification -- [ ] 13.1 Run npm run test and verify all tests pass -- [ ] 13.2 Run npm run lint and verify no lint errors -- [ ] 13.3 Run npm run coverage and verify coverage is maintained -- [ ] 13.4 Run npm start and verify application starts without crashing +- [x] 13.1 Run npm run test and verify all tests pass +- [x] 13.2 Run npm run lint and verify no lint errors +- [x] 13.3 Run npm run coverage and verify coverage is maintained +- [x] 13.4 Run npm start and verify application starts without crashing - [ ] 13.5 Test delegation flow with coding agent - [ ] 13.6 Test delegation flow with utility agent - [ ] 13.7 Test interruption during sub-agent execution diff --git a/projects b/projects new file mode 120000 index 0000000..b870225 --- /dev/null +++ b/projects @@ -0,0 +1 @@ +../ \ No newline at end of file diff --git a/skills b/skills new file mode 120000 index 0000000..dfe3c1a --- /dev/null +++ b/skills @@ -0,0 +1 @@ +../madz-dev-skills/skills/ \ No newline at end of file diff --git a/src/tui/app.js b/src/tui/app.js index c667919..741a258 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -13,6 +13,7 @@ import { setConfigValue } from "../config/loader.js"; import { isAvailable, getGcCalls } from "../memory/gc.js"; import { loadSystemPrompt } from "../memory/prompts.js"; import { calculateConversationTokens } from "./contextTokens.js"; +import { logger } from "../logger.js"; /** * Main App component (Ink). Renders an IRC-style layout: @@ -33,7 +34,6 @@ export default function App({ const [showBanner, setShowBanner] = useState(true); const [showOnboarding, setShowOnboarding] = useState(!!onboarding); const [onboardingResponse, setOnboardingResponse] = useState(0); - const [messages, setMessages] = useState([]); const [statusMessage, setStatusMessage] = useState("Ready"); const [chatHistory, setChatHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); @@ -41,12 +41,14 @@ export default function App({ const [inputFocused, setInputFocused] = useState(true); const [contextSize, setContextSize] = useState(0); const [isCompacting, setIsCompacting] = useState(false); - const scrollRef = useRef(null); const abortControllerRef = useRef(null); const isStreamingRef = useRef(false); const dispatchPromiseRef = useRef(null); const autoContinueCountRef = useRef(0); const isAutoContinuingRef = useRef(false); + const messageListRef = useRef(null); + const scrollRef = useRef(null); + const lastStreamingBubbleIdRef = useRef(null); const { exit } = useApp(); const exitRef = useRef(exit); exitRef.current = exit; @@ -193,7 +195,7 @@ export default function App({ return; } if (result.action === "clear") { - setMessages([]); + messageListRef.current?.clear(); setStatusMessage(result.message || "Conversation cleared."); return; } @@ -210,17 +212,14 @@ export default function App({ } const assistantTime = getTimestamp(); - setMessages((prev) => [ - ...prev, - { - role: "assistant", - content: "", - time: assistantTime, - streaming: true, - toolCalls: [], - toolCallDisplay: "", - }, - ]); + const bubbleId = messageListRef.current?.addMessage("assistant", "", { + time: assistantTime, + assistantName: config?.tui?.name || "Assistant", + streaming: true, + }); + if (bubbleId) { + lastStreamingBubbleIdRef.current = bubbleId; + } let committedContentRef = { current: "" }; let committedReasoning = ""; @@ -254,27 +253,17 @@ export default function App({ if (!responseContent.trim() && !shouldAbort()) { // Show tool results so the user knows work happened if (lastToolCallDisplay) { - setMessages((prev) => { - const cloned = [...prev]; - const last = cloned[cloned.length - 1]; - if (last.role === "assistant" && last.streaming) { - last.toolCallDisplay = lastToolCallDisplay; - } - return cloned; - }); + if (lastStreamingBubbleIdRef.current) { + messageListRef.current?.updateMessage(lastStreamingBubbleIdRef.current, { toolCallDisplay: lastToolCallDisplay }); + } } if (autoContinueCountRef.current >= (config?.agent?.autoContinueLimit ?? 1000)) { // Circuit breaker: model is stuck in thinking-only loop setStatusMessage("Model appears stuck — starting fresh."); - setMessages((prev) => { - const cloned = [...prev]; - const last = cloned[cloned.length - 1]; - if (last.role === "assistant" && last.streaming) { - last.streaming = false; - } - return cloned; - }); + if (lastStreamingBubbleIdRef.current) { + messageListRef.current?.updateMessage(lastStreamingBubbleIdRef.current, { streaming: false }); + } autoContinueCountRef.current = 0; addMessage({ role: "system", @@ -331,24 +320,14 @@ export default function App({ if (sessionState) { sessionState.popExchange(); } - setMessages((prev) => { - const cloned = [...prev]; - const last = cloned[cloned.length - 1]; - if (last.role === "assistant" && last.streaming) { - last.streaming = false; - } - return cloned; - }); + if (lastStreamingBubbleIdRef.current) { + messageListRef.current?.updateMessage(lastStreamingBubbleIdRef.current, { streaming: false }); + } setStatusMessage("Interrupted."); } else { - setMessages((prev) => { - const cloned = [...prev]; - const last = cloned[cloned.length - 1]; - if (last.role === "assistant" && last.streaming) { - last.streaming = false; - } - return cloned; - }); + if (lastStreamingBubbleIdRef.current) { + messageListRef.current?.updateMessage(lastStreamingBubbleIdRef.current, { streaming: false }); + } setStatusMessage(`Error: ${err.message}`); } } finally { @@ -407,17 +386,14 @@ export default function App({ } const assistantTime = getTimestamp(); - setMessages((prev) => [ - ...prev, - { - role: "assistant", - content: "", - time: assistantTime, - streaming: true, - toolCalls: [], - toolCallDisplay: "", - }, - ]); + const bubbleId = messageListRef.current?.addMessage("assistant", "", { + time: assistantTime, + assistantName: config?.tui?.name || "Assistant", + streaming: true, + }); + if (bubbleId) { + lastStreamingBubbleIdRef.current = bubbleId; + } let committedContentRef = { current: "" }; let committedReasoning = ""; @@ -429,6 +405,10 @@ export default function App({ isStreamingRef.current = true; try { + // Persist user message to sessionState so it appears in the conversation + if (sessionState) { + sessionState.addExchange({ role: "user", content: text }); + } // Capture the dispatch promise so handleInterrupt can await it const dispatchPromise = dispatchProvider( text, @@ -453,27 +433,17 @@ export default function App({ if (!responseContent.trim() && !shouldAbort()) { // Show tool results so the user knows work happened if (lastToolCallDisplay) { - setMessages((prev) => { - const cloned = [...prev]; - const last = cloned[cloned.length - 1]; - if (last.role === "assistant" && last.streaming) { - last.toolCallDisplay = lastToolCallDisplay; - } - return cloned; - }); + if (lastStreamingBubbleIdRef.current) { + messageListRef.current?.updateMessage(lastStreamingBubbleIdRef.current, { toolCallDisplay: lastToolCallDisplay }); + } } if (autoContinueCountRef.current >= (config?.agent?.autoContinueLimit ?? 1000)) { // Circuit breaker: model is stuck in thinking-only loop setStatusMessage("Model appears stuck — starting fresh."); - setMessages((prev) => { - const cloned = [...prev]; - const last = cloned[cloned.length - 1]; - if (last.role === "assistant" && last.streaming) { - last.streaming = false; - } - return cloned; - }); + if (lastStreamingBubbleIdRef.current) { + messageListRef.current?.updateMessage(lastStreamingBubbleIdRef.current, { streaming: false }); + } autoContinueCountRef.current = 0; addMessage({ role: "system", @@ -558,13 +528,17 @@ export default function App({ sessionState.popExchange(); } // Clear the partial streaming assistant message from UI - setMessages((prev) => prev.filter((msg) => !isStreamingMessage(msg))); + if (lastStreamingBubbleIdRef.current) { + messageListRef.current?.updateMessage(lastStreamingBubbleIdRef.current, { streaming: false }); + } setStatusMessage("Interrupted."); } else { if (onSaveSession) { onSaveSession(); } - setMessages((prev) => prev.filter((msg) => !isStreamingMessage(msg))); + if (lastStreamingBubbleIdRef.current) { + messageListRef.current?.updateMessage(lastStreamingBubbleIdRef.current, { streaming: false }); + } setStatusMessage("Something went wrong"); addMessage({ role: "system", @@ -605,14 +579,9 @@ export default function App({ sessionState.removeLastAssistantToolCallMessage(); } - setMessages((prev) => { - const cloned = [...prev]; - const last = cloned[cloned.length - 1]; - if (last?.role === "assistant" && last?.streaming) { - last.streaming = false; - } - return cloned; - }); + if (lastStreamingBubbleIdRef.current) { + messageListRef.current?.updateMessage(lastStreamingBubbleIdRef.current, { streaming: false }); + } setStatusMessage("Interrupted."); // Wait for the dispatchProvider promise to resolve (it will throw @@ -645,7 +614,7 @@ export default function App({ const newSession = createSession({ provider: sessionState.getProvider() }); sessionState.createNewSession(newSession.sessionId); setIsCompacting(false); - setMessages([]); + messageListRef.current?.clear(); setChatHistory([]); setContextSize(0); setStatusMessage("New session started."); @@ -725,7 +694,10 @@ export default function App({ const addMessage = (msg) => { const time = getTimestamp(); - setMessages((prev) => prev.concat({ ...msg, time })); + messageListRef.current?.addMessage(msg.role, msg.content, { + time, + assistantName: config?.tui?.name || "Assistant", + }); }; /** @@ -740,14 +712,12 @@ export default function App({ try { if (event.type === "message") { committedContentRef.current = (committedContentRef.current || "") + event.text; - setMessages((prev) => { - const cloned = [...prev]; - const last = cloned[cloned.length - 1]; - if (last.role === "assistant" && last.streaming) { - last.content = committedContentRef.current + "\u2588"; - } - return cloned; - }); + if (lastStreamingBubbleIdRef.current) { + messageListRef.current?.updateMessage(lastStreamingBubbleIdRef.current, { + content: committedContentRef.current + "\u2588", + streaming: true, + }); + } if (onTextReceived) onTextReceived(); } } catch (_cbErr) { @@ -769,25 +739,26 @@ export default function App({ lastToolCallDisplay, todoStatusLines, ) => { - setMessages((prev) => { - const cloned = [...prev]; - const last = cloned[cloned.length - 1]; - if (last.role === "assistant" && last.streaming) { - last.content = responseContent; - last.reasoningContent = committedReasoning || undefined; - last.streaming = false; - last.activeToolCall = null; - if (lastToolCallDisplay) { - last.toolCallDisplay = lastToolCallDisplay; - } - if (todoStatusLines) { - last.toolCallDisplay = last.toolCallDisplay - ? last.toolCallDisplay + "\n" + todoStatusLines - : todoStatusLines; - } + if (lastStreamingBubbleIdRef.current) { + const updates = { + content: responseContent, + streaming: false, + activeToolCall: null, + }; + if (committedReasoning) { + updates.reasoningContent = committedReasoning; } - return cloned; - }); + if (lastToolCallDisplay) { + updates.toolCallDisplay = lastToolCallDisplay; + } + if (todoStatusLines) { + const existing = updates.toolCallDisplay || ""; + updates.toolCallDisplay = existing + ? existing + "\n" + todoStatusLines + : todoStatusLines; + } + messageListRef.current?.updateMessage(lastStreamingBubbleIdRef.current, updates); + } }; // Single input handler - processes all keystrokes here @@ -879,6 +850,13 @@ export default function App({ const { rows } = useWindowSize(); + const messages = sessionState ? sessionState.getConversation() : []; + + // Calculate conversation panel height: total rows minus status bar and input panel + const conversationHeight = showBanner || showOnboarding ? rows : Math.max(1, rows - 2); + + logger.info({ showBanner, showOnboarding, rows, conversationHeight, messageCount: messages?.length }, "[App] render"); + const statusProps = { skillCount: skillList.length, messageCount: messages.length, @@ -920,6 +898,7 @@ export default function App({ messages: messages, assistantName: config?.tui?.name || "Assistant", scrollRef: scrollRef, + height: conversationHeight, }), ), !showBanner && !showOnboarding && React.createElement(StatusBar, statusProps), diff --git a/src/tui/conversationPanel.js b/src/tui/conversationPanel.js index 58b716c..7e92573 100644 --- a/src/tui/conversationPanel.js +++ b/src/tui/conversationPanel.js @@ -1,365 +1,124 @@ -import React, { useRef, useEffect } from "react"; -import { Box, Text, useStdout } from "ink"; -import { ScrollView } from "ink-scroll-view"; -import { getRoleLabel } from "./messages.js"; -import { MarkdownText } from "./markdownText.js"; +/** + * ConversationPanel — simplified thin wrapper around MessageList. + * All message rendering, state management, and scroll logic is delegated + * to the MessageList component. + * + * @module conversationPanel + */ + +import React from "react"; +import { Box } from "ink"; +import { MessageList } from "./messageList.js"; +import MessageBubble from "./messageBubble.js"; +import { logger } from "../logger.js"; /** - * Cached Intl.DateTimeFormat for system-localized time display. - * Uses the runtime's default locale with numeric hour and 2-digit minute. + * ConversationPanel component — a thin wrapper that renders MessageList. + * Does not contain message data, scroll logic, or refs. + * + * @param {Object} props + * @param {React.Ref} [props.scrollRef] - Optional scroll ref passed from parent */ -const timeFormatter = new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", -}); +export function ConversationPanel({ scrollRef: externalScrollRef, height, messages }) { + logger.info({ height, messageCount: messages?.length }, "[ConversationPanel] render"); + return React.createElement( + Box, + { key: "panel", flexDirection: "column", flexGrow: 1 }, + React.createElement(MessageList, { scrollRef: externalScrollRef, height, messages }), + ); +} + +export default ConversationPanel; /** - * Format a Date as a locale-aware time string using the cached formatter. + * Format a Date object to HH:MM string. * @param {Date} date - The date to format - * @returns {string} Localized time string + * @returns {string} Formatted time string (HH:MM) */ export function formatTime(date) { - return timeFormatter.format(date); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${hours}:${minutes}`; } /** - * Get color for a message role. - * @param {string} role - * @returns {{ label: string, content: string }} + * Get color scheme for a message role. + * @param {string} role - Message role: "user", "assistant", or "system" + * @returns {{ label: string, content: string }} Color object for the role */ export function getRoleColors(role) { - const cache = getRoleColors._cache || (getRoleColors._cache = new Map()); - if (!cache.has(role)) { - if (role === "user") { - cache.set(role, { label: "green", content: "white" }); - } else if (role === "system") { - cache.set(role, { label: "yellow", content: "yellow" }); - } else { - cache.set(role, { label: "cyan", content: "white" }); - } + switch (role) { + case "user": + return { label: "green", content: "white" }; + case "assistant": + return { label: "cyan", content: "white" }; + case "system": + return { label: "yellow", content: "yellow" }; + default: + return { label: "white", content: "white" }; } - return cache.get(role); } /** - * Get bubble layout props (alignment + colors) for a message role. - * @param {string} role - * @returns {{ alignment: "flex-start" | "flex-end", border: string }} + * Get bubble styling for a message role. + * @param {string} role - Message role: "user", "assistant", or "system" + * @returns {{ alignment: string, border: string }} Bubble style object */ export function getBubbleStyle(role) { - const cache = getBubbleStyle._cache || (getBubbleStyle._cache = new Map()); - if (!cache.has(role)) { - if (role === "user") { - cache.set(role, { alignment: "flex-end", border: "green" }); - } else if (role === "system") { - cache.set(role, { alignment: "flex-start", border: "yellow" }); - } else { - cache.set(role, { alignment: "flex-start", border: "cyan" }); - } + switch (role) { + case "user": + return { alignment: "flex-end", border: "green" }; + case "assistant": + return { alignment: "flex-start", border: "cyan" }; + case "system": + return { alignment: "flex-start", border: "yellow" }; + default: + return { alignment: "flex-start", border: "white" }; } - return cache.get(role); } /** - * Memoized message bubble component. - * Skips re-render when display-relevant message fields haven't changed. - * Compares: role, content, time, reasoningContent, streaming, - * activeToolCall, toolCallDisplay, and a stable message identifier. - * @param {object} props - * @param {Message} props.msg - The message data object - * @param {string} props.assistantName - Name to display for assistant - * @returns {React.ReactElement} + * Render a list of messages as React elements. + * @param {Array} messages - Array of message objects + * @param {string} assistantName - Name for assistant role label + * @param {number} [maxMessages] - Maximum number of messages to render (most recent) + * @returns {Array} Array of React elements */ -const MessageBubble = React.memo( - function MessageBubble({ msg, assistantName }) { - const time = msg.time || formatTime(new Date()); - const colors = getRoleColors(msg.role); - const bubble = getBubbleStyle(msg.role); - - const content = msg.content || ""; - const hasReasoning = msg.role === "assistant" && msg.reasoningContent; - const hasActiveToolCall = msg.role === "assistant" && msg.activeToolCall; - const hasToolCallDisplay = msg.role === "assistant" && msg.toolCallDisplay; - - const reasoningEl = hasReasoning - ? React.createElement( - Box, - { flexDirection: "row", marginTop: 1, marginLeft: 2 }, - React.createElement( - Text, - { dimColor: true, color: "gray" }, - `(thinking) ` + - (msg.reasoningContent || "").slice(0, 200) + - (msg.reasoningContent && msg.reasoningContent.length > 200 - ? "\u00b7\u00b7\u00b7" - : ""), - ), - ) - : null; - - const toolCallEl = hasActiveToolCall - ? React.createElement( - Box, - { flexDirection: "row", marginTop: 1, marginLeft: 2 }, - React.createElement( - Text, - { dimColor: true, color: "gray" }, - `- Running: ${msg.activeToolCall.name} \u00b7\u00b7\u00b7`, - ), - ) - : null; +export function renderMessages(messages, assistantName, maxMessages) { + const msgList = messages || []; - const toolDisplayEl = hasToolCallDisplay - ? React.createElement( - Box, - { flexDirection: "column", marginTop: 1, marginLeft: 2 }, - ...msg.toolCallDisplay - .split("\n") - .map((line, j) => - React.createElement( - Text, - { key: "tool-" + j, dimColor: true, color: "gray" }, - " " + line, - ), - ), - ) - : null; - - return React.createElement( - Box, - { - key: "msg-" + msg.id, - flexDirection: "row", - paddingY: 0, - justifyContent: bubble.alignment, - }, + if (msgList.length === 0) { + return [ React.createElement( Box, - { - key: "bubble-" + msg.id, - flexDirection: "column", - paddingX: 1, - borderColor: bubble.border, - borderStyle: "round", - maxWidth: "90%", - }, - React.createElement( - Box, - { flexDirection: "row" }, - React.createElement(Text, { color: "gray" }, "[" + time + "] "), - React.createElement( - Text, - { color: colors.label, bold: true }, - getRoleLabel(msg.role, assistantName) + ": ", - ), - ), - React.createElement( - Box, - { flexDirection: "column" }, - React.createElement( - Box, - { flexDirection: "row" }, - React.createElement(MarkdownText, { content }), - ), - reasoningEl, - toolCallEl, - toolDisplayEl, - ), + { key: "empty", justifyContent: "center", color: "gray" }, + "No messages yet", ), - ); - }, - function areEqual(prevProps, nextProps) { - const p = prevProps.msg; - const n = nextProps.msg; - return ( - p.role === n.role && - p.content === n.content && - p.time === n.time && - p.reasoningContent === n.reasoningContent && - p.streaming === n.streaming && - p.toolCallDisplay === n.toolCallDisplay && - p.activeToolCall === n.activeToolCall && - p.id === n.id - ); - }, -); - -/** - * Render the conversation message loop for a given messages array. - * Returns React elements for each message bubble. - * Uses memoized MessageBubble components to skip re-render of unchanged rows. - * Limits rendering to the last maxMessages messages to prevent performance - * degradation with very long conversations. - * @param {Array} messages - The messages to render - * @param {string} assistantName - Name to display for assistant messages - * @param {number} [maxMessages] - Maximum number of messages to render (default: all) - * @returns {Array} React elements - */ -export function renderMessages(messages, assistantName, maxMessages = Infinity) { - const children = []; - const startIdx = Math.max(0, (messages?.length ?? 0) - maxMessages); - - for (let i = startIdx; i < (messages?.length ?? 0); i++) { - const msg = messages[i]; - const rowKey = "msg-" + i; - - children.push( - React.createElement(MessageBubble, { - key: rowKey, - msg: { ...msg, id: msg.id ?? i }, - assistantName, - }), - ); + ]; } - if (children.length === 0) { - children.push( - React.createElement( - Text, - { key: "empty", color: "gray" }, - " No messages yet. Start chatting!", - ), - ); - } - - return children; -} - -/** - * Scroll throttle interval in milliseconds during active streaming. - * Reduces scroll-to-bottom frequency by ~90% while maintaining smooth UX. - */ -const SCROLL_THROTTLE_MS = 100; - -/** - * 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. Older messages are - * not rendered but remain in the data array for reference. - */ -const MAX_RENDER_MESSAGES = 100; - -/** - * Conversation panel component with ScrollView-based scrolling. - * Handles keyboard scroll input, terminal resize remeasurement, - * auto-scroll-to-bottom with throttling during streaming, - * and manual scroll detection to suppress auto-scroll when user scrolls up. - * @param {Object} props - * @param {Array} props.messages - Messages to display - * @param {string} props.assistantName - Name for assistant messages - * @param {React.Ref} [props.scrollRef] - Optional external scroll ref - */ -export function ConversationPanel({ - messages = [], - assistantName = "Assistant", - scrollRef: externalScrollRef, -}) { - // Default to empty array for both null and undefined - messages = messages || []; - - 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 { stdout } = useStdout(); - - // Handle terminal resize by remeasuring content heights - useEffect(() => { - const resizeHandler = () => { - if (scrollRef.current && stdout.isTTY && !process.env.CI) { - scrollRef.current.remeasure(); - } - }; - stdout.on("resize", resizeHandler); - return () => { - stdout.off("resize", resizeHandler); - }; - }, [stdout, scrollRef]); - - // 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; - } - }; - - // Check scroll position on each render to detect manual scrolling - checkScrollPosition(); - }, [messages, scrollRef]); - - // Tracks both message count changes and streaming content growth via a - // lightweight content hash so the effect re-evaluates during active streaming. - // Implements 100ms throttle on scroll-to-bottom during active streaming, - // with immediate scroll on streaming pause and manual scroll suppression. - useEffect(() => { - if (!scrollRef.current) return; - - const lastMsg = messages[messages.length - 1]; - const isStreaming = lastMsg?.streaming === true; - const streamingContentLen = isStreaming ? (lastMsg.content || "").length : 0; - const contentHash = messages.length + streamingContentLen; - - const wasScrolling = - messages.length > previousMessageCount.current || - (isStreaming && contentHash !== previousContentHashRef.current); + const limited = + maxMessages && maxMessages !== Infinity && msgList.length > maxMessages + ? msgList.slice(-maxMessages) + : msgList; - 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) - // But allow immediate scroll when streaming pauses - if (isStreaming && timeSinceLastScroll < SCROLL_THROTTLE_MS) { - // Too soon — skip this scroll tick - previousContentHashRef.current = contentHash; - return; - } - - // Re-measure viewport dimensions. - scrollRef.current.remeasure(); - - // Defer scrollToBottom to the next tick. - // ink-scroll-view updates its internal contentHeightRef via useLayoutEffect - // after render. Calling scrollToBottom synchronously reads stale content height, - // causing the scroll offset to be miscalculated. Deferring ensures the - // measurement phase completes before we calculate the scroll position. - const scrollHandle = () => { - if (scrollRef.current) { - scrollRef.current.scrollToBottom(); - previousMessageCount.current = messages.length; - lastScrollTimeRef.current = Date.now(); - } - }; - previousContentHashRef.current = contentHash; - const timer = setTimeout(scrollHandle, 0); - return () => clearTimeout(timer); - }, [messages, stdout.isTTY]); - - const children = React.useMemo( - () => renderMessages(messages, assistantName, MAX_RENDER_MESSAGES), - [messages, assistantName], - ); - - return React.createElement( - Box, - { key: "panel", flexDirection: "column", flexGrow: 1 }, - React.createElement(ScrollView, { ref: scrollRef, key: "scroll", focus: false }, ...children), - ); + return limited.map((msg) => { + const globalIdx = messages.indexOf(msg); + return React.createElement( + MessageBubble, + { + key: "msg-" + globalIdx, + msg: { + role: msg.role, + content: msg.content, + time: msg.time, + streaming: msg.streaming, + reasoningContent: msg.reasoningContent, + activeToolCall: msg.activeToolCall, + toolCallDisplay: msg.toolCallDisplay, + }, + assistantName: assistantName, + }, + ); + }); } diff --git a/src/tui/messageBubble.js b/src/tui/messageBubble.js new file mode 100644 index 0000000..d4256bb --- /dev/null +++ b/src/tui/messageBubble.js @@ -0,0 +1,171 @@ +/** + * MessageBubble component — a standalone message bubble that manages its own state. + * Each bubble is a React component instance with its own useState hooks, + * allowing independent updates without re-rendering other bubbles. + * + * Exposes an imperative `update()` method via useImperativeHandle for + * streaming handlers to call directly during message streaming. + * + * @module messageBubble + */ + +import React, { useState, useImperativeHandle, forwardRef, memo } from "react"; +import { Box, Text } from "ink"; +import { getRoleLabel } from "./messages.js"; +import { getRoleColors, getBubbleStyle, formatTime } from "./conversationPanel.js"; +import { MarkdownText } from "./markdownText.js"; + +/** + * Maximum length for reasoning content display truncation. + * @constant {number} + */ +const REASONING_MAX_LENGTH = 200; + +/** + * MessageBubble component props. + * @typedef {Object} MessageBubbleProps + * @property {string} role - Message role: "user", "assistant", or "system" + * @property {string} content - Initial message content + * @property {string} [assistantName] - Name to display for assistant + * @property {string} [time] - Timestamp string (HH:MM format) + */ + +/** + * MessageBubble component — manages its own state for content, streaming, + * tool calls, and reasoning content. Updates independently via the exposed + * `update()` method. + * + * @param {MessageBubbleProps} props - Component props + * @param {React.Ref} ref - Ref to expose the update method + * @returns {React.ReactElement} + */ +const MessageBubble = forwardRef(function MessageBubble( + { role, content: initialContent, assistantName, time: initialTime }, + ref, +) { + const [content, setContent] = useState(initialContent || ""); + const [streaming, setStreaming] = useState(false); + const [toolCallDisplay, setToolCallDisplay] = useState(""); + const [reasoningContent, setReasoningContent] = useState(""); + const [activeToolCall, setActiveToolCall] = useState(null); + const [time] = useState(initialTime || formatTime(new Date())); + + /** + * Update the bubble's state with new values. + * Called by streaming handlers during message streaming. + * + * @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} + */ + useImperativeHandle( + ref, + () => ({ + update: (updates) => { + if (updates.content !== undefined) setContent(updates.content); + if (updates.streaming !== undefined) setStreaming(updates.streaming); + if (updates.toolCallDisplay !== undefined) setToolCallDisplay(updates.toolCallDisplay); + if (updates.reasoningContent !== undefined) setReasoningContent(updates.reasoningContent); + if (updates.activeToolCall !== undefined) setActiveToolCall(updates.activeToolCall); + }, + }), + [], + ); + + const colors = getRoleColors(role); + const bubble = getBubbleStyle(role); + + const hasReasoning = role === "assistant" && reasoningContent; + const hasActiveToolCall = role === "assistant" && activeToolCall; + const hasToolCallDisplay = role === "assistant" && toolCallDisplay; + + const reasoningEl = hasReasoning + ? React.createElement( + Box, + { flexDirection: "row", marginTop: 1, marginLeft: 2 }, + React.createElement( + Text, + { dimColor: true, color: "gray" }, + `(thinking) ` + + reasoningContent.slice(0, REASONING_MAX_LENGTH) + + (reasoningContent.length > REASONING_MAX_LENGTH ? "..." : ""), + ), + ) + : null; + + const toolCallEl = hasActiveToolCall + ? React.createElement( + Box, + { flexDirection: "row", marginTop: 1, marginLeft: 2 }, + React.createElement( + Text, + { dimColor: true, color: "gray" }, + `- Running: ${activeToolCall.name} ...`, + ), + ) + : null; + + const toolDisplayEl = hasToolCallDisplay + ? React.createElement( + Box, + { flexDirection: "column", marginTop: 1, marginLeft: 2 }, + ...toolCallDisplay + .split("\n") + .map((line, j) => + React.createElement( + Text, + { key: "tool-" + j, dimColor: true, color: "gray" }, + " " + line, + ), + ), + ) + : null; + + return React.createElement( + Box, + { + key: "msg-bubble-" + role + "-" + time, + flexDirection: "row", + paddingY: 0, + justifyContent: bubble.alignment, + }, + React.createElement( + Box, + { + key: "bubble-inner-" + role + "-" + time, + flexDirection: "column", + paddingX: 1, + borderColor: bubble.border, + borderStyle: "round", + maxWidth: "90%", + }, + React.createElement( + Box, + { flexDirection: "row" }, + React.createElement(Text, { color: "gray" }, "[" + time + "] "), + React.createElement( + Text, + { color: colors.label, bold: true }, + getRoleLabel(role, assistantName) + ": ", + ), + ), + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Box, + { flexDirection: "row" }, + React.createElement(MarkdownText, { content: content + (streaming ? "\u2588" : "") }), + ), + reasoningEl, + toolCallEl, + toolDisplayEl, + ), + ), + ); +}); + +export default memo(MessageBubble); \ No newline at end of file diff --git a/src/tui/messageList.js b/src/tui/messageList.js new file mode 100644 index 0000000..3c84df5 --- /dev/null +++ b/src/tui/messageList.js @@ -0,0 +1,259 @@ +/** + * MessageList component — manages an array of MessageBubble component instances. + * Provides imperative methods (addMessage, updateMessage, clear) and handles + * scroll-to-bottom internally via its own ref. + * + * @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([]); + 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 internal bubbles when messages length changes + 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([]); + return; + } + setBubbles((prev) => { + // If the bubbles state already has the correct number of bubbles, + // don't replace them — imperative addMessage/updateMessage calls + // created bubbles with stable IDs that must survive this sync. + if (prev.length === currentCount) { + logger.info({ bubbleCount: prev.length }, "[MessageList] count matches, preserving existing bubbles"); + return prev; + } + const newBubbles = messages.map((msg, idx) => ({ + id: `msg-${idx}`, + 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: newBubbles.length }, "[MessageList] bubbles set"); + return newBubbles; + }); + }, [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) => [...prev, newBubble]); + 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) => + prev.map((bubble) => { + if (bubble.id === id) { + return { ...bubble, ...updates }; + } + return bubble; + }), + ); + }, []); + + /** + * Clear all message bubbles from the list. + */ + const clear = useCallback(() => { + setBubbles([]); + 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 lastBubble = bubbles[bubbles.length - 1]; + const isStreaming = lastBubble?.streaming === true; + const streamingContentLen = isStreaming ? (lastBubble?.content || "").length : 0; + const contentHash = bubbles.length + streamingContentLen; + + const wasScrolling = + bubbles.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 = bubbles.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 startIdx = Math.max(0, bubbles.length - MAX_RENDER_MESSAGES); + const visibleBubbles = bubbles.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; \ No newline at end of file