Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/substrate/tasks/chat-viewport-scroll-clip.md
Fimeg e480809c70 docs: rescue the agent-substrate tree out of a gitignored directory
219 files, 2.0 MB, untracked in souveraine/docs and existing nowhere else.
The volume is at 100% with no snapshots.
2026-07-26 12:11:50 -04:00

3.9 KiB

task_id title status assignee priority phase
ui-viewport-001 Fix Chat Viewport — Messages Slightly Below Visible Area pending low 3.0

Task: Fix Chat Viewport Scroll Clipping

Objective

The chat message area occasionally has a small overshoot where the last line of the latest message falls just below the visible area — not enough to warrant a full page scroll, but enough that the message appears cut off by 1-3 lines.

Current State

Auto-scroll logic in draw_messages() at src/ui/chat.rs:1019-1033:

let total = lines.len() as u16;
let view = area.height.saturating_sub(2);
let scroll = total.saturating_sub(view).saturating_sub(state.scroll);

let para = Paragraph::new(lines)
    .wrap(Wrap { trim: false })
    .scroll((scroll, 0))

The calculation: scroll = total - view - user_scroll. This positions the bottom of the content at the bottom of the viewport. But:

  1. The view height subtracts 2 for borders (saturating_sub(2)), which is correct but means total lines that exactly equal view will scroll to 0 (no scroll needed). If total == view + 1, scroll = 1, and the top line is clipped while the bottom line occupies the last content row. This works correctly in theory but fails when:

  2. Streaming in-progress: During streaming (ChatMessage::Assistant { streaming: true }), the lines vector grows every tick. The scroll calculation is done once per frame but the content is changing. If a long streaming response adds exactly 1 line past the viewport, the overflow is clipped until the next frame.

  3. Empty line padding: Each message adds an empty Line::from("") after it (lines 967, 983, etc.). The last message's trailing empty line can push the content exactly 1 line past the viewport, causing the last real line to appear clipped while the empty line sits in the scroll shadow.

  4. Bubble border consumption: Each bubble is 3 lines (top border, content, bottom border) + content. If the last bubble's bottom border lands at view / total boundary, the 1-line border can push content out by exactly 1.

Fix Options

Option A: Add 1-line scroll buffer

Change the scroll calculation to always leave a 1-line buffer at the bottom:

let scroll = total.saturating_sub(view).saturating_sub(state.scroll).saturating_sub(1);

Pros: 1-line fix. Catches the edge case reliably. Cons: Leaves one empty line at the bottom of the chat area. Slightly less efficient use of vertical space.

Option B: Skip trailing empty lines from count

Filter out trailing Line::from("") entries from total:

let non_empty = lines.iter().rev().skip_while(|l| l.is_empty()).count();
let scroll = non_empty.saturating_sub(view).saturating_sub(state.scroll);

Pros: More accurate. No visual waste. Cons: Slightly more complex. Empty lines between messages are still needed for visual separation.

Both A and B — skip trailing empty lines from the count AND add a 1-line buffer to prevent streaming-edge overflow.

Bonus: Detect during streaming

During active streaming (state.busy), force scroll to the bottom every frame rather than computing a delta:

if state.busy {
    scroll = total.saturating_sub(view);
} else {
    scroll = total.saturating_sub(view).saturating_sub(state.scroll);
}

Investigation Needed

  1. Reproduce the clipping: send a long message (3-5 paragraphs) and watch the last line appear/disappear as streaming completes.
  2. Check whether the issue is worse with tool cards (which add multiple lines per invocation and inflate total rapidly).

Files to Modify

File Change
src/ui/chat.rsdraw_messages() Adjust scroll calculation; skip trailing empty lines in count; add streaming buffer

Not in Scope

  • General scrollbar UX (the chat currently has no visible scrollbar indicator — separate task)
  • Keyboard-driven scroll position persistence across turns