Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-15
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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/<your-name>/<branch-slug>`; 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/<your-name>/<branch-slug>`. 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/<your-name>/<branch-slug> --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/<your-name>/<branch-slug> --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).
29 changes: 27 additions & 2 deletions rust/fleet-watcher/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use tuirealm::terminal::{CrosstermTerminalAdapter, TerminalAdapter};
pub enum Msg {
Tick,
Quit,
Resize,
}

#[derive(Debug, Eq, PartialEq, Clone, Hash)]
Expand Down Expand Up @@ -1074,6 +1075,12 @@ impl<F: PrFeed + 'static> AppComponent<Msg, NoUserEvent> for WatcherView<F> {
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,
}
}
Expand Down Expand Up @@ -1108,7 +1115,10 @@ impl Model<CrosstermTerminalAdapter> {
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)
Expand All @@ -1134,7 +1144,7 @@ impl<T: TerminalAdapter> Model<T> {
self.redraw = true;
match msg {
Msg::Quit => self.quit = true,
Msg::Tick => {}
Msg::Tick | Msg::Resize => {}
}
}
}
Expand Down Expand Up @@ -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());
Expand Down