interactive: snap to bottom after /sessions load
Loading a session via the /sessions dialog used to park the viewport on the last user turn, intended to show "where you left off". In practice users open /sessions to resume work and expect to land at the live tail of the conversation, not somewhere mid-scroll. Replace the scrollToLastTurn call in applySessionSelection with scrollToBottom so a /sessions resume snaps to the latest message. The CLI startup paths (--continue, --resume, --session) still park on the last turn since they're a different entry point with a different mental model (boot into a saved session vs. switch sessions during an active run).
- commit
- 4b2bce3fb243358f44e8d226aaa33bd997e22ffb
- author
- patriceckhart · · 2026-04-27 13:31 UTC
- parent
- 8e92c62
- signature
- unsigned
- refs
- tag: v0.1.27
1 file · +3 -15
M internal/agent/modes/interactive.go +3 -15
| @@ -2264,15 +2264,8 @@ func totalTurnsLocked(msgs []provider.Message) int { | |||
| 2264 | 2264 | } | |
| 2265 | 2265 | ||
| 2266 | 2266 | // applySessionSelection loads the given session via the cli-provided | |
| 2267 | - | // callback and parks the viewport on the last turn so the user lands | |
| 2268 | - | // looking at where the conversation left off (their last prompt at the | |
| 2269 | - | // top of the chat, the assistant's last reply right below). Older | |
| 2270 | - | // history is one scroll up; pgdn or end snaps to the current tail. | |
| 2271 | - | // | |
| 2272 | - | // Without this, scrollOffset stayed at 0 (pinned to the live tail), | |
| 2273 | - | // which on a long resumed session showed only the last few rows of | |
| 2274 | - | // the final assistant message — the user read that as "only one liner | |
| 2275 | - | // happened, the resume didn't work". | |
| 2267 | + | // callback and snaps the viewport to the bottom (the latest message) | |
| 2268 | + | // so the user lands at the live tail of the resumed conversation. | |
| 2276 | 2269 | func (i *Interactive) applySessionSelection(path string) { | |
| 2277 | 2270 | if i.cfg.LoadSession == nil { | |
| 2278 | 2271 | i.mu.Lock() | |
| @@ -2293,17 +2286,12 @@ func (i *Interactive) applySessionSelection(path string) { | |||
| 2293 | 2286 | i.parkedTurn = 0 | |
| 2294 | 2287 | i.parkedTotal = 0 | |
| 2295 | 2288 | i.view.InvalidateRenderCache() | |
| 2296 | - | // Pull the freshly-loaded transcript into the view so the anchor | |
| 2297 | - | // math below sees the post-resume messages, not the empty pre-load | |
| 2298 | - | // state. redraw() does the same on its next pass; we just front-run | |
| 2299 | - | // it here to compute the scroll target. | |
| 2300 | 2289 | if i.agent != nil { | |
| 2301 | 2290 | i.view.Messages = i.agent.Messages() | |
| 2302 | 2291 | } | |
| 2303 | - | msgs := i.view.Messages | |
| 2304 | 2292 | i.mu.Unlock() | |
| 2305 | 2293 | ||
| 2306 | - | i.scrollToLastTurn(msgs) | |
| 2294 | + | i.scrollToBottom() | |
| 2307 | 2295 | } | |
| 2308 | 2296 | ||
| 2309 | 2297 | // scrollToLastTurn parks the viewport at the most recent user turn, | |
| @@ -2264,15 +2264,8 @@ func totalTurnsLocked(msgs []provider.Message) int { | |||||
| 2264 | } | 2264 | } | ||
| 2265 | 2265 | ||||
| 2266 | // applySessionSelection loads the given session via the cli-provided | 2266 | // applySessionSelection loads the given session via the cli-provided | ||
| 2267 | - | // callback and parks the viewport on the last turn so the user lands | 2267 | + | // callback and snaps the viewport to the bottom (the latest message) |
| 2268 | - | // looking at where the conversation left off (their last prompt at the | 2268 | + | // so the user lands at the live tail of the resumed conversation. |
| 2269 | - | // top of the chat, the assistant's last reply right below). Older | |||
| 2270 | - | // history is one scroll up; pgdn or end snaps to the current tail. | |||
| 2271 | - | // | |||
| 2272 | - | // Without this, scrollOffset stayed at 0 (pinned to the live tail), | |||
| 2273 | - | // which on a long resumed session showed only the last few rows of | |||
| 2274 | - | // the final assistant message — the user read that as "only one liner | |||
| 2275 | - | // happened, the resume didn't work". | |||
| 2276 | func (i *Interactive) applySessionSelection(path string) { | 2269 | func (i *Interactive) applySessionSelection(path string) { | ||
| 2277 | if i.cfg.LoadSession == nil { | 2270 | if i.cfg.LoadSession == nil { | ||
| 2278 | i.mu.Lock() | 2271 | i.mu.Lock() | ||
| @@ -2293,17 +2286,12 @@ func (i *Interactive) applySessionSelection(path string) { | |||||
| 2293 | i.parkedTurn = 0 | 2286 | i.parkedTurn = 0 | ||
| 2294 | i.parkedTotal = 0 | 2287 | i.parkedTotal = 0 | ||
| 2295 | i.view.InvalidateRenderCache() | 2288 | i.view.InvalidateRenderCache() | ||
| 2296 | - | // Pull the freshly-loaded transcript into the view so the anchor | |||
| 2297 | - | // math below sees the post-resume messages, not the empty pre-load | |||
| 2298 | - | // state. redraw() does the same on its next pass; we just front-run | |||
| 2299 | - | // it here to compute the scroll target. | |||
| 2300 | if i.agent != nil { | 2289 | if i.agent != nil { | ||
| 2301 | i.view.Messages = i.agent.Messages() | 2290 | i.view.Messages = i.agent.Messages() | ||
| 2302 | } | 2291 | } | ||
| 2303 | - | msgs := i.view.Messages | |||
| 2304 | i.mu.Unlock() | 2292 | i.mu.Unlock() | ||
| 2305 | 2293 | ||||
| 2306 | - | i.scrollToLastTurn(msgs) | 2294 | + | i.scrollToBottom() |
| 2307 | } | 2295 | } | ||
| 2308 | 2296 | ||||
| 2309 | // scrollToLastTurn parks the viewport at the most recent user turn, | 2297 | // scrollToLastTurn parks the viewport at the most recent user turn, | ||