Skip to main content

zeph_tui/app/
transcript.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Sub-agent transcript view: initiating background loads, polling for completion,
5//! reloading on file change, and projecting cached entries into chat messages.
6
7use tokio::sync::oneshot;
8
9use super::{
10    AgentViewTarget, App, ChatMessage, MessageRole, TRANSCRIPT_MAX_ENTRIES, TranscriptCache,
11    TuiTranscriptEntry, load_transcript_file,
12};
13
14impl App {
15    /// Switch the chat view target. Clears render cache and scroll offset.
16    /// All view changes MUST go through this method (W5).
17    pub fn set_view_target(&mut self, target: AgentViewTarget) {
18        if self.sessions.current().view_target == target {
19            return;
20        }
21        self.sessions.current_mut().view_target = target;
22        self.sessions.current_mut().render_cache.clear();
23        self.sessions.current_mut().scroll_offset = 0;
24        self.sessions.current_mut().transcript_cache = None;
25        if self
26            .sessions
27            .current_mut()
28            .pending_transcript
29            .take()
30            .is_some()
31        {
32            // Only clear if a load was actually in flight — otherwise this could wipe an
33            // unrelated status_label. Its "loading transcript..." label would otherwise
34            // never be cleared, since poll_pending_transcript (the only other clearer)
35            // never runs once pending_transcript is gone (e.g. Esc back to Main before
36            // the load resolves).
37            self.sessions.current_mut().status_label = None;
38        }
39        // Kick off transcript load if switching to a subagent.
40        if let AgentViewTarget::SubAgent { ref id, .. } = self.sessions.current().view_target {
41            let id = id.clone();
42            self.start_transcript_load(&id);
43        }
44    }
45
46    /// Initiates a background transcript load for the given agent ID.
47    fn start_transcript_load(&mut self, agent_id: &str) {
48        // Find transcript_dir from current metrics.
49        let transcript_path = self
50            .metrics
51            .sub_agents
52            .iter()
53            .find(|sa| sa.id == agent_id)
54            .and_then(|sa| sa.transcript_dir.as_deref())
55            .map(|dir| std::path::PathBuf::from(dir).join(format!("{agent_id}.jsonl")));
56
57        let Some(path) = transcript_path else {
58            return;
59        };
60
61        let (tx, rx) = oneshot::channel();
62        self.sessions.current_mut().pending_transcript = Some(rx);
63        self.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
64        // Determine if the agent is still active (for C2: skip warning on partial last line).
65        let is_active = self
66            .metrics
67            .sub_agents
68            .iter()
69            .find(|sa| sa.id == agent_id)
70            .is_some_and(|sa| matches!(sa.state.as_str(), "working" | "submitted"));
71
72        tokio::task::spawn_blocking(move || {
73            // EXEMPT: short one-shot load; result delivered via oneshot and polled every tick
74            let result = load_transcript_file(&path, is_active);
75            let _ = tx.send(result);
76        });
77    }
78
79    /// Poll the pending transcript load and install result if ready.
80    pub fn poll_pending_transcript(&mut self) {
81        let Some(rx) = self.sessions.current_mut().pending_transcript.as_mut() else {
82            return;
83        };
84        match rx.try_recv() {
85            Ok((entries, total)) => {
86                self.sessions.current_mut().pending_transcript = None;
87                self.sessions.current_mut().status_label = None;
88                let turns_at_load = self
89                    .sessions
90                    .current()
91                    .view_target
92                    .subagent_id()
93                    .and_then(|id| self.metrics.sub_agents.iter().find(|sa| sa.id == id))
94                    .map_or(0, |sa| sa.turns_used);
95                if let AgentViewTarget::SubAgent { ref id, .. } =
96                    self.sessions.current().view_target.clone()
97                {
98                    self.sessions.current_mut().transcript_cache = Some(TranscriptCache {
99                        agent_id: id.clone(),
100                        entries,
101                        turns_at_load,
102                        total_in_file: total,
103                    });
104                }
105                self.sessions.current_mut().render_cache.clear();
106            }
107            Err(oneshot::error::TryRecvError::Empty) => {}
108            Err(oneshot::error::TryRecvError::Closed) => {
109                self.sessions.current_mut().pending_transcript = None;
110                self.sessions.current_mut().status_label = None;
111            }
112        }
113    }
114
115    /// Check if the transcript needs reloading (turns count increased).
116    pub(super) fn maybe_reload_transcript(&mut self) {
117        let AgentViewTarget::SubAgent { ref id, .. } = self.sessions.current().view_target.clone()
118        else {
119            return;
120        };
121        // Don't start a new load while one is already in flight.
122        if self.sessions.current().pending_transcript.is_some() {
123            return;
124        }
125        let current_turns = self
126            .metrics
127            .sub_agents
128            .iter()
129            .find(|sa| sa.id == *id)
130            .map_or(0, |sa| sa.turns_used);
131        let cached_turns = self
132            .sessions
133            .current()
134            .transcript_cache
135            .as_ref()
136            .map_or(0, |c| c.turns_at_load);
137        if current_turns > cached_turns {
138            let agent_id = id.to_owned();
139            self.start_transcript_load(&agent_id);
140        }
141    }
142
143    /// Returns the messages to display in the chat area.
144    ///
145    /// Always returns an owned `Vec` — the cost is one clone of at most
146    /// `MAX_TUI_MESSAGES` (2000) ref-counted strings inside `ChatMessage`.
147    /// When viewing a subagent, returns transcript entries converted to [`ChatMessage`].
148    /// When no transcript is loaded yet, returns a loading placeholder.
149    #[must_use]
150    pub fn visible_messages(&self) -> Vec<ChatMessage> {
151        let slot = self.sessions.current();
152        if slot.view_target.is_main() {
153            return slot.messages.clone();
154        }
155        if let Some(ref cache) = slot.transcript_cache {
156            return cache
157                .entries
158                .iter()
159                .map(TuiTranscriptEntry::to_chat_message)
160                .collect();
161        }
162        if slot.pending_transcript.is_some() {
163            return vec![ChatMessage::new(
164                MessageRole::System,
165                "Loading transcript...".to_owned(),
166            )];
167        }
168        let name = slot.view_target.subagent_name().unwrap_or("unknown");
169        vec![ChatMessage::new(
170            MessageRole::System,
171            format!("Transcript not available for {name}."),
172        )]
173    }
174
175    /// Returns the truncation info string if the transcript was truncated.
176    #[must_use]
177    pub fn transcript_truncation_info(&self) -> Option<String> {
178        let cache = self.sessions.current().transcript_cache.as_ref()?;
179        if cache.total_in_file > TRANSCRIPT_MAX_ENTRIES {
180            Some(format!(
181                "[showing last {TRANSCRIPT_MAX_ENTRIES} of {} messages]",
182                cache.total_in_file
183            ))
184        } else {
185            None
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use tokio::sync::{mpsc, oneshot};
193    use zeph_core::metrics::SubAgentMetrics;
194
195    use super::*;
196
197    fn make_app() -> App {
198        let (user_tx, _) = mpsc::channel(1);
199        let (_, agent_rx) = mpsc::channel(1);
200        App::new(user_tx, agent_rx)
201    }
202
203    fn sub_agent(id: &str, transcript_dir: Option<&str>) -> SubAgentMetrics {
204        SubAgentMetrics {
205            id: id.to_owned(),
206            name: "test-agent".to_owned(),
207            state: "completed".to_owned(),
208            transcript_dir: transcript_dir.map(str::to_owned),
209            ..Default::default()
210        }
211    }
212
213    // ── #5984 status_label lifecycle around transcript load ────────────────────
214
215    #[tokio::test]
216    async fn start_transcript_load_sets_status_label_before_dispatch() {
217        // start_transcript_load offloads to spawn_blocking, which requires a Tokio runtime.
218        let mut app = make_app();
219        app.metrics.sub_agents = vec![sub_agent("sa-1", Some("/tmp/zeph-test-nonexistent-dir"))];
220        app.start_transcript_load("sa-1");
221        assert_eq!(
222            app.status_label(),
223            Some("loading transcript..."),
224            "status_label must be set synchronously before the spawn_blocking dispatch"
225        );
226        assert!(app.sessions.current().pending_transcript.is_some());
227    }
228
229    #[test]
230    fn start_transcript_load_noop_when_no_transcript_dir() {
231        // No transcript_dir → transcript_path is None → early return, no dispatch at all.
232        let mut app = make_app();
233        app.metrics.sub_agents = vec![sub_agent("sa-1", None)];
234        app.start_transcript_load("sa-1");
235        assert_eq!(app.status_label(), None);
236        assert!(app.sessions.current().pending_transcript.is_none());
237    }
238
239    #[test]
240    fn poll_pending_transcript_clears_status_label_on_success() {
241        let mut app = make_app();
242        app.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
243        app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
244            id: "sa-1".to_owned(),
245            name: "Planner".to_owned(),
246        };
247        let (tx, rx) = oneshot::channel();
248        app.sessions.current_mut().pending_transcript = Some(rx);
249        tx.send((Vec::new(), 0)).expect("receiver still open");
250
251        app.poll_pending_transcript();
252
253        assert_eq!(app.status_label(), None);
254        assert!(app.sessions.current().pending_transcript.is_none());
255    }
256
257    #[test]
258    fn poll_pending_transcript_clears_status_label_when_task_panics() {
259        // Closed branch: the spawn_blocking task dropped its sender without sending
260        // (e.g. panicked) — status_label must not be left stuck on "loading transcript...".
261        let mut app = make_app();
262        app.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
263        let (tx, rx) = oneshot::channel::<(Vec<TuiTranscriptEntry>, usize)>();
264        app.sessions.current_mut().pending_transcript = Some(rx);
265        drop(tx);
266
267        app.poll_pending_transcript();
268
269        assert_eq!(app.status_label(), None);
270        assert!(app.sessions.current().pending_transcript.is_none());
271    }
272
273    #[test]
274    fn poll_pending_transcript_is_noop_while_still_pending() {
275        let mut app = make_app();
276        app.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
277        let (_tx, rx) = oneshot::channel();
278        app.sessions.current_mut().pending_transcript = Some(rx);
279
280        app.poll_pending_transcript();
281
282        // Not ready yet (TryRecvError::Empty) — status_label must remain set.
283        assert_eq!(app.status_label(), Some("loading transcript..."));
284        assert!(app.sessions.current().pending_transcript.is_some());
285    }
286
287    // ── #5984 set_view_target cancellation must not strand status_label ────────
288
289    #[test]
290    fn set_view_target_cancels_pending_load_and_clears_status_label() {
291        // Reproduces the stuck-spinner bug: a transcript load is in flight
292        // (status_label = "loading transcript..."), then the user navigates away
293        // (e.g. Esc back to Main) before it resolves. The switch cancels
294        // pending_transcript, but poll_pending_transcript (the only other clearer)
295        // will now never run again — set_view_target itself must clear the label.
296        let mut app = make_app();
297        app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
298            id: "sa-1".to_owned(),
299            name: "Planner".to_owned(),
300        };
301        let (_tx, rx) = oneshot::channel();
302        app.sessions.current_mut().pending_transcript = Some(rx);
303        app.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
304
305        app.set_view_target(AgentViewTarget::Main);
306
307        assert!(
308            app.sessions.current().pending_transcript.is_none(),
309            "pending load must be cancelled"
310        );
311        assert_eq!(
312            app.status_label(),
313            None,
314            "cancelling the in-flight transcript load must clear its status_label"
315        );
316    }
317
318    #[test]
319    fn set_view_target_preserves_unrelated_status_label_when_nothing_pending() {
320        // Negative case for the same fix: switching view targets with no pending_transcript
321        // in flight (e.g. no transcript_dir on record for the target agent, so
322        // start_transcript_load returns early) must not wipe an unrelated status_label.
323        let mut app = make_app();
324        assert!(app.metrics.sub_agents.is_empty());
325        app.sessions.current_mut().status_label = Some("indexing files...".to_owned());
326
327        app.set_view_target(AgentViewTarget::SubAgent {
328            id: "sa-1".to_owned(),
329            name: "Planner".to_owned(),
330        });
331
332        assert!(app.sessions.current().pending_transcript.is_none());
333        assert_eq!(
334            app.status_label(),
335            Some("indexing files..."),
336            "unrelated status_label must not be wiped when there was no in-flight \
337             transcript load to cancel"
338        );
339    }
340}