| 1 | //! Status surface state: toast queue, sticky status, and the |
| 2 | //! `status_message` -> toast synchronization helpers. |
| 3 | //! |
| 4 | //! `StatusToast` / `StatusToastLevel` live here with the `impl App` |
| 5 | //! extension block that owns toast/status/message behavior; state fields |
| 6 | //! (`status_toasts`, `sticky_status`, `status_message`, |
| 7 | //! `last_status_message_seen`) remain on `App` in `app.rs`. |
| 8 | |
| 9 | use super::*; |
| 10 | |
| 11 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 12 | pub enum StatusToastLevel { |
| 13 | Info, |
| 14 | Success, |
| 15 | Warning, |
| 16 | Error, |
| 17 | } |
| 18 | |
| 19 | impl StatusToastLevel { |
| 20 | /// Resolve every toast surface through the same semantic theme slots. |
| 21 | pub(crate) fn ink(self) -> codewhale_palette::ChromeInk { |
| 22 | use codewhale_palette::ChromeInk; |
| 23 | match self { |
| 24 | Self::Info => ChromeInk::Info, |
| 25 | Self::Success => ChromeInk::Outcome, |
| 26 | Self::Warning => ChromeInk::Attention, |
| 27 | Self::Error => ChromeInk::Failure, |
| 28 | } |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | #[derive(Debug, Clone)] |
| 33 | pub struct StatusToast { |
| 34 | pub text: String, |
| 35 | pub level: StatusToastLevel, |
| 36 | pub created_at: Instant, |
| 37 | pub ttl_ms: Option<u64>, |
| 38 | pub(crate) kind: StatusToastKind, |
| 39 | event_id: Option<String>, |
| 40 | } |
| 41 | |
| 42 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 43 | pub(crate) enum StatusToastKind { |
| 44 | Ordinary, |
| 45 | ActionRequired, |
| 46 | RedactionGate(RedactionGateNotice), |
| 47 | BehavioralTip(crate::tui::behavioral_tips::BehavioralTip), |
| 48 | PluginSuggestion, |
| 49 | ContextPressure(crate::context_budget::PressureLevel), |
| 50 | /// A condition that holds until its owner retires it by event id (a |
| 51 | /// session save that keeps failing). It never times out: expiring it |
| 52 | /// would report recovery that has not happened. |
| 53 | Standing, |
| 54 | } |
| 55 | |
| 56 | impl StatusToast { |
| 57 | #[must_use] |
| 58 | pub fn new(text: impl Into<String>, level: StatusToastLevel, ttl_ms: Option<u64>) -> Self { |
| 59 | Self { |
| 60 | text: text.into(), |
| 61 | level, |
| 62 | created_at: Instant::now(), |
| 63 | ttl_ms, |
| 64 | kind: StatusToastKind::Ordinary, |
| 65 | event_id: None, |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | pub(crate) fn for_event(mut self, event_id: impl Into<String>) -> Self { |
| 70 | self.event_id = Some(event_id.into()); |
| 71 | self |
| 72 | } |
| 73 | |
| 74 | /// A notice for a standing condition, withdrawn only through |
| 75 | /// [`App::retire_event_notices`]`(event_id)` once the condition clears. |
| 76 | #[must_use] |
| 77 | pub(crate) fn standing( |
| 78 | text: impl Into<String>, |
| 79 | level: StatusToastLevel, |
| 80 | event_id: impl Into<String>, |
| 81 | ) -> Self { |
| 82 | Self { |
| 83 | text: text.into(), |
| 84 | level, |
| 85 | created_at: Instant::now(), |
| 86 | ttl_ms: None, |
| 87 | kind: StatusToastKind::Standing, |
| 88 | event_id: Some(event_id.into()), |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | pub(crate) fn for_action(mut self, request_id: impl Into<String>) -> Self { |
| 93 | self.kind = StatusToastKind::ActionRequired; |
| 94 | self.event_id = Some(request_id.into()); |
| 95 | self |
| 96 | } |
| 97 | |
| 98 | pub(crate) fn for_redaction_gate(mut self, notice: RedactionGateNotice) -> Self { |
| 99 | self.kind = StatusToastKind::RedactionGate(notice); |
| 100 | self |
| 101 | } |
| 102 | |
| 103 | #[must_use] |
| 104 | pub(crate) fn context_pressure( |
| 105 | text: impl Into<String>, |
| 106 | level: crate::context_budget::PressureLevel, |
| 107 | ) -> Self { |
| 108 | Self { |
| 109 | text: text.into(), |
| 110 | level: StatusToastLevel::Warning, |
| 111 | created_at: Instant::now(), |
| 112 | ttl_ms: None, |
| 113 | kind: StatusToastKind::ContextPressure(level), |
| 114 | event_id: None, |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | #[must_use] |
| 119 | pub fn is_expired(&self, now: Instant) -> bool { |
| 120 | self.ttl_ms.is_some_and(|ttl| { |
| 121 | now.saturating_duration_since(self.created_at).as_millis() >= u128::from(ttl) |
| 122 | }) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | impl App { |
| 127 | pub fn push_status_toast( |
| 128 | &mut self, |
| 129 | text: impl Into<String>, |
| 130 | level: StatusToastLevel, |
| 131 | ttl_ms: Option<u64>, |
| 132 | ) { |
| 133 | self.push_status_toast_record(StatusToast::new(text, level, ttl_ms)); |
| 134 | } |
| 135 | |
| 136 | /// Coalesce a still-visible duplicate without renewing its first expiry. |
| 137 | /// Decision identities keep otherwise identical requests independent. |
| 138 | pub(crate) fn push_status_toast_record(&mut self, mut toast: StatusToast) { |
| 139 | // An omitted lifetime used to leave routine notices in the queue |
| 140 | // forever, resurfacing after newer notices expired. Pending actions |
| 141 | // and safety gates have explicit kinds and keep their own lifecycle. |
| 142 | if toast.kind == StatusToastKind::Ordinary && toast.ttl_ms.is_none() { |
| 143 | toast.ttl_ms = Some(Self::STICKY_ERROR_TTL_MS); |
| 144 | } |
| 145 | self.prune_expired_status_toasts(toast.created_at); |
| 146 | if self.status_toasts.iter().any(|existing| { |
| 147 | existing.level == toast.level |
| 148 | && existing.text == toast.text |
| 149 | && existing.kind == toast.kind |
| 150 | && existing.event_id == toast.event_id |
| 151 | }) { |
| 152 | return; |
| 153 | } |
| 154 | self.status_toasts.push_back(toast); |
| 155 | // Overflow drops the oldest notice that is not a standing condition; |
| 156 | // those leave only when their condition clears. |
| 157 | while self.status_toasts.len() > 24 { |
| 158 | let oldest = self |
| 159 | .status_toasts |
| 160 | .iter() |
| 161 | .position(|toast| toast.kind != StatusToastKind::Standing) |
| 162 | .unwrap_or(0); |
| 163 | self.status_toasts.remove(oldest); |
| 164 | } |
| 165 | self.needs_redraw = true; |
| 166 | } |
| 167 | |
| 168 | /// Retire requests, never their denial/error outcomes. `None` settles |
| 169 | /// requests for the whole finished/cancelled turn. |
| 170 | pub(crate) fn retire_action_notices(&mut self, request_id: Option<&str>) { |
| 171 | let before = self.status_toasts.len(); |
| 172 | self.status_toasts.retain(|toast| { |
| 173 | toast.kind != StatusToastKind::ActionRequired |
| 174 | || request_id.is_some_and(|id| toast.event_id.as_deref() != Some(id)) |
| 175 | }); |
| 176 | self.needs_redraw |= self.status_toasts.len() != before; |
| 177 | } |
| 178 | |
| 179 | /// Retire the notices pushed with [`StatusToast::for_event`]`(event_id)`. |
| 180 | pub(crate) fn retire_event_notices(&mut self, event_id: &str) { |
| 181 | let before = self.status_toasts.len(); |
| 182 | self.status_toasts |
| 183 | .retain(|toast| toast.event_id.as_deref() != Some(event_id)); |
| 184 | self.needs_redraw |= self.status_toasts.len() != before; |
| 185 | } |
| 186 | |
| 187 | /// Gate transitions retire their own guidance or failed-write receipt, |
| 188 | /// independently of translated text and unrelated requests/errors. |
| 189 | pub(crate) fn retire_redaction_gate_notice(&mut self, notice: RedactionGateNotice) { |
| 190 | let before = self.status_toasts.len(); |
| 191 | self.status_toasts |
| 192 | .retain(|toast| toast.kind != StatusToastKind::RedactionGate(notice)); |
| 193 | self.needs_redraw |= self.status_toasts.len() != before; |
| 194 | } |
| 195 | |
| 196 | /// Default lifetime for sticky error toasts. Long enough to read, short |
| 197 | /// enough that a failed workflow does not permanently occupy footer chrome. |
| 198 | pub const STICKY_ERROR_TTL_MS: u64 = 8_000; |
| 199 | |
| 200 | pub fn set_sticky_status( |
| 201 | &mut self, |
| 202 | text: impl Into<String>, |
| 203 | level: StatusToastLevel, |
| 204 | ttl_ms: Option<u64>, |
| 205 | ) { |
| 206 | let text = text.into(); |
| 207 | if self.sticky_status.as_ref().is_some_and(|existing| { |
| 208 | existing.text == text |
| 209 | && existing.level == level |
| 210 | && existing.kind == StatusToastKind::Ordinary |
| 211 | && !existing.is_expired(Instant::now()) |
| 212 | }) { |
| 213 | return; |
| 214 | } |
| 215 | // Cap sticky errors so a missing TTL never becomes permanent chrome. |
| 216 | // Explicit shorter TTLs still win; longer/None fall back to the default. |
| 217 | let ttl_ms = match level { |
| 218 | StatusToastLevel::Error => Some( |
| 219 | ttl_ms |
| 220 | .unwrap_or(Self::STICKY_ERROR_TTL_MS) |
| 221 | .min(Self::STICKY_ERROR_TTL_MS), |
| 222 | ), |
| 223 | _ => ttl_ms.or(Some(Self::STICKY_ERROR_TTL_MS)), |
| 224 | }; |
| 225 | self.sticky_status = Some(StatusToast::new(text, level, ttl_ms)); |
| 226 | self.needs_redraw = true; |
| 227 | } |
| 228 | |
| 229 | pub fn clear_sticky_status(&mut self) { |
| 230 | if self.sticky_status.take().is_some() { |
| 231 | self.needs_redraw = true; |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | /// Dismiss the persistent context-pressure warning without dismissing |
| 236 | /// unrelated error/status chrome. Returns whether anything was cleared. |
| 237 | pub fn dismiss_context_pressure_warning(&mut self) -> bool { |
| 238 | let is_context_pressure = self |
| 239 | .sticky_status |
| 240 | .as_ref() |
| 241 | .is_some_and(|status| matches!(status.kind, StatusToastKind::ContextPressure(_))); |
| 242 | if is_context_pressure { |
| 243 | if self.status_message.as_ref() == self.sticky_status.as_ref().map(|toast| &toast.text) |
| 244 | { |
| 245 | self.status_message = None; |
| 246 | self.last_status_message_seen = None; |
| 247 | } |
| 248 | if let Some(StatusToastKind::ContextPressure(level)) = |
| 249 | self.sticky_status.as_ref().map(|status| status.kind) |
| 250 | { |
| 251 | self.context_pressure_warning_dismissed = Some(level); |
| 252 | } |
| 253 | self.clear_sticky_status(); |
| 254 | return true; |
| 255 | } |
| 256 | false |
| 257 | } |
| 258 | |
| 259 | /// Drop sticky error chrome when the user resumes typing so a prior |
| 260 | /// workflow/provider failure does not linger over the next draft. |
| 261 | pub fn acknowledge_sticky_on_composer_activity(&mut self) { |
| 262 | if self |
| 263 | .sticky_status |
| 264 | .as_ref() |
| 265 | .is_some_and(|toast| matches!(toast.level, StatusToastLevel::Error)) |
| 266 | { |
| 267 | self.clear_sticky_status(); |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | pub(super) fn classify_status_text(text: &str) -> (StatusToastLevel, Option<u64>, bool) { |
| 272 | let lower = text.to_ascii_lowercase(); |
| 273 | let has = |needle: &str| lower.contains(needle); |
| 274 | |
| 275 | if has("offline mode") || has("context critical") { |
| 276 | return (StatusToastLevel::Warning, None, true); |
| 277 | } |
| 278 | if has("error") |
| 279 | || has("failed") |
| 280 | || has("denied") |
| 281 | || has("timeout") |
| 282 | || has("aborted") |
| 283 | || has("critical") |
| 284 | { |
| 285 | return ( |
| 286 | StatusToastLevel::Error, |
| 287 | Some(Self::STICKY_ERROR_TTL_MS), |
| 288 | true, |
| 289 | ); |
| 290 | } |
| 291 | // A success keyword under a negation ("not saved", "no longer |
| 292 | // found", "could not enable") is a failure the coarse keyword match |
| 293 | // would otherwise paint green. Guard it: negated success degrades to |
| 294 | // a neutral Info toast rather than a misleading Success. |
| 295 | let negated = has("not ") |
| 296 | || has("no longer") |
| 297 | || has("no ") |
| 298 | || has("could not") |
| 299 | || has("couldn't") |
| 300 | || has("cannot") |
| 301 | || has("can't") |
| 302 | || has("unable"); |
| 303 | if !negated |
| 304 | && (has("saved") |
| 305 | || has("loaded") |
| 306 | || has("queued") |
| 307 | || has("found") |
| 308 | || has("enabled") |
| 309 | || has("completed")) |
| 310 | { |
| 311 | return (StatusToastLevel::Success, Some(5_000), false); |
| 312 | } |
| 313 | if has("cancelled") || has("canceled") || has("warning") { |
| 314 | return (StatusToastLevel::Warning, Some(5_000), false); |
| 315 | } |
| 316 | (StatusToastLevel::Info, Some(4_000), false) |
| 317 | } |
| 318 | |
| 319 | fn is_mode_switch_status_message(message: &str) -> bool { |
| 320 | message.starts_with("Switched to ") && message.ends_with(" mode") |
| 321 | } |
| 322 | |
| 323 | pub fn sync_status_message_to_toasts(&mut self) { |
| 324 | let current = self.status_message.clone(); |
| 325 | if self.last_status_message_seen == current { |
| 326 | return; |
| 327 | } |
| 328 | self.last_status_message_seen = current.clone(); |
| 329 | |
| 330 | let Some(message) = current else { |
| 331 | return; |
| 332 | }; |
| 333 | if message.trim().is_empty() { |
| 334 | return; |
| 335 | } |
| 336 | if Self::is_mode_switch_status_message(&message) { |
| 337 | return; |
| 338 | } |
| 339 | let now = Instant::now(); |
| 340 | // A typed producer already owns this notice. The legacy adapter must |
| 341 | // not reclassify tool text or create a second sticky/queued copy. |
| 342 | if self |
| 343 | .status_toasts |
| 344 | .iter() |
| 345 | .chain(self.sticky_status.iter()) |
| 346 | .any(|toast| toast.text == message && !toast.is_expired(now)) |
| 347 | { |
| 348 | return; |
| 349 | } |
| 350 | |
| 351 | let (level, ttl_ms, sticky) = Self::classify_status_text(&message); |
| 352 | if sticky { |
| 353 | self.set_sticky_status(message, level, ttl_ms); |
| 354 | } else { |
| 355 | // A routine success ("Auto-compaction enabled") must not clear |
| 356 | // the one error that says no model is connected: nothing but |
| 357 | // connecting a provider resolves it (U1). |
| 358 | if matches!(level, StatusToastLevel::Success) |
| 359 | && !self.onboarding_needs_api_key |
| 360 | && self |
| 361 | .sticky_status |
| 362 | .as_ref() |
| 363 | .is_some_and(|toast| matches!(toast.level, StatusToastLevel::Error)) |
| 364 | { |
| 365 | self.clear_sticky_status(); |
| 366 | } |
| 367 | self.push_status_toast(message, level, ttl_ms); |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | fn prune_expired_status_toasts(&mut self, now: Instant) { |
| 372 | let queued_before = self.status_toasts.len(); |
| 373 | self.status_toasts.retain(|toast| !toast.is_expired(now)); |
| 374 | let queued_removed = self.status_toasts.len() != queued_before; |
| 375 | let sticky_removed = self |
| 376 | .sticky_status |
| 377 | .as_ref() |
| 378 | .is_some_and(|toast| toast.is_expired(now)); |
| 379 | if sticky_removed { |
| 380 | self.sticky_status = None; |
| 381 | } |
| 382 | if queued_removed || sticky_removed { |
| 383 | self.needs_redraw = true; |
| 384 | } |
| 385 | } |
| 386 | |
| 387 | pub fn active_status_toast( |
| 388 | &mut self, |
| 389 | phase: crate::tui::underwater::ShellPhase, |
| 390 | ) -> Option<StatusToast> { |
| 391 | self.sync_status_message_to_toasts(); |
| 392 | let now = Instant::now(); |
| 393 | self.prune_expired_status_toasts(now); |
| 394 | |
| 395 | let eligible = |toast: &&StatusToast| { |
| 396 | phase != crate::tui::underwater::ShellPhase::Done |
| 397 | || matches!( |
| 398 | toast.level, |
| 399 | StatusToastLevel::Warning | StatusToastLevel::Error |
| 400 | ) |
| 401 | }; |
| 402 | let sticky = self.sticky_status.as_ref().filter(eligible).cloned(); |
| 403 | let latest = self.status_toasts.iter().rev().find(eligible).cloned(); |
| 404 | match (sticky, latest) { |
| 405 | (Some(sticky), Some(latest)) |
| 406 | if matches!( |
| 407 | sticky.kind, |
| 408 | StatusToastKind::ContextPressure(crate::context_budget::PressureLevel::High) |
| 409 | | StatusToastKind::ContextPressure( |
| 410 | crate::context_budget::PressureLevel::Medium |
| 411 | ) |
| 412 | ) => |
| 413 | { |
| 414 | Some(latest) |
| 415 | } |
| 416 | (Some(sticky), _) => Some(sticky), |
| 417 | (None, latest) => latest, |
| 418 | } |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | #[cfg(test)] |
| 423 | mod tests { |
| 424 | use super::*; |
| 425 | use std::time::Duration; |
| 426 | |
| 427 | fn app() -> App { |
| 428 | App::new( |
| 429 | crate::test_support::test_tui_options(std::path::PathBuf::from(".")), |
| 430 | &crate::config::Config::default(), |
| 431 | ) |
| 432 | } |
| 433 | |
| 434 | #[test] |
| 435 | fn live_toast_duplicates_keep_first_expiry_and_distinct_decisions() { |
| 436 | let mut app = app(); |
| 437 | let now = Instant::now(); |
| 438 | let record = |id: &str, at: Instant| { |
| 439 | let mut toast = StatusToast::new( |
| 440 | "Review this request", |
| 441 | StatusToastLevel::Warning, |
| 442 | Some(2_000), |
| 443 | ) |
| 444 | .for_action(id); |
| 445 | toast.created_at = at; |
| 446 | toast |
| 447 | }; |
| 448 | app.push_status_toast_record(record("a", now)); |
| 449 | app.push_status_toast_record(record("a", now + Duration::from_millis(1_000))); |
| 450 | assert_eq!(app.status_toasts.len(), 1); |
| 451 | assert_eq!(app.status_toasts[0].created_at, now); |
| 452 | app.push_status_toast_record(record("b", now + Duration::from_millis(1_000))); |
| 453 | assert_eq!(app.status_toasts.len(), 2); |
| 454 | app.push_status_toast_record(record("a", now + Duration::from_millis(2_000))); |
| 455 | assert_eq!( |
| 456 | app.status_toasts.len(), |
| 457 | 2, |
| 458 | "expired a is replaced; independent b survives" |
| 459 | ); |
| 460 | assert_eq!( |
| 461 | app.status_toasts.back().unwrap().created_at, |
| 462 | now + Duration::from_millis(2_000) |
| 463 | ); |
| 464 | } |
| 465 | |
| 466 | #[test] |
| 467 | fn routine_notices_expire_without_dismissing_pending_actions_or_safety_gates() { |
| 468 | let mut app = app(); |
| 469 | let now = Instant::now(); |
| 470 | app.push_status_toast("Temporary warning", StatusToastLevel::Warning, None); |
| 471 | app.push_status_toast("Copied", StatusToastLevel::Info, None); |
| 472 | app.set_sticky_status("Offline mode", StatusToastLevel::Warning, None); |
| 473 | app.push_status_toast_record( |
| 474 | StatusToast::new("Review request", StatusToastLevel::Warning, None) |
| 475 | .for_action("pending"), |
| 476 | ); |
| 477 | app.push_status_toast_record( |
| 478 | StatusToast::new("Review redaction", StatusToastLevel::Warning, None) |
| 479 | .for_redaction_gate(RedactionGateNotice::WriteFailure), |
| 480 | ); |
| 481 | app.prune_expired_status_toasts(now + Duration::from_secs(10)); |
| 482 | assert!(app.sticky_status.is_none()); |
| 483 | assert_eq!(app.status_toasts.len(), 2); |
| 484 | assert_eq!(app.status_toasts[0].kind, StatusToastKind::ActionRequired); |
| 485 | assert_eq!( |
| 486 | app.status_toasts[1].kind, |
| 487 | StatusToastKind::RedactionGate(RedactionGateNotice::WriteFailure) |
| 488 | ); |
| 489 | } |
| 490 | |
| 491 | #[test] |
| 492 | fn legacy_status_does_not_reclassify_a_typed_notice_or_repeat_a_live_notice() { |
| 493 | let mut app = app(); |
| 494 | let text = "承認してください · failed-tool"; |
| 495 | app.push_status_toast(text, StatusToastLevel::Warning, Some(12_000)); |
| 496 | let first = app.status_toasts[0].created_at; |
| 497 | for message in [text, "another status", text] { |
| 498 | app.status_message = Some(message.into()); |
| 499 | app.sync_status_message_to_toasts(); |
| 500 | } |
| 501 | assert_eq!(app.status_toasts.len(), 2); |
| 502 | assert_eq!(app.status_toasts[0].level, StatusToastLevel::Warning); |
| 503 | assert_eq!(app.status_toasts[0].created_at, first); |
| 504 | assert!( |
| 505 | app.sticky_status.is_none(), |
| 506 | "tool data must not create an inferred error" |
| 507 | ); |
| 508 | } |
| 509 | |
| 510 | #[test] |
| 511 | fn repeated_sticky_error_does_not_renew_its_expiry() { |
| 512 | let mut app = app(); |
| 513 | app.set_sticky_status("same failure", StatusToastLevel::Error, None); |
| 514 | let created_at = app.sticky_status.as_ref().unwrap().created_at; |
| 515 | app.set_sticky_status("same failure", StatusToastLevel::Error, None); |
| 516 | assert_eq!(app.sticky_status.as_ref().unwrap().created_at, created_at); |
| 517 | app.sticky_status.as_mut().unwrap().created_at = created_at - Duration::from_secs(10); |
| 518 | app.set_sticky_status("same failure", StatusToastLevel::Error, None); |
| 519 | assert!(app.sticky_status.as_ref().unwrap().created_at >= created_at); |
| 520 | } |
| 521 | |
| 522 | #[test] |
| 523 | fn retiring_action_notices_preserves_other_requests_and_outcome_receipts() { |
| 524 | let mut app = app(); |
| 525 | for id in ["a", "b"] { |
| 526 | app.push_status_toast_record( |
| 527 | StatusToast::new("Review", StatusToastLevel::Warning, None).for_action(id), |
| 528 | ); |
| 529 | } |
| 530 | app.push_status_toast_record( |
| 531 | StatusToast::new("Denied", StatusToastLevel::Warning, None).for_event("a"), |
| 532 | ); |
| 533 | app.retire_action_notices(Some("a")); |
| 534 | assert_eq!(app.status_toasts.len(), 2); |
| 535 | assert_eq!(app.status_toasts[0].event_id.as_deref(), Some("b")); |
| 536 | app.retire_action_notices(None); |
| 537 | assert_eq!(app.status_toasts.len(), 1); |
| 538 | assert_eq!(app.status_toasts[0].text, "Denied"); |
| 539 | } |
| 540 | |
| 541 | #[test] |
| 542 | fn redaction_gate_cleanup_preserves_failed_writes_until_settled_and_unrelated_notices() { |
| 543 | let mut app = app(); |
| 544 | // Deliberately equal translated text: identity must own cleanup. |
| 545 | for notice in [ |
| 546 | RedactionGateNotice::EnterGuidance, |
| 547 | RedactionGateNotice::WriteFailure, |
| 548 | ] { |
| 549 | app.push_status_toast_record( |
| 550 | StatusToast::new("確認してください", StatusToastLevel::Warning, None) |
| 551 | .for_redaction_gate(notice), |
| 552 | ); |
| 553 | } |
| 554 | app.push_status_toast("確認してください", StatusToastLevel::Warning, None); |
| 555 | app.push_status_toast_record( |
| 556 | StatusToast::new("Review", StatusToastLevel::Warning, None).for_action("request"), |
| 557 | ); |
| 558 | app.set_sticky_status("Unrelated failure", StatusToastLevel::Error, None); |
| 559 | app.needs_redraw = false; |
| 560 | app.retire_redaction_gate_notice(RedactionGateNotice::EnterGuidance); |
| 561 | assert!(app.needs_redraw); |
| 562 | assert_eq!(app.status_toasts.len(), 3); |
| 563 | assert_eq!( |
| 564 | app.status_toasts[0].kind, |
| 565 | StatusToastKind::RedactionGate(RedactionGateNotice::WriteFailure) |
| 566 | ); |
| 567 | app.retire_redaction_gate_notice(RedactionGateNotice::WriteFailure); |
| 568 | assert_eq!(app.status_toasts.len(), 2); |
| 569 | assert_eq!(app.status_toasts[0].kind, StatusToastKind::Ordinary); |
| 570 | assert_eq!(app.status_toasts[1].kind, StatusToastKind::ActionRequired); |
| 571 | assert_eq!( |
| 572 | app.sticky_status.as_ref().unwrap().text, |
| 573 | "Unrelated failure" |
| 574 | ); |
| 575 | } |
| 576 | } |
| 577 |