219 files, 2.0 MB, untracked in souveraine/docs and existing nowhere else. The volume is at 100% with no snapshots.
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:
-
The
viewheight subtracts 2 for borders (saturating_sub(2)), which is correct but meanstotallines that exactly equalviewwill scroll to 0 (no scroll needed). Iftotal == 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: -
Streaming in-progress: During streaming (
ChatMessage::Assistant { streaming: true }), thelinesvector 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. -
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. -
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.
Option C: Both (recommended)
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
- Reproduce the clipping: send a long message (3-5 paragraphs) and watch the last line appear/disappear as streaming completes.
- Check whether the issue is worse with tool cards (which add multiple lines per invocation and inflate
totalrapidly).
Files to Modify
| File | Change |
|---|---|
src/ui/chat.rs — draw_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