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