diff --git a/openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/.openspec.yaml b/openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/.openspec.yaml new file mode 100644 index 0000000..9f70866 --- /dev/null +++ b/openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-15 diff --git a/openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/proposal.md b/openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/proposal.md new file mode 100644 index 0000000..b2754c0 --- /dev/null +++ b/openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/proposal.md @@ -0,0 +1,18 @@ +## Why + +`fleet-watcher` rendered narrower than the actual terminal width and didn't reflow when the tmux pane / kitty window was resized. Root cause: `WatcherView` only subscribed to `EventClause::Tick`. When the terminal emitted a `WindowResize` event, the catch-all `_ => None` arm in `on()` swallowed it, so no `Msg` was returned, `Model::update` never set `redraw = true`, and the dashboard waited up to 2 seconds for the next Tick (or stayed at the cached layout indefinitely if the Tick happened to coincide with a no-op draw). + +## What Changes + +- `Msg::Resize` variant added so resize events can be dispatched into the existing update loop without overloading the `Tick` semantics. +- `WatcherView::on()` matches `Event::WindowResize(_, _)` and returns `Some(Msg::Resize)`. +- `WatcherView` subscription list now includes `Sub::new(EventClause::WindowResize, SubClause::Always)` alongside the existing `Tick` subscription so tuirealm actually routes resize events to the component. +- `Model::update()` handles `Msg::Resize` symmetrically with `Msg::Tick` — sets `redraw = true` (already unconditional at the top of the match) without further state changes. ratatui's `Terminal::draw` autoresizes the back buffer on the next call, so the dashboard immediately reflows to fill the new surface. +- Regression test `window_resize_event_triggers_redraw_message` exercises the resize path directly through `WatcherView::on()` and asserts a non-`None` `Msg` is returned. + +## Impact + +- Affected surface: `rust/fleet-watcher/src/main.rs` only. Other fleet TUIs (`fleet-state`, `fleet-waves`, `fleet-plan-tree`, `fleet-tui-poc`) share the same Tick-only subscription pattern and likely have the same bug, but they're out of scope for this PR. The user opted to fix watcher first; a follow-up can mirror the pattern across the family. +- Risk: very low. Adding a Msg variant and a subscription is additive. Existing `Msg::Tick`/`Msg::Quit` behavior is unchanged, all 6 pre-existing tests stay green, and the snapshot rendering is unaffected (it doesn't go through the event loop). +- Rollout: ship with the next `cargo build --release` of `fleet-watcher`. No config flag. Operators don't need to do anything; the watcher will start reflowing on resize automatically. +- No version bump or release-note edit required (internal binary, no published artifact). diff --git a/openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/specs/fleet-watcher-resize-redraw/spec.md b/openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/specs/fleet-watcher-resize-redraw/spec.md new file mode 100644 index 0000000..f380ec5 --- /dev/null +++ b/openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/specs/fleet-watcher-resize-redraw/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: fleet-watcher MUST reflow on terminal resize +`fleet-watcher` SHALL trigger a redraw within one event-loop iteration when the terminal or hosting tmux pane reports a `WindowResize` event, so the dashboard immediately uses the full new width/height instead of waiting for the next periodic tick. + +#### Scenario: WindowResize event produces a redraw-triggering Msg +- **GIVEN** a `WatcherView` mounted in the tuirealm `Application` with both `EventClause::Tick` and `EventClause::WindowResize` subscriptions active +- **WHEN** the terminal backend emits `Event::WindowResize(width, height)` (e.g. tmux pane grew, kitty window resized, or `SIGWINCH` after a layout change) +- **THEN** `WatcherView::on()` SHALL return `Some(Msg::Resize)` (not `None`) +- **AND** `Model::update(Msg::Resize)` SHALL set `redraw = true` +- **AND** the next `Model::view()` SHALL render at the new `frame.area()` so headers, stat cards, the review queue, and the diff sparkline span the full width + +#### Scenario: Existing Tick and Quit paths are unaffected +- **GIVEN** the same mounted `WatcherView` +- **WHEN** `Event::Tick` fires every 2 seconds +- **THEN** `Msg::Tick` SHALL still be returned and the tick counter SHALL advance +- **AND** keyboard `q` / `Esc` SHALL still produce `Msg::Quit` and terminate the app on the next loop iteration diff --git a/openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/tasks.md b/openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/tasks.md new file mode 100644 index 0000000..cbae495 --- /dev/null +++ b/openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/tasks.md @@ -0,0 +1,34 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11`. +- [x] 1.2 Define normative requirements in `specs/fleet-watcher-resize-redraw/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes — added `Msg::Resize` variant, `WindowResize` arm in `WatcherView::on()`, `WindowResize` subscription in `Model::init_app()`, and `Msg::Resize` handling in `Model::update()`. All within `rust/fleet-watcher/src/main.rs`. +- [x] 2.2 Add/update focused regression coverage — new test `window_resize_event_triggers_redraw_message` calls `WatcherView::on(&Event::WindowResize(200, 60))` and asserts the returned `Msg` is `Some(_)`. Verified failure before fix, then green after. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands — `cargo test -p fleet-watcher` from `rust/` → 7 passed, 0 failed (1 new + 6 pre-existing). +- [x] 3.2 Run `openspec validate agent-claude-fleet-watcher-resize-redraw-2026-05-16-01-11 --type change --strict` — "Change ... is valid". +- [x] 3.3 Run `openspec validate --specs` — no main-spec deltas in this change; the requirement lives only on the change's delta spec. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/rust/fleet-watcher/src/main.rs b/rust/fleet-watcher/src/main.rs index 84f0f41..0fff079 100644 --- a/rust/fleet-watcher/src/main.rs +++ b/rust/fleet-watcher/src/main.rs @@ -33,6 +33,7 @@ use tuirealm::terminal::{CrosstermTerminalAdapter, TerminalAdapter}; pub enum Msg { Tick, Quit, + Resize, } #[derive(Debug, Eq, PartialEq, Clone, Hash)] @@ -1074,6 +1075,12 @@ impl AppComponent for WatcherView { self.tick = self.tick.wrapping_add(1); Some(Msg::Tick) } + // Terminal/tmux pane resized — emit a Msg so the Model loop sets + // redraw=true on the same iteration instead of waiting up to 2s + // for the next Tick. ratatui's `Terminal::draw` autoresizes the + // back buffer, so the next draw immediately picks up the new + // width/height and the dashboard reflows to fill the surface. + Event::WindowResize(_, _) => Some(Msg::Resize), _ => None, } } @@ -1108,7 +1115,10 @@ impl Model { app.mount( Id::Watcher, Box::new(WatcherView::default()), - vec![Sub::new(EventClause::Tick, SubClause::Always)], + vec![ + Sub::new(EventClause::Tick, SubClause::Always), + Sub::new(EventClause::WindowResize, SubClause::Always), + ], )?; app.active(&Id::Watcher)?; Ok(app) @@ -1134,7 +1144,7 @@ impl Model { self.redraw = true; match msg { Msg::Quit => self.quit = true, - Msg::Tick => {} + Msg::Tick | Msg::Resize => {} } } } @@ -1293,6 +1303,21 @@ mod tests { assert!(frame.contains("no merged diff activity yet")); } + #[test] + fn window_resize_event_triggers_redraw_message() { + // Regression for the "not full width / not dynamic" complaint: + // WatcherView only subscribed to Tick events, so terminal resize + // events (Event::WindowResize) were swallowed by the catch-all + // `_ => None` arm. The model never set `redraw = true` and the + // dashboard stayed at its previous layout until the next 2s tick. + let mut view = WatcherView::with_feed(StubPrFeed::fixture()); + let msg = view.on(&Event::WindowResize(200, 60)); + assert!( + msg.is_some(), + "WindowResize must produce a Msg so Model::update sets redraw=true" + ); + } + #[test] fn render_sixty_row_snapshot_for_pr_body() { let mut view = WatcherView::with_feed(StubPrFeed::fixture());