fix(ui): correct mouse selection Y-offset for reasoning blocks

The getItemAndLineAtY() method was using item.Height() which returns 0
for reasoning blocks (StreamingMessageItem with role='reasoning') because
their render cache is intentionally never populated (they include a live
duration timer).

This caused all items below a reasoning block to have incorrect Y
coordinates — clicking on the reasoning text would highlight the
assistant text below it instead.

Two fixes:
1. getItemAndLineAtY() now uses renderedHeight() which calls Render()
   and counts lines — matching exactly what View() does. This is the
   single source of truth for item height during hit-testing.

2. StreamingMessageItem.Height() now falls back to Render(0) when
   cachedRender is empty, fixing the same issue for other callers
   (GotoBottom, ScrollBy, clampOffset, etc.).
This commit is contained in:
Ed Zynda
2026-04-01 18:15:04 +03:00
parent 4fa5775974
commit 8e3cfeede5
2 changed files with 27 additions and 3 deletions
+9 -2
View File
@@ -172,10 +172,17 @@ func (s *StreamingMessageItem) Render(width int) string {
// Height returns the number of lines.
func (s *StreamingMessageItem) Height() int {
if s.cachedRender == "" {
// For reasoning blocks, cachedRender is never populated (rendering is
// width-independent and includes a live timer). Fall back to Render(0)
// so callers always get the correct height.
rendered := s.cachedRender
if rendered == "" {
rendered = s.Render(0)
}
if rendered == "" {
return 0
}
return strings.Count(s.cachedRender, "\n") + 1
return strings.Count(rendered, "\n") + 1
}
// AppendChunk adds a content chunk and invalidates the render cache.
+18 -1
View File
@@ -284,6 +284,10 @@ func (s *ScrollList) selectLine(itemIdx, lineIdx int) {
// getItemAndLineAtY converts a viewport-relative Y coordinate to item index
// and line index within that item. Accounts for scroll offset and item gaps.
// Returns (-1, -1) if Y is outside the viewport or beyond all items.
//
// IMPORTANT: Uses Render()+line counting (not Height()) to compute item height,
// because Height() on some MessageItem implementations (e.g. StreamingMessageItem
// for reasoning blocks) may return 0 when the render cache is empty.
func (s *ScrollList) getItemAndLineAtY(y int) (itemIdx, lineIdx int) {
if y < 0 || y >= s.height || len(s.items) == 0 {
return -1, -1
@@ -292,7 +296,8 @@ func (s *ScrollList) getItemAndLineAtY(y int) (itemIdx, lineIdx int) {
currentY := 0
for idx := s.offsetIdx; idx < len(s.items); idx++ {
item := s.items[idx]
itemHeight := item.Height()
// Compute height the same way View() does: render, then count lines.
itemHeight := s.renderedHeight(item)
// Account for partial visibility of the first item.
startLine := 0
@@ -667,6 +672,18 @@ func (s *ScrollList) clampOffset() {
}
}
// renderedHeight returns the height of a message item in lines by actually
// rendering it. This is the single source of truth for item height — it
// matches exactly what View() produces, unlike item.Height() which may
// return stale/zero values for uncached items (e.g. reasoning blocks).
func (s *ScrollList) renderedHeight(item MessageItem) int {
rendered := item.Render(s.width)
if rendered == "" {
return 0
}
return strings.Count(rendered, "\n") + 1
}
// abs returns the absolute value of x.
func abs(x int) int {
if x < 0 {