fix: new/update message behavior (#1958)

Kujtim Hoxha created

Change summary

internal/ui/list/list.go  | 26 ++++++++++++++++++++++++++
internal/ui/model/chat.go |  4 ++--
internal/ui/model/ui.go   | 20 ++++++++++++++------
3 files changed, 42 insertions(+), 8 deletions(-)

Detailed changes

internal/ui/list/list.go 🔗

@@ -75,6 +75,32 @@ func (l *List) Gap() int {
 	return l.gap
 }
 
+// AtBottom returns whether the list is scrolled to the bottom.
+func (l *List) AtBottom() bool {
+	if len(l.items) == 0 {
+		return true
+	}
+
+	// Calculate total height of all items from the bottom.
+	var totalHeight int
+	for i := len(l.items) - 1; i >= 0; i-- {
+		item := l.getItem(i)
+		totalHeight += item.height
+		if l.gap > 0 && i < len(l.items)-1 {
+			totalHeight += l.gap
+		}
+		if totalHeight >= l.height {
+			// This is the expected bottom position.
+			expectedIdx := i
+			expectedLine := totalHeight - l.height
+			return l.offsetIdx == expectedIdx && l.offsetLine >= expectedLine
+		}
+	}
+
+	// All items fit in viewport - we're at bottom if at top.
+	return l.offsetIdx == 0 && l.offsetLine == 0
+}
+
 // SetReverse shows the list in reverse order.
 func (l *List) SetReverse(reverse bool) {
 	l.reverse = reverse

internal/ui/model/chat.go 🔗

@@ -481,9 +481,9 @@ func (m *Chat) HasHighlight() bool {
 	return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
 }
 
-// HighlighContent returns the currently highlighted content based on the mouse
+// HighlightContent returns the currently highlighted content based on the mouse
 // selection. It returns an empty string if no content is highlighted.
-func (m *Chat) HighlighContent() string {
+func (m *Chat) HighlightContent() string {
 	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
 	if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
 		return ""

internal/ui/model/ui.go 🔗

@@ -873,6 +873,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 	var cmds []tea.Cmd
 	existingItem := m.chat.MessageItem(msg.ID)
+	atBottom := m.chat.list.AtBottom()
 
 	if existingItem != nil {
 		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
@@ -893,9 +894,6 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
 			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
 			m.chat.AppendMessages(newInfoItem)
-			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
-				cmds = append(cmds, cmd)
-			}
 		}
 	}
 
@@ -922,9 +920,12 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 			}
 		}
 	}
+
 	m.chat.AppendMessages(items...)
-	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
-		cmds = append(cmds, cmd)
+	if atBottom {
+		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 	}
 
 	return tea.Batch(cmds...)
@@ -934,6 +935,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
 	var cmds []tea.Cmd
 
+	atBottom := m.chat.list.AtBottom()
 	// Only process messages with tool calls or results.
 	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
 		return nil
@@ -1013,6 +1015,12 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.
 	// Update the chat so it updates the index map for animations to work as expected
 	m.chat.UpdateNestedToolIDs(toolCallID)
 
+	if atBottom {
+		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+
 	return tea.Batch(cmds...)
 }
 
@@ -2905,7 +2913,7 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
 }
 
 func (m *UI) copyChatHighlight() tea.Cmd {
-	text := m.chat.HighlighContent()
+	text := m.chat.HighlightContent()
 	return tea.Sequence(
 		tea.SetClipboard(text),
 		func() tea.Msg {