use tokio::sync::oneshot;
use super::{
AgentViewTarget, App, ChatMessage, MessageRole, TRANSCRIPT_MAX_ENTRIES, TranscriptCache,
TuiTranscriptEntry, load_transcript_file,
};
impl App {
pub fn set_view_target(&mut self, target: AgentViewTarget) {
if self.sessions.current().view_target == target {
return;
}
self.sessions.current_mut().view_target = target;
self.sessions.current_mut().render_cache.clear();
self.sessions.current_mut().scroll_offset = 0;
self.sessions.current_mut().transcript_cache = None;
if self
.sessions
.current_mut()
.pending_transcript
.take()
.is_some()
{
self.sessions.current_mut().status_label = None;
}
if let AgentViewTarget::SubAgent { ref id, .. } = self.sessions.current().view_target {
let id = id.clone();
self.start_transcript_load(&id);
}
}
fn start_transcript_load(&mut self, agent_id: &str) {
let transcript_path = self
.metrics
.sub_agents
.iter()
.find(|sa| sa.id == agent_id)
.and_then(|sa| sa.transcript_dir.as_deref())
.map(|dir| std::path::PathBuf::from(dir).join(format!("{agent_id}.jsonl")));
let Some(path) = transcript_path else {
return;
};
let (tx, rx) = oneshot::channel();
self.sessions.current_mut().pending_transcript = Some(rx);
self.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
let is_active = self
.metrics
.sub_agents
.iter()
.find(|sa| sa.id == agent_id)
.is_some_and(|sa| matches!(sa.state.as_str(), "working" | "submitted"));
tokio::task::spawn_blocking(move || {
let result = load_transcript_file(&path, is_active);
let _ = tx.send(result);
});
}
pub fn poll_pending_transcript(&mut self) {
let Some(rx) = self.sessions.current_mut().pending_transcript.as_mut() else {
return;
};
match rx.try_recv() {
Ok((entries, total)) => {
self.sessions.current_mut().pending_transcript = None;
self.sessions.current_mut().status_label = None;
let turns_at_load = self
.sessions
.current()
.view_target
.subagent_id()
.and_then(|id| self.metrics.sub_agents.iter().find(|sa| sa.id == id))
.map_or(0, |sa| sa.turns_used);
if let AgentViewTarget::SubAgent { ref id, .. } =
self.sessions.current().view_target.clone()
{
self.sessions.current_mut().transcript_cache = Some(TranscriptCache {
agent_id: id.clone(),
entries,
turns_at_load,
total_in_file: total,
});
}
self.sessions.current_mut().render_cache.clear();
}
Err(oneshot::error::TryRecvError::Empty) => {}
Err(oneshot::error::TryRecvError::Closed) => {
self.sessions.current_mut().pending_transcript = None;
self.sessions.current_mut().status_label = None;
}
}
}
pub(super) fn maybe_reload_transcript(&mut self) {
let AgentViewTarget::SubAgent { ref id, .. } = self.sessions.current().view_target.clone()
else {
return;
};
if self.sessions.current().pending_transcript.is_some() {
return;
}
let current_turns = self
.metrics
.sub_agents
.iter()
.find(|sa| sa.id == *id)
.map_or(0, |sa| sa.turns_used);
let cached_turns = self
.sessions
.current()
.transcript_cache
.as_ref()
.map_or(0, |c| c.turns_at_load);
if current_turns > cached_turns {
let agent_id = id.to_owned();
self.start_transcript_load(&agent_id);
}
}
#[must_use]
pub fn visible_messages(&self) -> Vec<ChatMessage> {
let slot = self.sessions.current();
if slot.view_target.is_main() {
return slot.messages.clone();
}
if let Some(ref cache) = slot.transcript_cache {
return cache
.entries
.iter()
.map(TuiTranscriptEntry::to_chat_message)
.collect();
}
if slot.pending_transcript.is_some() {
return vec![ChatMessage::new(
MessageRole::System,
"Loading transcript...".to_owned(),
)];
}
let name = slot.view_target.subagent_name().unwrap_or("unknown");
vec![ChatMessage::new(
MessageRole::System,
format!("Transcript not available for {name}."),
)]
}
#[must_use]
pub fn transcript_truncation_info(&self) -> Option<String> {
let cache = self.sessions.current().transcript_cache.as_ref()?;
if cache.total_in_file > TRANSCRIPT_MAX_ENTRIES {
Some(format!(
"[showing last {TRANSCRIPT_MAX_ENTRIES} of {} messages]",
cache.total_in_file
))
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use tokio::sync::{mpsc, oneshot};
use zeph_core::metrics::SubAgentMetrics;
use super::*;
fn make_app() -> App {
let (user_tx, _) = mpsc::channel(1);
let (_, agent_rx) = mpsc::channel(1);
App::new(user_tx, agent_rx)
}
fn sub_agent(id: &str, transcript_dir: Option<&str>) -> SubAgentMetrics {
SubAgentMetrics {
id: id.to_owned(),
name: "test-agent".to_owned(),
state: "completed".to_owned(),
transcript_dir: transcript_dir.map(str::to_owned),
..Default::default()
}
}
#[tokio::test]
async fn start_transcript_load_sets_status_label_before_dispatch() {
let mut app = make_app();
app.metrics.sub_agents = vec![sub_agent("sa-1", Some("/tmp/zeph-test-nonexistent-dir"))];
app.start_transcript_load("sa-1");
assert_eq!(
app.status_label(),
Some("loading transcript..."),
"status_label must be set synchronously before the spawn_blocking dispatch"
);
assert!(app.sessions.current().pending_transcript.is_some());
}
#[test]
fn start_transcript_load_noop_when_no_transcript_dir() {
let mut app = make_app();
app.metrics.sub_agents = vec![sub_agent("sa-1", None)];
app.start_transcript_load("sa-1");
assert_eq!(app.status_label(), None);
assert!(app.sessions.current().pending_transcript.is_none());
}
#[test]
fn poll_pending_transcript_clears_status_label_on_success() {
let mut app = make_app();
app.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
id: "sa-1".to_owned(),
name: "Planner".to_owned(),
};
let (tx, rx) = oneshot::channel();
app.sessions.current_mut().pending_transcript = Some(rx);
tx.send((Vec::new(), 0)).expect("receiver still open");
app.poll_pending_transcript();
assert_eq!(app.status_label(), None);
assert!(app.sessions.current().pending_transcript.is_none());
}
#[test]
fn poll_pending_transcript_clears_status_label_when_task_panics() {
let mut app = make_app();
app.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
let (tx, rx) = oneshot::channel::<(Vec<TuiTranscriptEntry>, usize)>();
app.sessions.current_mut().pending_transcript = Some(rx);
drop(tx);
app.poll_pending_transcript();
assert_eq!(app.status_label(), None);
assert!(app.sessions.current().pending_transcript.is_none());
}
#[test]
fn poll_pending_transcript_is_noop_while_still_pending() {
let mut app = make_app();
app.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
let (_tx, rx) = oneshot::channel();
app.sessions.current_mut().pending_transcript = Some(rx);
app.poll_pending_transcript();
assert_eq!(app.status_label(), Some("loading transcript..."));
assert!(app.sessions.current().pending_transcript.is_some());
}
#[test]
fn set_view_target_cancels_pending_load_and_clears_status_label() {
let mut app = make_app();
app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
id: "sa-1".to_owned(),
name: "Planner".to_owned(),
};
let (_tx, rx) = oneshot::channel();
app.sessions.current_mut().pending_transcript = Some(rx);
app.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
app.set_view_target(AgentViewTarget::Main);
assert!(
app.sessions.current().pending_transcript.is_none(),
"pending load must be cancelled"
);
assert_eq!(
app.status_label(),
None,
"cancelling the in-flight transcript load must clear its status_label"
);
}
#[test]
fn set_view_target_preserves_unrelated_status_label_when_nothing_pending() {
let mut app = make_app();
assert!(app.metrics.sub_agents.is_empty());
app.sessions.current_mut().status_label = Some("indexing files...".to_owned());
app.set_view_target(AgentViewTarget::SubAgent {
id: "sa-1".to_owned(),
name: "Planner".to_owned(),
});
assert!(app.sessions.current().pending_transcript.is_none());
assert_eq!(
app.status_label(),
Some("indexing files..."),
"unrelated status_label must not be wiped when there was no in-flight \
transcript load to cancel"
);
}
}