From 949ceb5a2100484b03e8abe91e55319b465bff61 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 20:13:45 +0900 Subject: [PATCH 1/3] fix: refresh tui after rewind --- crates/tui/src/app.rs | 341 ++++++++++++++++++++++++++++++++--- crates/tui/src/single_pod.rs | 2 +- crates/tui/src/ui.rs | 28 ++- 3 files changed, 343 insertions(+), 28 deletions(-) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 4788b07f..25fb1e84 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -3,7 +3,7 @@ use std::path::Path; use std::time::{Duration, Instant}; use protocol::{ - AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, PodStatus, + AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Method, PodStatus, RewindTarget, RunResult, Segment, }; @@ -69,6 +69,11 @@ pub struct RewindPickerState { pub targets: Vec, pub selected: usize, pub scroll: RewindPickerScroll, + /// True after Enter submitted an authoritative `RewindTo` and before the + /// Pod replies with either `RewindApplied` or `Error`. While set, the + /// picker remains visible but further submits/navigation are ignored so a + /// destructive rewind cannot be queued multiple times by key repeat. + pub applying: bool, } impl RewindPickerState { @@ -79,6 +84,7 @@ impl RewindPickerState { targets, selected, scroll: RewindPickerScroll::default(), + applying: false, } } @@ -276,6 +282,11 @@ pub struct App { /// Dedicated main-view rewind picker state. pub rewind_picker: Option, rewind_request_pending: bool, + /// After a successful rewind restore, ignore any queued live-update events + /// until the authoritative Pod status/snapshot catches up. This prevents + /// old stream tail events that were already in transit from re-polluting the + /// just-restored display. + rewind_refresh_fence: bool, greeting: Option, /// In-TUI mirror of the Pod's session task store, reconstructed /// directly from observed `TaskCreate` / `TaskUpdate` tool calls and @@ -337,6 +348,7 @@ impl App { completion: None, rewind_picker: None, rewind_request_pending: false, + rewind_refresh_fence: false, greeting: None, task_store: TaskStore::new(), task_pane_open: false, @@ -799,6 +811,33 @@ impl App { }); } + fn handle_error(&mut self, code: ErrorCode, message: String) { + let text = format!("[{code:?}] {message}"); + let was_applying = if let Some(picker) = self.rewind_picker.as_mut() { + let applying = picker.applying; + picker.applying = false; + applying + } else { + false + }; + if was_applying { + self.flash_actionbar_notice( + format!("Rewind failed: {text}"), + ActionbarNoticeLevel::Error, + ActionbarNoticeSource::Tui, + Duration::from_secs(6), + ); + } + self.push_error(text); + } + + fn rewind_submit_pending(&self) -> bool { + self.rewind_picker + .as_ref() + .map(|picker| picker.applying) + .unwrap_or(false) + } + fn push_history_item(&mut self, item: &serde_json::Value) { let item_type = item["type"].as_str().unwrap_or(""); match item_type { @@ -927,6 +966,10 @@ impl App { } pub fn handle_pod_event(&mut self, event: Event) -> Option { + if self.rewind_refresh_fence && event_is_stale_after_rewind(&event) { + return None; + } + match event { Event::UserMessage { segments } => { self.turn_index += 1; @@ -1148,7 +1191,7 @@ impl App { self.run_output_tokens += output_tokens.unwrap_or(0); } Event::Error { code, message } => { - self.push_error(format!("[{code:?}] {message}")); + self.handle_error(code, message); } Event::RunEnd { result } => { self.latest_llm_wait_event = None; @@ -1231,10 +1274,12 @@ impl App { greeting, status, } => { + self.rewind_refresh_fence = false; self.restore_snapshot(&entries, greeting); self.set_pod_status(status); } Event::Status { status } => { + self.rewind_refresh_fence = false; self.set_pod_status(status); } Event::Completions { kind, entries } => { @@ -1262,9 +1307,8 @@ impl App { input, summary, } => { - if let Some(greeting) = self.greeting.clone() { - self.restore_snapshot(&entries, greeting); - } + self.restore_rewind_snapshot(&entries); + self.rewind_refresh_fence = true; let restored_composer = if self.input.is_empty() { self.input.replace_with_segments(&input); true @@ -1610,6 +1654,10 @@ impl App { } pub fn request_rewind_picker(&mut self) -> Option { + if self.rewind_submit_pending() { + self.push_command_diagnostic("rewind is already applying; wait for the Pod response"); + return None; + } if !self.connected { self.push_command_diagnostic("cannot rewind before the Pod is connected"); return None; @@ -1629,9 +1677,22 @@ impl App { self.rewind_request_pending = false; } + pub fn cancel_rewind_picker(&mut self) { + if self.rewind_submit_pending() { + self.flash_actionbar_notice( + "Rewind is applying; wait for the Pod response.", + ActionbarNoticeLevel::Warn, + ActionbarNoticeSource::Tui, + Duration::from_secs(3), + ); + return; + } + self.close_rewind_picker(); + } + pub fn rewind_picker_up(&mut self) { if let Some(picker) = self.rewind_picker.as_mut() { - if picker.targets.is_empty() { + if picker.applying || picker.targets.is_empty() { return; } picker.selected = if picker.selected == 0 { @@ -1644,13 +1705,17 @@ impl App { pub fn rewind_picker_down(&mut self) { if let Some(picker) = self.rewind_picker.as_mut() { - if !picker.targets.is_empty() { + if !picker.applying && !picker.targets.is_empty() { picker.selected = (picker.selected + 1) % picker.targets.len(); } } } pub fn submit_rewind_picker(&mut self) -> Option { + if self.rewind_submit_pending() { + self.push_command_diagnostic("rewind is already applying; wait for the Pod response"); + return None; + } if self.paused { self.push_command_diagnostic( "cannot apply rewind while the Pod is paused; resume or wait for idle first", @@ -1666,22 +1731,32 @@ impl App { let Some(picker) = self.rewind_picker.as_ref() else { return None; }; - let Some(target) = picker.selected_target() else { - self.push_command_diagnostic("no rewind target is available"); - return None; - }; - if !target.eligible { - self.push_command_diagnostic( - target - .disabled_reason - .clone() - .unwrap_or_else(|| "rewind target is disabled".into()), - ); + if picker.applying { + self.push_command_diagnostic("rewind is already applying; wait for the Pod response"); return None; } + let (target_id, expected_head_entries) = match picker.selected_target() { + Some(target) if target.eligible => (target.id.clone(), target.expected_head_entries), + Some(target) => { + self.push_command_diagnostic( + target + .disabled_reason + .clone() + .unwrap_or_else(|| "rewind target is disabled".into()), + ); + return None; + } + None => { + self.push_command_diagnostic("no rewind target is available"); + return None; + } + }; + if let Some(picker) = self.rewind_picker.as_mut() { + picker.applying = true; + } Some(Method::RewindTo { - target: target.id.clone(), - expected_head_entries: target.expected_head_entries, + target: target_id, + expected_head_entries, }) } @@ -1836,12 +1911,50 @@ impl App { self.greeting = Some(greeting.clone()); self.context_window = greeting.context_window; self.session_context_tokens = greeting.context_tokens; + self.restore_entries(entries, Some(greeting)); + } + + /// Restore after a successful destructive rewind. The Pod's + /// `RewindApplied` event already contains the authoritative post-rewind + /// session tail; always clear/replay from it even if this TUI instance has + /// somehow lost connect-time greeting metadata. Skipping the restore in + /// that case would leave old post-target output visible after success. + fn restore_rewind_snapshot(&mut self, entries: &[serde_json::Value]) { + let greeting = self.greeting.clone().or_else(|| { + self.blocks.iter().find_map(|b| match b { + Block::Greeting(g) => Some(g.clone()), + _ => None, + }) + }); + if let Some(greeting) = greeting.clone() { + self.greeting = Some(greeting.clone()); + self.context_window = greeting.context_window; + self.session_context_tokens = greeting.context_tokens; + } + let missing_greeting = greeting.is_none(); + self.restore_entries(entries, greeting); + if missing_greeting { + self.blocks.push(Block::Alert { + level: AlertLevel::Warn, + source: AlertSource::Pod, + message: "Rewind applied, but greeting metadata was unavailable; restored the session tail without the header.".to_owned(), + }); + } + } + + fn restore_entries( + &mut self, + entries: &[serde_json::Value], + greeting: Option, + ) { self.turn_index = 0; self.blocks.clear(); self.cache = FileCache::new(); self.task_store = TaskStore::new(); self.task_pane_scroll = 0; - self.blocks.push(Block::Greeting(greeting)); + if let Some(greeting) = greeting { + self.blocks.push(Block::Greeting(greeting)); + } self.assistant_streaming = false; for entry in entries { @@ -1865,6 +1978,7 @@ impl App { self.task_store = TaskStore::new(); self.task_pane_scroll = 0; if let Some(g) = greeting { + self.greeting = Some(g.clone()); self.blocks.push(Block::Greeting(g)); } } @@ -1962,6 +2076,38 @@ impl App { } } +fn event_is_stale_after_rewind(event: &Event) -> bool { + matches!( + event, + Event::Alert(_) + | Event::MemoryWorker(_) + | Event::CompactStart + | Event::CompactDone { .. } + | Event::CompactFailed { .. } + | Event::SegmentRotated { .. } + | Event::UserMessage { .. } + | Event::SystemItem { .. } + | Event::TurnStart { .. } + | Event::InvokeStart { .. } + | Event::LlmCallStart { .. } + | Event::LlmCallEnd { .. } + | Event::LlmRetry { .. } + | Event::LlmContinuation { .. } + | Event::TextDelta { .. } + | Event::TextDone { .. } + | Event::ThinkingStart + | Event::ThinkingDelta { .. } + | Event::ThinkingDone { .. } + | Event::ToolCallStart { .. } + | Event::ToolCallArgsDelta { .. } + | Event::ToolCallDone { .. } + | Event::ToolResult { .. } + | Event::Usage { .. } + | Event::TurnEnd { .. } + | Event::RunEnd { .. } + ) +} + pub fn fmt_tokens(n: u64) -> String { if n >= 1_000_000 { format!("{:.1}M", n as f64 / 1_000_000.0) @@ -2109,6 +2255,159 @@ mod actionbar_notice_tests { } } +#[cfg(test)] +mod rewind_refresh_tests { + use super::*; + + #[test] + fn rewind_applied_replaces_old_live_tail_and_restores_input() { + let mut app = app_with_rewind_picker(); + app.greeting = Some(greeting()); + app.blocks.push(Block::Greeting(greeting())); + app.blocks.push(Block::AssistantText { + text: "old post-target output".into(), + }); + + app.handle_pod_event(Event::RewindApplied { + entries: vec![], + input: vec![Segment::text("selected rewind input")], + summary: summary(3), + }); + + assert!(app.rewind_picker.is_none()); + assert!(!blocks_contain(&app, "old post-target output")); + assert_eq!(composer_text(&app), "selected rewind input"); + assert!(blocks_contain(&app, "Rewound session")); + } + + #[test] + fn rewind_applied_clears_old_tail_even_when_greeting_is_missing() { + let mut app = app_with_rewind_picker(); + app.blocks.push(Block::AssistantText { + text: "old live tail without greeting".into(), + }); + + app.handle_pod_event(Event::RewindApplied { + entries: vec![], + input: vec![Segment::text("rewound input")], + summary: summary(1), + }); + + assert!(!blocks_contain(&app, "old live tail without greeting")); + assert!(blocks_contain(&app, "greeting metadata was unavailable")); + assert_eq!(composer_text(&app), "rewound input"); + } + + #[test] + fn pending_rewind_submit_suppresses_duplicate_enter_and_failure_preserves_display() { + let mut app = app_with_rewind_picker(); + app.blocks.push(Block::AssistantText { + text: "still-visible old display on failure".into(), + }); + + let first = app.submit_rewind_picker(); + assert!(matches!(first, Some(Method::RewindTo { .. }))); + assert!(app.rewind_picker.as_ref().unwrap().applying); + assert!(app.submit_rewind_picker().is_none()); + + app.handle_pod_event(Event::Error { + code: ErrorCode::InvalidRequest, + message: "stale rewind target".into(), + }); + + assert!(!app.rewind_picker.as_ref().unwrap().applying); + assert!(blocks_contain(&app, "still-visible old display on failure")); + let notice = app.current_actionbar_notice(Instant::now()).unwrap(); + assert!(notice.text.contains("Rewind failed")); + } + + #[test] + fn stale_live_update_after_success_does_not_repollute_restored_display() { + let mut app = app_with_rewind_picker(); + app.greeting = Some(greeting()); + app.blocks.push(Block::Greeting(greeting())); + app.blocks.push(Block::AssistantText { + text: "old tail before rewind".into(), + }); + + app.handle_pod_event(Event::RewindApplied { + entries: vec![], + input: vec![Segment::text("rewound input")], + summary: summary(2), + }); + app.handle_pod_event(Event::TextDelta { + text: "stale tail after rewind".into(), + }); + assert!(!blocks_contain(&app, "stale tail after rewind")); + + app.handle_pod_event(Event::Status { + status: PodStatus::Idle, + }); + app.handle_pod_event(Event::TextDelta { + text: "new live tail after status".into(), + }); + assert!(blocks_contain(&app, "new live tail after status")); + } + + fn app_with_rewind_picker() -> App { + let mut app = App::new("test".into()); + app.connected = true; + app.rewind_picker = Some(RewindPickerState::new( + 5, + vec![RewindTarget { + id: protocol::RewindTargetId { + segment_id: uuid::Uuid::nil(), + user_input_entry_index: 1, + }, + expected_head_entries: 5, + truncate_entries: 2, + turn_index: 1, + timestamp_ms: None, + preview: "rewind target".into(), + eligible: true, + disabled_reason: None, + warning: None, + }], + )); + app + } + + fn greeting() -> protocol::Greeting { + protocol::Greeting { + pod_name: "test".into(), + cwd: "/tmp".into(), + provider: "mock".into(), + model: "mock".into(), + scope_summary: "scope".into(), + tools: vec![], + context_window: 100, + context_tokens: 10, + } + } + + fn summary(discarded_entries: usize) -> protocol::RewindSummary { + protocol::RewindSummary { + truncated_to_entries: 1, + discarded_entries, + tool_side_effect_warning: false, + } + } + + fn blocks_contain(app: &App, needle: &str) -> bool { + app.blocks.iter().any(|block| match block { + Block::AssistantText { text } + | Block::SystemMessage { text } + | Block::Alert { message: text, .. } => text.contains(needle), + Block::UserMessage { segments } => Segment::flatten_to_text(segments).contains(needle), + _ => false, + }) + } + + fn composer_text(app: &App) -> String { + Segment::flatten_to_text(&app.input.submit_segments()) + } +} + #[cfg(test)] mod composer_history_persistence_tests { use super::*; diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index af648ca4..40b2ebdb 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -726,7 +726,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { if app.rewind_picker.is_some() { match key.code { KeyCode::Esc => { - app.close_rewind_picker(); + app.cancel_rewind_picker(); return None; } KeyCode::Enter => return app.submit_rewind_picker(), diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 5442bb95..c83306d1 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -433,7 +433,25 @@ fn draw_rewind_picker( picker: &mut crate::app::RewindPickerState, ) { let mut logical: Vec> = Vec::new(); - logical.push(Line::from(vec![ + let action_spans = if picker.applying { + vec![ + Span::styled( + "Applying rewind…", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" waiting for Pod response"), + ] + } else { + vec![ + Span::styled("Enter", Style::default().fg(Color::Green)), + Span::raw(" apply "), + Span::styled("Esc", Style::default().fg(Color::Green)), + Span::raw(" cancel"), + ] + }; + let mut header = vec![ Span::styled( "Rewind targets", Style::default() @@ -441,11 +459,9 @@ fn draw_rewind_picker( .add_modifier(Modifier::BOLD), ), Span::raw(format!(" head={} ", picker.head_entries)), - Span::styled("Enter", Style::default().fg(Color::Green)), - Span::raw(" apply "), - Span::styled("Esc", Style::default().fg(Color::Green)), - Span::raw(" cancel"), - ])); + ]; + header.extend(action_spans); + logical.push(Line::from(header)); logical.push(Line::from(Span::styled( "Selecting a target discards the later history suffix; tool side effects are not undone.", Style::default().fg(Color::DarkGray), From 3a7edbde527572ec4f64bec7e79c95f310d285e9 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 20:14:36 +0900 Subject: [PATCH 2/3] ticket: record rewind live refresh report --- .yoi/tickets/00001KV04NJ8D/item.md | 2 +- .yoi/tickets/00001KV04NJ8D/thread.md | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV04NJ8D/item.md b/.yoi/tickets/00001KV04NJ8D/item.md index 82042c4c..ec73b7e3 100644 --- a/.yoi/tickets/00001KV04NJ8D/item.md +++ b/.yoi/tickets/00001KV04NJ8D/item.md @@ -2,7 +2,7 @@ title: 'TUI rewind picker の Enter 後に live 表示が巻き戻らない問題を調査・修正する' state: 'inprogress' created_at: '2026-06-13T09:23:07Z' -updated_at: '2026-06-13T10:56:45Z' +updated_at: '2026-06-13T11:14:26Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui', 'pod-protocol', 'persistence', 'history-rewind'] diff --git a/.yoi/tickets/00001KV04NJ8D/thread.md b/.yoi/tickets/00001KV04NJ8D/thread.md index 18195ed8..5336c084 100644 --- a/.yoi/tickets/00001KV04NJ8D/thread.md +++ b/.yoi/tickets/00001KV04NJ8D/thread.md @@ -186,4 +186,29 @@ Critical risks / reviewer focus: Ticket evidence、relations、orchestration plan、関連 closed Ticket、bounded code map、Orchestrator worktree clean state を確認した。blocking relation はなく、rewind live refresh の要件・invariants・escalation conditions は実装可能な粒度で記録済み。実装 side effect の前に routing decision / IntentPacket / accepted plan を記録したため、専用 child worktree と Coder delegation に進む。 +--- + + + +## Implementation report + +Implementation report for commit 949ceb5a (fix: refresh tui after rewind) + +Files changed: +- crates/tui/src/app.rs: added rewind apply-pending state, post-rewind live-update fence, authoritative RewindApplied restore path that always clears/replays retained entries, actionbar-visible failure handling, and focused rewind refresh tests. +- crates/tui/src/single_pod.rs: route Esc through cancel_rewind_picker so submitted rewinds stay visibly pending until the Pod responds. +- crates/tui/src/ui.rs: show an applying/waiting state in the rewind picker header while RewindTo is pending. + +Validation: +- cargo test -p tui rewind_refresh_tests: PASS (4 tests). +- cargo test -p tui single_pod::tests::rewind_picker: PASS (2 tests). +- cargo fmt --check: PASS. +- git diff --check: PASS. +- cargo test -p tui: ATTEMPTED; unrelated failures remain in multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace and spawn::tests::{profile_choices_include_builtin_and_project_default_marker, profile_choices_use_project_registry_default}. The rewind-focused tests above passed. + +Residual risks: +- The stale-update fence is intentionally short-lived and clears on authoritative Status/Snapshot after RewindApplied; it drops queued display-mutating live events during that window to avoid re-polluting the restored session tail. +- No protocol or Pod persistence changes were made. + + --- From 06a6e4ec576a08cec0879ff04a56315c2f0906af Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 20:22:36 +0900 Subject: [PATCH 3/3] ticket: approve rewind live refresh --- .../review-approve-rewind-live-refresh.md | 24 ++++++++++++++ .yoi/tickets/00001KV04NJ8D/item.md | 2 +- .yoi/tickets/00001KV04NJ8D/thread.md | 32 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 .yoi/tickets/00001KV04NJ8D/artifacts/review-approve-rewind-live-refresh.md diff --git a/.yoi/tickets/00001KV04NJ8D/artifacts/review-approve-rewind-live-refresh.md b/.yoi/tickets/00001KV04NJ8D/artifacts/review-approve-rewind-live-refresh.md new file mode 100644 index 00000000..a58f4a2a --- /dev/null +++ b/.yoi/tickets/00001KV04NJ8D/artifacts/review-approve-rewind-live-refresh.md @@ -0,0 +1,24 @@ +## Review: approve + +Reviewed implementation commits `949ceb5a` and `3a7edbde` against Ticket `00001KV04NJ8D` intent and acceptance criteria. + +Evidence: +- `Event::RewindApplied` no longer gates transcript restoration on `App::greeting`; it clears/replays the Pod-provided post-rewind entries through a shared restore path and emits an explicit warning if greeting metadata is unavailable, avoiding silent stale-view success. +- Rewind picker submit now enters an `applying` state, suppresses repeated `Enter`/navigation, shows an applying header, blocks `Esc` from hiding an in-flight destructive request, and closes on successful `RewindApplied`. +- Rewind failure (`Event::Error` while applying) clears the pending state, leaves the existing transcript intact, and surfaces an actionbar-visible failure plus normal error block. +- A short rewind refresh fence drops display-mutating stale live events after successful restore until authoritative `Status`/`Snapshot`; no Pod protocol or persistence semantics changed. +- Temporary investigation logging was not present in the final diff. +- Focused tests cover successful restore/old-tail removal, missing-greeting restore, duplicate-submit suppression with failure preservation, and stale live update suppression; existing rewind picker tests still pass. + +Validation performed: +- `git diff --check 20daae0c..HEAD`: PASS. +- `cargo test -p tui rewind_refresh_tests`: PASS (4 tests). +- `cargo test -p tui single_pod::tests::rewind_picker`: PASS (2 tests). +- `cargo fmt --check`: PASS. +- `cargo check -p protocol -p pod -p tui`: PASS. +- `cargo test -p tui`: FAILED only in the already-reported unrelated tests: `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace` and `spawn::tests::{profile_choices_include_builtin_and_project_default_marker, profile_choices_use_project_registry_default}`; rewind-focused tests passed in that run. + +Residual note: +- The stale-update fence intentionally relies on the Pod's follow-up `Status`/`Snapshot` to clear; this matches the current `RewindApplied`/`Status` flow and is acceptable for this Ticket. + +Decision: approve. diff --git a/.yoi/tickets/00001KV04NJ8D/item.md b/.yoi/tickets/00001KV04NJ8D/item.md index ec73b7e3..b5dee5b6 100644 --- a/.yoi/tickets/00001KV04NJ8D/item.md +++ b/.yoi/tickets/00001KV04NJ8D/item.md @@ -2,7 +2,7 @@ title: 'TUI rewind picker の Enter 後に live 表示が巻き戻らない問題を調査・修正する' state: 'inprogress' created_at: '2026-06-13T09:23:07Z' -updated_at: '2026-06-13T11:14:26Z' +updated_at: '2026-06-13T11:21:32Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui', 'pod-protocol', 'persistence', 'history-rewind'] diff --git a/.yoi/tickets/00001KV04NJ8D/thread.md b/.yoi/tickets/00001KV04NJ8D/thread.md index 5336c084..8e17a31e 100644 --- a/.yoi/tickets/00001KV04NJ8D/thread.md +++ b/.yoi/tickets/00001KV04NJ8D/thread.md @@ -211,4 +211,36 @@ Residual risks: - No protocol or Pod persistence changes were made. +--- + + + +## Review: approve + +## Review: approve + +Reviewed implementation commits `949ceb5a` and `3a7edbde` against Ticket `00001KV04NJ8D` intent and acceptance criteria. + +Evidence: +- `Event::RewindApplied` no longer gates transcript restoration on `App::greeting`; it clears/replays the Pod-provided post-rewind entries through a shared restore path and emits an explicit warning if greeting metadata is unavailable, avoiding silent stale-view success. +- Rewind picker submit now enters an `applying` state, suppresses repeated `Enter`/navigation, shows an applying header, blocks `Esc` from hiding an in-flight destructive request, and closes on successful `RewindApplied`. +- Rewind failure (`Event::Error` while applying) clears the pending state, leaves the existing transcript intact, and surfaces an actionbar-visible failure plus normal error block. +- A short rewind refresh fence drops display-mutating stale live events after successful restore until authoritative `Status`/`Snapshot`; no Pod protocol or persistence semantics changed. +- Temporary investigation logging was not present in the final diff. +- Focused tests cover successful restore/old-tail removal, missing-greeting restore, duplicate-submit suppression with failure preservation, and stale live update suppression; existing rewind picker tests still pass. + +Validation performed: +- `git diff --check 20daae0c..HEAD`: PASS. +- `cargo test -p tui rewind_refresh_tests`: PASS (4 tests). +- `cargo test -p tui single_pod::tests::rewind_picker`: PASS (2 tests). +- `cargo fmt --check`: PASS. +- `cargo check -p protocol -p pod -p tui`: PASS. +- `cargo test -p tui`: FAILED only in the already-reported unrelated tests: `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace` and `spawn::tests::{profile_choices_include_builtin_and_project_default_marker, profile_choices_use_project_registry_default}`; rewind-focused tests passed in that run. + +Residual note: +- The stale-update fence intentionally relies on the Pod's follow-up `Status`/`Snapshot` to clear; this matches the current `RewindApplied`/`Status` flow and is acceptable for this Ticket. + +Decision: approve. + + ---