fix(console-ui): gate input the instant A starts a wake — optimistic Waking card

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 22:18:37 +02:00
parent 03a2fabff1
commit f470858110
+68 -1
View File
@@ -68,6 +68,12 @@ pub(crate) struct Shell {
pub(crate) in_stream: bool, pub(crate) in_stream: bool,
connecting: Option<Connecting>, connecting: Option<Connecting>,
wake: Option<WakeStatus>, wake: Option<WakeStatus>,
/// 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 ms1 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<Toast>, toast: Option<Toast>,
mesh: RuntimeEffect, mesh: RuntimeEffect,
/// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition. /// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition.
@@ -109,6 +115,7 @@ impl Shell {
in_stream: false, in_stream: false,
connecting: None, connecting: None,
wake: None, wake: None,
wake_optimistic: false,
toast: None, toast: None,
mesh, mesh,
bg_mix, 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 let Some(w) = &self.wake {
if w.online { if w.online {
// Awake: stop the wake loop, and connect if that's what A meant. // Awake: stop the wake loop, and connect if that's what A meant.
@@ -258,6 +274,7 @@ impl Shell {
MenuEvent::Back => { MenuEvent::Back => {
self.bus.send(ConsoleCmd::CancelWake); self.bus.send(ConsoleCmd::CancelWake);
self.wake = None; self.wake = None;
self.wake_optimistic = false;
return Some(MenuPulse::Confirm); return Some(MenuPulse::Confirm);
} }
MenuEvent::Confirm if w.timed_out => { MenuEvent::Confirm if w.timed_out => {
@@ -335,6 +352,29 @@ impl Shell {
fn apply(&mut self, fx: Outbox) { fn apply(&mut self, fx: Outbox) {
for cmd in fx.cmds { 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 ms1 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); self.bus.send(cmd);
} }
if let Some(text) = fx.toast { if let Some(text) = fx.toast {
@@ -966,6 +1006,33 @@ mod tests {
s.motion = Motion::None; 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 /// Render every console scene to PNGs for the eyeball pass (ignored; run with
/// `PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui --release -- --ignored dump`). /// `PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui --release -- --ignored dump`).
/// CPU raster — the SkSL aurora, layers and text all run without a GPU. /// CPU raster — the SkSL aurora, layers and text all run without a GPU.