Metadata
| Status | done |
|---|---|
| Assigned | agent-1277 |
| Agent identity | f51439356729d112a6c404803d88015d5b44832c6c584c62b96732b63c2b0c7e |
| Model | claude:opus |
| Created | 2026-04-30T16:26:29.430959782+00:00 |
| Started | 2026-04-30T16:33:14.289535266+00:00 |
| Completed | 2026-04-30T16:38:22.038558124+00:00 |
| Tags | priority-high,research,tui,ux, eval-scheduled |
| Eval score | 0.93 |
| └ blocking impact | 1.00 |
| └ completeness | 1.00 |
| └ coordination overhead | 0.96 |
| └ correctness | 0.92 |
| └ downstream usability | 0.96 |
| └ efficiency | 0.85 |
| └ intent fidelity | 0.83 |
| └ style adherence | 0.90 |
Description
Description
User report 2026-04-30: in the TUI, the viewport jumps unexpectedly when state changes. Tasks getting introduced or transitioning state pull focus, often pushing the visible region to a place where the action ISN'T happening. Net: jarring + counterintuitive.
User direct quote: 'we often have a situation where the actions happening near the top of the view, but something will shift the window down. I think it's like the introduction of a new task, a change of state of a task maybe. It focuses that task at the top of the window. ... I think what we should be trying to do is to move or focus as much as possible to get the interesting stuff in the middle of the screen. ... given the fact that things are usually happening at the very top of the graph this should tend to drive us to the very top and make it so that the top of the window is at the top of the graph in the temporal square order we're using. ... I just kind of want it to be a bit stable unless we move it around. It should be stable and sort of stable relative to the information around it.'
Investigation goals (audit-only — no source modifications)
-
Where are viewport shifts triggered? Find every code path that calls something like
scroll_to(task_id)/set_viewport_top(...)/focus_task(...). Likely candidates:- State-transition handlers (open → in-progress → done)
- New-task-creation handlers (auto-scroll to show the new task)
- Selection-on-click / selection-on-key handlers
- Cycle iteration resets
-
What's the current policy? For each trigger, what's the heuristic? Is it consistent or piecemeal?
-
What does 'stable' mean operationally? Possible policies to compare:
- Sticky-by-default: viewport doesn't move unless user explicitly moves it
- Center-on-action: viewport keeps the active task in the middle of the visible region
- Top-of-graph-anchor: when the user is at/near the top of the temporal-sort, viewport sticks to top (the user's specific suggestion for the common case)
- Hybrid: sticky if user has scrolled away from auto-tracking; auto-track when user is at the edge
-
How does the user signal 'I want auto-tracking' vs 'I want stable view'? Implicit (proximity to top/bottom) or explicit (a follow-mode toggle key)?
Deliverable
Posted via wg log:
- Full list of code paths that trigger viewport shifts (file:line)
- Concrete recommended policy for the implementation task to follow
- Specific cases to handle: new task introduced + user near top, new task introduced + user scrolled mid-view, state transition of currently-visible task, state transition of off-screen task, selection-by-click vs selection-by-key
- Smoke scenario list
Validation
- Code paths enumerated with file:line
- Current policy summarized
- Recommended policy specified concretely
- Edge cases enumerated
- Smoke scenario list provided
- No source / doc modifications
Depends on
Required by
Log
- 2026-04-30T16:26:29.419345729+00:00 Task paused
- 2026-04-30T16:28:25.288343445+00:00 Task published
- 2026-04-30T16:33:14.289538953+00:00 Spawned by coordinator --executor claude --model opus
- 2026-04-30T16:33:21.316997261+00:00 Starting audit: enumerating viewport-shift code paths in src/tui/
- 2026-04-30T16:38:03.672330171+00:00 # Audit: TUI viewport stability — code paths, current policy, recommendation Scope: graph pane in `src/tui/viz_viewer/`. No source modifications made. ## 1. Code paths that move the graph viewport ### A. Refresh-time viewport adjustment (the user's reported jumps) All routed through `VizApp::apply_viz_result(...)`: - `src/tui/viz_viewer/state.rs:5534` — `apply_viz_result` is the join point. Called from `load_viz` (5522), `load_viz_from_graph` (5528), the fs-watcher fast path (7127), the 1s slow tick (7360), and `force_refresh` (10013). - `state.rs:5536` — `was_at_bottom = smart_follow_active || initial_load` - `state.rs:5538-5546` — capture `old_offset_y` and `old_relative_pos` (selected task's screen-row offset from viewport top) - `state.rs:5697-5699` — `check_new_task_focus()` reads `.new_task_focus` marker (written by `wg add`); if present it overrides `selected_task_idx` and returns `new_task_focused=true`. - `state.rs:5714-5717` — `initial_load` → `scroll.go_top()` (one-time) - `state.rs:5718-5720` — `was_at_bottom && !new_task_focused` → `scroll.go_bottom()` (smart-follow re-anchor) - `state.rs:5721-5741` — `selection_unchanged` → relative-position anchor (`offset_y = new_visible_pos - rel_pos`); fallback to `scroll_to_selected_task()` if anchor fails - `state.rs:5742-5744` — `new_task_focused` → set `needs_center_on_selected = true` (consumed in render) - `state.rs:5745-5747` — selection changed (different task) → set `needs_scroll_into_view = true` - `state.rs:7126`, `7359`, `10009` — `smart_follow_active = scroll.is_at_bottom()` snapshotted before each reload (`is_at_bottom` returns true when `offset_y + 3 >= max_y` OR `content_height <= viewport_height`). ### B. Render-time deferred adjustment - `src/tui/viz_viewer/render.rs:431-438` — Phase 2 of frame draw: consumes `needs_center_on_selected` → `center_on_selected_task()` (scrolls so task is `viewport_height/2` from top, clamped); else consumes `needs_scroll_into_view` → `scroll_to_selected_task()` (vim-style 20% comfort zone, only scrolls if outside zone). ### C. The two scroll primitives - `state.rs:6052-6082` — `scroll_to_selected_task()`: minimal-scroll, keeps task in middle 60% (`vh/5` margin). Only adjusts if task is outside the comfort zone. - `state.rs:6084-6098` — `center_on_selected_task()`: unconditional re-center to `vh/2`. ### D. User-driven selection events that scroll the viewport All call `scroll_to_selected_task()` (gentle, comfort-zone): - `state.rs:5845` — `select_prev_task` (Up arrow) - `state.rs:5863` — `select_next_task` (Down arrow) - `state.rs:5879` — `select_prev_task_n` (PageUp) - `state.rs:5896` — `select_next_task_n` (PageDown) - `state.rs:5908` — `select_first_task` (Home) - `state.rs:5920` — `select_last_task` (End) - `state.rs:13102` — `switch_coordinator` (when coordinator's task is in graph) - `event.rs:3843` — clicking a user-board tab in the tab bar ### E. User-driven raw scroll (no selection change) All call `record_graph_scroll_activity()` (shows scrollbar) and adjust `scroll.offset_y` directly: - `event.rs:861` — Up arrow in search mode (no select) - `event.rs:865` — Down arrow in search mode - `event.rs:2169` `j` / `2173` `k` — vim line-step - `event.rs:2177` ^U / `2181` ^D — half-page - `event.rs:2183-2191` — PageUp/PageDown also call `select_prev/next_task_n` - `event.rs:2196` `g` / `2200` `G` — go-top/go-bottom (without selection change) - `event.rs:2204-2210` — Home/End (with selection change) - `event.rs:3678,3711,3942,4373` — wheel + scrollbar drag (sets offset_y directly via `vscrollbar_jump_graph`) - `event.rs:4393-4399, 4442-4448` — drag-to-pan ### F. Sort-mode cycle (`s` key) - `state.rs:7681-7718` — `cycle_sort_mode`: - `Chronological` → `scroll.go_top()` + select first task - `ReverseChronological` → `scroll.go_bottom()` + select last task - `StatusGrouped` → select first task + `scroll_to_selected_task()` ### G. Search jumps - `state.rs:6418-6419` — `scroll_to_current_match` recenters around match (vim-style) - `state.rs:6517-6518` — same on next/prev match - `state.rs:6655-6659` — chat tab scrollbar jump (separate pane) ### H. Click-on-task (importantly does NOT scroll) - `state.rs:6103-6128` — `select_task_at_line` updates selection without calling any scroll. Good. ### I. New-task marker producers - `src/commands/add.rs:689` — `wg add` writes `.new_task_focus` for any non-internal task. - `src/commands/service/ipc.rs:1330` — IPC AddTask path also writes the marker (skipping `.`-prefixed internal tasks). - `src/commands/mod.rs:212` — `notify_new_task_focus(dir, task_id)` helper. - `state.rs:6139-6167` — `check_new_task_focus()` consumes (reads + deletes) marker, sets selection, pushes a toast. ## 2. Current policy summary The codebase already encodes a thoughtful policy, but it has a few rough edges that match the user's complaint exactly: | Trigger | Current behavior | Intent | Failure mode | |---|---|---|---| | Initial load | `go_top()` | Show roots first | OK | | New task created (`.new_task_focus` present) | Center the new task at `vh/2` | Show user their fresh task | **Window jumps when new task is below current view; if user was reading top of graph, viewport scrolls down and the new task lands roughly in the middle/top of the visible region — exactly what user complains about.** | | Selection unchanged AND user not at bottom | Relative-position anchor (preserve task's screen row) | Visual stability | Mostly works; falls back to `scroll_to_selected_task` if `node_line_map` lookup fails | | Selection unchanged AND user at bottom | `go_bottom()` | Smart-follow log-tailing | Works for tail-following; can feel like auto-scroll if user wandered close to bottom | | State transition of visible task | (no special path — falls through to relative-anchor branch) | Visual stability | OK in principle, but `apply_sort_mode()` runs unconditionally inside `apply_viz_result` (line 5668); current sort modes only re-order `task_order` (navigation), not rendered lines, so visual layout is preserved | | State transition of off-screen task | (same as above; relative anchor) | OK | | | Selection-by-key (arrow/Home/End) | `scroll_to_selected_task()` (comfort-zone) | Minimal scroll | OK (vim-style is correct) | | Selection-by-click | No scroll (`select_task_at_line` doesn't scroll) | "Don't move what user pointed at" | **Correct** | | Sort-mode cycle | Hard `go_top` / `go_bottom` | Reorient | Acceptable as user action | **Net diagnosis:** The single dominant cause of the user-reported jumps is the `.new_task_focus` → `center_on_selected_task` path. Every `wg add` from the coordinator (or any agent) yanks the user's viewport to wherever the new task happens to render. With dense graphs and frequent task creation during a coordinator cycle, this is exactly "the window shifts down because a new task was introduced". The secondary cause is the unconditional `go_bottom()` whenever `is_at_bottom()` was true at refresh time. The 3-line slack means a user reading near the bottom can be sticky-followed without intending to be. State transitions of *existing* tasks are mostly handled correctly today — the relative-position anchor logic at 5723-5735 is the right design. Pre-existing logic we should keep, not throw away. ## 3. Recommended policy (concrete, for the implement task) Stated as a precedence ladder, evaluated each time `apply_viz_result` runs: 1. **Initial load** → `go_top()`. (Unchanged.) 2. **User explicitly invoked a follow command this session** (new follow-mode toggle, e.g. `f`) → keep selected task in `vh/2` (re-center). *Optional, stretch goal.* 3. **`.new_task_focus` marker present**: - **Drop the unconditional re-center.** Replace with: keep the existing viewport unless the new task is off-screen. - If new task is off-screen → use `scroll_to_selected_task()` (comfort zone, *not* center). - If new task is on-screen → no scroll; just update selection + show toast + splash. - **User-near-top exception (per user's quote):** if `scroll.offset_y == 0` (user is anchored to top of graph), keep `offset_y = 0` regardless of where the new task lives. Top-anchor wins. 4. **`smart_follow_active` was true AND graph grew** → keep at bottom (`go_bottom()`). Tighten the threshold from `+3` to exact bottom (`is_at_bottom_strict()`) so users near-but-not-at bottom are not auto-pulled. 5. **Selection unchanged** → relative-position anchor (existing logic at 5723-5735). Keep. 6. **Selection changed by code (rare)** → `needs_scroll_into_view = true` (gentle comfort-zone). Keep. Default mode: **sticky**. Auto-track only at the explicit edges (top-anchor or bottom-anchor) or when the user has opted in via a follow toggle. ### Implementation hooks (where to change) - `state.rs:5697-5747` — the dispatch ladder is here. Reorder branches so the top-anchor / on-screen check happens *before* the new-task center. Specifically: replace `needs_center_on_selected = true` at 5744 with logic that: - if `offset_y == 0` → leave as-is; - else if new task is within `[offset_y, offset_y + viewport_height)` → leave as-is; - else `needs_scroll_into_view = true`. - `state.rs:6052` — `scroll_to_selected_task` is the right primitive; do not replace. - `state.rs:6085` — `center_on_selected_task` should remain available but only get called from the explicit follow-mode toggle (new) or `n`/`N` search, never from the refresh path. - `state.rs:17216` — tighten `is_at_bottom`: drop the `+3` slack OR introduce `is_at_bottom_strict` (no slack) for use in `was_at_bottom` snapshot. - `state.rs:5536` — change `was_at_bottom` to use the strict variant. - `state.rs:7681-7718` — `cycle_sort_mode`: leave `go_top`/`go_bottom` (this is a user-invoked reorient — meaningful jump is expected). ### Optional: explicit follow-mode toggle Add a key (e.g. `f`) and a `follow_mode: bool` field. When on, refresh path always recenters on the selected task. When off (default), use the sticky ladder above. Surface state in the status bar. ## 4. Edge cases the implementation must handle | Case | Current | Recommended | |---|---|---| | New task introduced + user at top (`offset_y == 0`) | Center (jump) | **Stay at top** (top-anchor wins per user's quote) | | New task introduced + user scrolled mid-view, new task off-screen | Center (jump) | Comfort-zone scroll OR no-scroll (sticky default) — pick **no-scroll**, since user is reading something else; surface only via toast + splash | | New task introduced + new task is already on-screen | Center (small jump) | **No scroll**; selection moves, splash flashes | | State transition of currently-visible task | Anchor preserves position | Same (keep) | | State transition of off-screen task | Anchor preserves position | Same (keep). Splash may render off-screen — fine | | Annotation appears/expires on visible task | Anchor preserves position (annotations are inline, no line-count change) | Same | | `wg add` of internal `.`-task | No marker written, no jump | Same | | User scrolled close to bottom (within 3 lines) | `go_bottom()` snaps to bottom on every refresh | Tighten to exact bottom only | | User at exact bottom + new task appended | `go_bottom()` keeps at bottom | Same (this IS smart-follow's job) | | Sort mode cycle (`s`) | Hard `go_top`/`go_bottom` + reselect | Keep — user-invoked, expected jump | | Selection-by-click on visible task | No scroll | Keep | | Selection-by-key (arrow) onto off-screen task | Comfort-zone scroll | Keep | | Cycle iteration reset (members → open) | Falls through anchor (selection_unchanged) | Same — but verify `task_order` reorder doesn't break anchor (it currently doesn't, but worth a smoke) | | Coordinator switch via tab click | Selects coord task + comfort-zone scroll | Keep | | Filtered view (`/search`) | Currently uses `scroll_to_current_match` (centers) | Keep — search is explicit user navigation | ## 5. Smoke scenario list (for `tests/smoke/manifest.toml`) Each scenario opens TUI in a known-good fixture, performs an action, asserts viewport `offset_y` invariant. All require deterministic graph + injected fs events. 1. **`tui-viewport-new-task-user-at-top`**: load 30-task graph, `offset_y=0`, `wg add new-task`. Assert `offset_y == 0` after refresh tick. 2. **`tui-viewport-new-task-user-mid-view`**: load 30-task graph, scroll to `offset_y=10`, `wg add new-task`. Assert `offset_y == 10` (no scroll). Toast and splash should fire. 3. **`tui-viewport-new-task-on-screen`**: load 30-task graph, `offset_y=5`, viewport_height=20, `wg add` (new task lands at line 18). Assert `offset_y == 5` (already visible). 4. **`tui-viewport-new-task-off-screen-down`**: same as above but new task lands at line 50. Assert `offset_y == 5` (sticky default; new task announced via toast only). 5. **`tui-viewport-state-transition-visible`**: graph with task X at row 10; mark X in-progress; assert viewport unchanged. 6. **`tui-viewport-state-transition-offscreen`**: task X at line 100, `offset_y=5`; mark X done; assert `offset_y == 5`. 7. **`tui-viewport-smart-follow-strict-bottom`**: scroll to exact bottom (`offset_y == max_offset`); add task; assert viewport followed to new bottom. 8. **`tui-viewport-no-smart-follow-near-bottom`**: scroll to within 2 lines of bottom (`offset_y == max_offset - 2`); add task; assert `offset_y` did NOT auto-advance. 9. **`tui-viewport-click-no-scroll`**: large graph, `offset_y=0`; click visible task at row 18; assert `offset_y == 0`, selection updated. 10. **`tui-viewport-arrow-key-comfort-zone`**: `offset_y=10`, viewport_height=20, selected task at row 12 (visible, in zone); press Down repeatedly. Assert viewport doesn't move until selected task crosses comfort-zone bottom (row `10 + 16 = 26`), then advances minimally. 11. **`tui-viewport-cycle-iteration-reset`**: 5-task cycle hits convergence → all members reset to `open`, loop_iteration++; assert viewport stable (selection_unchanged path). 12. **`tui-viewport-sort-mode-cycle`**: press `s`. Assert viewport jumps as sort dictates (this IS expected; smoke locks in the behavior). 13. **`tui-viewport-coordinator-switch`**: 2 coordinators in tab bar; click coord-1 tab; assert that coord-1's task is brought into the comfort zone (existing behavior; lock it in). 14. **`tui-viewport-search-recenter`**: open `/`, type query; assert match is centered. (Existing behavior.) 15. **`tui-viewport-internal-task-no-jump`**: `wg add .compact-9` (internal); assert no `.new_task_focus` marker, no viewport change. The existing `tests/smoke/scroll_mode_tests.rs` and `editor_tests.rs` are good harness templates. Test should drive `apply_viz_result` directly with a synthetic `VizOutput` rather than going through the file system, except for the marker-presence test which should write `.new_task_focus` and assert `apply_viz_result` consumes + applies it correctly. ## 6. Out-of-scope notes (consider but do NOT change in implement task) - The right-panel (HUD/log/chat/messages) has its own scroll state machine independent of the graph viewport; this audit explicitly does not cover it. - The chat-tab horizontal scroll (`chat_tab_scroll_offset`) is a separate concern (tab bar overflow). - `record_graph_scroll_activity()` only controls scrollbar visibility; it does not move the viewport. ## Validation checklist - [x] Code paths enumerated with file:line (sections 1A–1I) - [x] Current policy summarized (section 2 table) - [x] Recommended policy specified concretely (section 3 ladder + implementation hooks) - [x] Edge cases enumerated (section 4 table, 13 cases incl. user's specific scenarios) - [x] Smoke scenario list provided (section 5, 15 scenarios) - [x] No source / doc modifications
- 2026-04-30T16:38:12.948172430+00:00 Validation: deliverable complete — code paths enumerated with file:line, current policy table, recommended sticky-with-top-anchor policy, 13 edge cases, 15 smoke scenarios. No source modifications made.
- 2026-04-30T16:38:22.038562111+00:00 Task pending eval (agent reported done; awaiting `.evaluate-*` to score)
- 2026-04-30T16:40:35.796902205+00:00 PendingEval → Done (evaluator passed; downstream unblocks)