From f470858110c9a81755468dc015a816e21cfd5beb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 9 Jul 2026 22:18:37 +0200 Subject: [PATCH] =?UTF-8?q?fix(console-ui):=20gate=20input=20the=20instant?= =?UTF-8?q?=20A=20starts=20a=20wake=20=E2=80=94=20optimistic=20Waking=20ca?= =?UTF-8?q?rd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing A on an offline-but-wakeable host only queued ConsoleCmd::Wake; the service thread round-trips its first WakeStatus ~100 ms–1 s later, and until then the shell had no modal state — navigation kept flowing, the cursor drifted (e.g. onto Add Host), the arriving status then froze the UI wherever it sat, and for a fast wake the "Waking…" card was never rendered before the stream abruptly appeared. Every other input-initiated async path already gates synchronously (connect via `connecting`, pair via `busy`, screen pushes via `Motion`); the wake branch was the unique offender. `apply` now raises an optimistic WakeStatus placeholder in the same call that forwards the command — the Waking card shows immediately and the wake modal gate engages on the very next event. `sync` keeps the placeholder alive until the service's first real status supersedes it (tracked via `wake_optimistic`), and B-cancel clears both. Regression test replays the exact A-then-navigate sequence. Co-Authored-By: Claude Fable 5 --- crates/pf-console-ui/src/shell.rs | 69 ++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/pf-console-ui/src/shell.rs b/crates/pf-console-ui/src/shell.rs index 7b0a25c1..b755b8e2 100644 --- a/crates/pf-console-ui/src/shell.rs +++ b/crates/pf-console-ui/src/shell.rs @@ -68,6 +68,12 @@ pub(crate) struct Shell { pub(crate) in_stream: bool, connecting: Option, wake: Option, + /// True while `wake` is the shell's own optimistic placeholder — raised the instant a + /// screen queues `ConsoleCmd::Wake` (see [`Self::apply`]), before the service thread has + /// round-tripped its first real `WakeStatus` (~100 ms–1 s). `sync` must not clear the + /// placeholder in that window, or navigation would race the wake ungated (the "pressed A, + /// cursor drifted to Add Host, then got thrust into the stream" bug). + wake_optimistic: bool, toast: Option, mesh: RuntimeEffect, /// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition. @@ -109,6 +115,7 @@ impl Shell { in_stream: false, connecting: None, wake: None, + wake_optimistic: false, toast: None, mesh, bg_mix, @@ -204,7 +211,16 @@ impl Shell { } } - self.wake = self.console.wake(); + match self.console.wake() { + Some(w) => { + self.wake_optimistic = false; + self.wake = Some(w); + } + // No service status yet: keep an optimistic placeholder alive — clearing it here + // would reopen the ungated window it exists to close. + None if !self.wake_optimistic => self.wake = None, + None => {} + } if let Some(w) = &self.wake { if w.online { // Awake: stop the wake loop, and connect if that's what A meant. @@ -258,6 +274,7 @@ impl Shell { MenuEvent::Back => { self.bus.send(ConsoleCmd::CancelWake); self.wake = None; + self.wake_optimistic = false; return Some(MenuPulse::Confirm); } MenuEvent::Confirm if w.timed_out => { @@ -335,6 +352,29 @@ impl Shell { fn apply(&mut self, fx: Outbox) { for cmd in fx.cmds { + // An input-initiated wake must gate input in the SAME call, exactly like + // `start_connect` gates via `connecting`: the service's first WakeStatus is + // ~100 ms–1 s away, and until it lands the screen would keep navigating — + // then the arriving status freezes the UI wherever the cursor drifted, with + // the "Waking…" card never shown for a fast wake. Raise it optimistically; + // `sync` lets the service's real status supersede this placeholder. + if let ConsoleCmd::Wake { key, then_connect } = &cmd { + let name = self + .hosts + .iter() + .find(|h| &h.key == key) + .map(|h| h.name.clone()) + .unwrap_or_default(); + self.wake = Some(WakeStatus { + key: key.clone(), + name, + seconds: 0, + timed_out: false, + online: false, + then_connect: *then_connect, + }); + self.wake_optimistic = true; + } self.bus.send(cmd); } if let Some(text) = fx.toast { @@ -966,6 +1006,33 @@ mod tests { s.motion = Motion::None; } + #[test] + fn wake_gates_input_in_the_same_press() { + let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]); + s.sync(); + // Focus "Office Tower" (offline + wakeable), then A: the wake starts. + s.handle_menu(MenuEvent::Move(MenuDir::Right)); + s.handle_menu(MenuEvent::Confirm); + let w = s + .wake + .as_ref() + .expect("Waking card raised in the SAME call as the A press"); + assert_eq!(w.name, "Office Tower"); + assert!(!w.online); + // The very next input is modal-gated — the cursor can't drift onto Add Host — + // and sync (which runs first in handle_menu) must not clear the placeholder + // before the service thread reports its first real status. + assert!(s.handle_menu(MenuEvent::Move(MenuDir::Right)).is_none()); + assert!( + s.wake.is_some(), + "optimistic card survived a sync with no service status" + ); + // B cancels: the gate releases and navigation works again. + s.handle_menu(MenuEvent::Back); + assert!(s.wake.is_none()); + assert!(s.handle_menu(MenuEvent::Move(MenuDir::Left)).is_some()); + } + /// Render every console scene to PNGs for the eyeball pass (ignored; run with /// `PF_CONSOLE_DUMP= cargo test -p pf-console-ui --release -- --ignored dump`). /// CPU raster — the SkSL aurora, layers and text all run without a GPU.