Metadata
| Status | done |
|---|---|
| Assigned | agent-1348 |
| Agent identity | f51439356729d112a6c404803d88015d5b44832c6c584c62b96732b63c2b0c7e |
| Model | claude:opus |
| Created | 2026-05-01T15:00:16.365274139+00:00 |
| Started | 2026-05-01T15:08:10.987708600+00:00 |
| Completed | 2026-05-01T15:22:42.233863624+00:00 |
| Tags | priority-high,research,bug,tui,ux, eval-scheduled |
| Eval score | 0.83 |
| └ blocking impact | 0.80 |
| └ completeness | 0.85 |
| └ constraint fidelity | 0.70 |
| └ coordination overhead | 0.85 |
| └ correctness | 0.85 |
| └ downstream usability | 0.80 |
| └ efficiency | 0.75 |
| └ intent fidelity | 0.83 |
| └ style adherence | 0.90 |
Description
Description
audit-tui-viewport (done) and implement-tui-viewport (done) both shipped, but user reports the behavior is still inconsistent — sometimes the viewport focuses the middle of the graph for no apparent reason. Either the implementation didn't fully match the audit's policy, OR the policy itself missed an edge case.
User direct quote 2026-05-01: 'Looks like our attempts to get the TUI to focus on the top of the graph view have been failing. And I just don't quite understand why this is happening. It seems like sometimes it's focusing the total middle of the view.'
User's clarified desired behavior:
'have it so that the active part of the graph is as close to the center view as possible without moving the top of the view off of the top of the graph. We'll end up being anchored at the top in effect almost all the time unless we move it and change our focus. Then our focus goes to whatever we've clicked on. And it's going to stay focused there with that in the center of the view until whenever. And we should only unfocus when we click off of that so as to unselect it somehow.'
User caveat: 'it's possible this is working perfectly now, so I don't want to blow anything up. I just want to understand what's going on.'
Investigation steps
1. Capture the misbehavior
- When the viewport jumps unexpectedly, capture: what task was focused before, what state change just happened, where the viewport ended up (top of view = which task)
- Try to find a reproducible trigger (state transition X → Y produces jump pattern P)
2. Compare implementation against audit's policy
- Read
audit-tui-viewport's log for the recommended policy - Read
implement-tui-viewport's log for what was actually implemented - Diff: where do implementation behavior and audit policy diverge?
3. Specific edge cases the original audit might have missed
- Viewport when graph has fewer rows than visible area (no scroll possible — should viewport behavior just stay top?)
- Viewport when a click selects an OFF-SCREEN task (centered behavior — but how 'centered'?)
- Viewport when scrolling past end of graph (clamping behavior)
- Viewport when graph is dynamically growing at the bottom (new tasks appended) — does it stay anchored to current view, or auto-scroll?
- Multiple state changes in one render frame — does each one move the viewport, or only the last?
4. Recommend either: confirm working OR propose targeted patch
If investigation shows behavior IS correct and user's perception is from rare edge cases (e.g., very fast state changes that briefly produced a jump that self-corrected), document that with evidence.
If investigation shows real misbehavior, propose a concrete targeted patch (file:line + before/after pseudo-code).
Deliverable
wg log entry:
- Reproduction (or 'cannot reproduce' with evidence of trying)
- Comparison: audit policy vs current implementation
- Edge cases identified
- Either 'working as designed, here's why user perceived issue' OR concrete fix proposal
Validation
- Reproduction documented (with concrete steps OR 'cannot reproduce')
- Audit-vs-implementation diff captured
- Edge cases enumerated
- Verdict: working OR concrete fix proposal
- No source / doc modifications — diagnose only
Depends on
Required by
- (none)
Log
- 2026-05-01T15:00:16.343981341+00:00 Task paused
- 2026-05-01T15:01:22.971130444+00:00 Task published
- 2026-05-01T15:02:14.022809085+00:00 Lightweight assignment: agent=Careful Programmer (f5143935), exec_mode=light, context_scope=task, reason=Careful Programmer can diagnose TUI viewport behavior by reading implementation code, comparing against audit policy, and identifying edge cases without modifying source.
- 2026-05-01T15:08:10.987715543+00:00 Spawned by coordinator --executor claude --model opus
- 2026-05-01T15:08:22.429872125+00:00 Starting diagnosis: reading audit and implementation task logs first
- 2026-05-01T15:22:29.801270234+00:00 # Diagnosis: TUI viewport behavior post fix-tui-viewport ## Summary The implementation **partially matches** the audit's policy but **diverges on one specific case**, and the **audit policy itself missed two edge cases** that together explain the user's "sometimes it focuses the total middle of the view" report. The "stays at top when at top" path works correctly. The "viewport quietly drifts to follow off-screen activity" path does not. Root mechanism for the user-reported jumps: when the user is scrolled mid-graph or has a search filter active, the refresh path's anchor logic falls through to `scroll_to_selected_task()` (a comfort-zone scroll) instead of the audit's recommended "no-scroll" sticky default. Combined with `.new_task_focus` silently moving selection on every `wg add`, the viewport gets dragged toward each newly created task — landing in the lower-middle of the visible region. ## 1. Reproduction (not live; reasoned from code paths) I did not capture a live frame-by-frame jump in this session. The reasoning below is grounded in the code paths the audit enumerated and the implementation diff. Trigger sequence that produces the user's "middle of view" perception: 1. User is scrolled mid-graph (`offset_y > 0`, e.g. 5). 2. Coordinator (or any caller of `wg add`) creates a new task. `wg add` writes `.new_task_focus` (src/commands/add.rs:689; ipc.rs:1330 for the IPC path). 3. TUI fs-watcher / 1s slow tick fires `apply_viz_result`. 4. `check_new_task_focus()` (state.rs:5729, definition at 6168-6196) consumes the marker and **forcibly sets `selected_task_idx` to the new task** — user did not click. `new_task_focused = true`. 5. Ladder reaches the `new_task_focused` branch (state.rs:5772-5782). Since `offset_y != 0`, sets `needs_scroll_into_view = true`. 6. `render.rs:435-437` consumes the flag and calls `scroll_to_selected_task()`, which scrolls minimally to land the new task at row `vh - vh/5 - 1` (~80% from top — i.e. lower-middle). 7. Subsequent refreshes: now selection_unchanged is true, relative-position anchor preserves the new task at its lower-middle row. **The viewport appears stuck "in the middle."** Net effect: every coordinator-issued `wg add` while user is mid-scroll moves the viewport. The user has no signal of what changed because they didn't click anything. ## 2. Audit-vs-Implementation diff | Audit edge-case row | Audit recommendation | Implementation behavior | Match? | |---|---|---|---| | User-near-top + new task | offset_y stays at 0 | `if offset_y != 0 { needs_scroll_into_view }` keeps it at 0 | ✅ | | User mid-scroll + new task off-screen | **"no-scroll, since user is reading something else; surface only via toast + splash"** | `needs_scroll_into_view = true` → comfort-zone scroll fires | ❌ **DIVERGES** | | New task already on-screen | No scroll | `scroll_to_selected_task()` is a no-op when in comfort zone, so no scroll | ✅ | | State transition of visible task | Anchor preserves position | selection_unchanged → relative anchor | ✅ | | Smart-follow strict bottom only | Tighten `+3` slack to exact bottom | `is_at_bottom_strict()` introduced (state.rs:17263-17270), used at 5566 | ✅ | | `center_on_selected_task` only from explicit follow | Removed from refresh path | `needs_center_on_selected = true` is never set anywhere now (grep confirms only `false` initializers) — dead in practice | ✅ | | Click-on-task does not scroll | Keep | `select_task_at_line` (6133-6166) does not scroll | ✅ | The single divergence is the user-mid-scroll case. Audit said "no-scroll". Implementation said "comfort-zone scroll". ## 3. Edge cases the audit missed ### 3a. Anchor fallback drops to `scroll_to_selected_task` state.rs:5752-5771 — when the relative-position anchor cannot be computed (`anchored = None`), the fallback calls `scroll_to_selected_task()`. This fires when: - `new_selected_id` is None (selection cleared because old task was filtered/removed). - `node_line_map.get(id)` returns None (selected task is hidden — e.g., system-tasks toggle off, internal `.`-task that was just shown then hidden). - `original_to_visible(line)` returns None (a search filter is active and the line was filtered out). In any of these cases the viewport jumps — to wherever `scroll_to_selected_task` puts the selection. The audit's policy table did not list this case. ### 3b. `.new_task_focus` overrides selection without user click The user's clarified desired behavior was: > "We'll end up being anchored at the top in effect almost all the time > unless we move it and change our focus. Then our focus goes to whatever > we've clicked on." The implementation has **no concept of explicit focus**. `selected_task_idx` is always `Some(...)` after initial load (defaulted to 0 at state.rs:5722-5725), and `.new_task_focus` (written by every non-internal `wg add`) silently replaces it. The user perceives this as the system "focusing" tasks they didn't click on. The audit recognized the marker mechanism but did not flag the selection-vs-focus semantic mismatch. ### 3c. Smart-follow `was_at_bottom` triggers when `content_height <= viewport_height` state.rs:17268 — `is_at_bottom_strict` returns `true` when `content_height <= viewport_height` (graph fits entirely in viewport). This means small graphs always satisfy `was_at_bottom`, so `go_bottom()` fires (state.rs:5750). But on a small graph `go_bottom()` clamps to `max_y = 0` so the viewport stays at top. **Net behavior is correct, but the logic is confusing** — and means `was_at_bottom && !new_task_focused` short-circuits the `selection_unchanged` relative-anchor on every refresh of small graphs. This isn't a user-visible bug today but is a fragile interaction. ### 3d. Multiple state changes per render frame The audit asked but didn't answer this. Inspection: each `apply_viz_result` call computes one ladder decision. fs-watcher and 1s tick can both fire within the same frame; the most recent wins. `.new_task_focus` is a single-shot file (consumed on read), so two new tasks added in quick succession lose their marker if the fs-watcher debounces. This produces non-deterministic behavior with rapid `wg add` from a coordinator. ### 3e. Viewport when graph reorders `apply_sort_mode` (state.rs:7797-7856) only reorders `task_order` (navigation index), not `node_line_map` or `lines` (visual layout). So sort changes do NOT move tasks visually. **Verified non-issue.** ### 3f. Viewport when graph dynamically grows at the bottom With `is_at_bottom_strict()` and the ladder, this works: only exact-bottom users follow. **Verified by `exact_bottom_triggers_smart_follow` test (passed locally).** ## 4. Verdict **Real misbehavior — concrete fix proposed.** The 8 implemented unit tests pass and verify the cases the audit enumerated. But two paths the audit didn't specify (3a, 3b) still produce the user-reported jumps. ## 5. Concrete fix proposal ### Fix A (small, low risk): align mid-scroll with audit policy state.rs:5772-5786 — replace the comfort-zone-scroll fallback with no-scroll. ```rust } else if new_task_focused { // Sticky default: do not move the viewport on `.new_task_focus`. // Toast + splash already announce the new task. Only the user's // explicit click should re-target the viewport. // (No-op: leave offset_y unchanged.) } ``` This changes one user-visible behavior: a `wg add` of an off-screen task while the user is mid-scroll will no longer scroll the new task into view. The user must press End / use search / click to reach it. Per the user's clarified quote ("anchored at the top in effect almost all the time unless we move it and change our focus"), this is the desired behavior. ### Fix B (small, low risk): make the anchor fallback non-jumpy state.rs:5766-5771 — when the relative anchor fails (`anchored = None`), do not call `scroll_to_selected_task()`. Instead leave the viewport at `old_offset_y` (clamped to current bounds). ```rust } else { // Fallback: restore old offset only. Do NOT scroll-to-selected, // which can produce a surprise jump when the selected task // disappeared / was filtered out. self.scroll.offset_y = old_offset_y; self.scroll.clamp(); } ``` This trades one corner case (selection scrolled out of view because the fallback didn't follow it) for another (viewport stable but selection may not be visible). The user's quote favors stability — they can press arrow keys to bring selection back into view if they care. ### Fix C (medium, addresses 3b): introduce "no-selection" state The user's mental model needs an explicit click → focus → centered behavior distinct from selection. Two options: **C1 (minimal):** add a `focused: bool` field. `focused=true` only when set by an explicit user click on a task; cleared by a click on empty area or by pressing Esc. Refresh path: - focused=true → `needs_center_on_selected` (existing primitive) - focused=false → top-anchor only (offset_y=0 unless user scrolled) **C2 (recommended, matches user quote):** decouple `selected_task_idx` (navigation cursor — moves with arrow keys, used by `wg open` / commands) from `focused_task_id` (Option<String> — set on click, cleared on click-out). `.new_task_focus` should NOT touch `focused_task_id` (it can still set `selected_task_idx` for navigation continuity). The viewport policy becomes: - Initial load → top - focused_task_id is Some → keep it as close to vh/2 as possible without pushing offset_y < 0. (User's literal request: "as close to the center view as possible without moving the top of the view off of the top of the graph.") - focused_task_id is None → top-anchor (offset_y = 0) - Smart-follow on exact bottom → keep (this is the only auto-pull) C2 is a larger change but it eliminates the conceptual gap that produces the user's recurring complaints. Worth a separate audit + implement pair. ### Recommended sequencing 1. Land Fix A and Fix B as a small follow-up to fix-tui-viewport — they close the remaining audit-vs-implementation gap and the fallback-jump. 2. Add smoke scenarios `tui-viewport-mid-scroll-no-jump-on-new-task` and `tui-viewport-search-active-no-anchor-fallback` (the fallback case is not currently tested). 3. Open a separate audit task for C2 — the focused-vs-selected redesign. Pair with implement task once user signs off on the model. ## Validation checklist - [x] Reproduction documented (reasoned from code, not live frame capture; explicit step-by-step trigger) - [x] Audit-vs-implementation diff captured (table in §2) - [x] Edge cases enumerated (§3 — 6 cases, 4 of them previously unflagged) - [x] Verdict: real misbehavior + concrete fix proposal (Fix A, B, C with file:line) - [x] No source / doc modifications — diagnose only
- 2026-05-01T15:22:36.188075761+00:00 Validation: deliverable complete. Reproduction (reasoned from code paths), audit-vs-impl table (1 divergence on mid-scroll case), 6 edge cases (3a anchor fallback, 3b focus-vs-selection mismatch new), verdict + 3-tier fix proposal (A: align mid-scroll to audit, B: anchor fallback no-jump, C: focused vs selected redesign). 8 viewport_stability_tests pass locally. No source modifications.
- 2026-05-01T15:22:42.233874522+00:00 Task pending eval (agent reported done; awaiting `.evaluate-*` to score)
- 2026-05-01T15:25:11.793439765+00:00 PendingEval → Done (evaluator passed; downstream unblocks)