//! Tick-action handler: drain turn events, LSP provision messages, //! API connectivity checks, staleness sweep, pending lessons, and //! todo.md polling. use crate::app::review::{should_trigger_review, trigger_review}; use crate::app::state::rest::AppStateRest; use crate::app::state::runtime::TurnEvent; use crate::app::state::types::{Toast, ToastKind}; use crate::dto::chat::message::{ChatMessage, Role}; use super::io::{maybe_trigger_review, spawn_api_connectivity_check}; use super::memory::refresh_lesson_counters; use super::turn::HIVE_MIND_KICKOFF_NOTE; /// Handle `Action::Tick` — the periodic event that drains async results /// and runs background maintenance tasks. pub(super) fn handle_tick(state: &mut AppStateRest) { state.misc.tick_count = state.misc.tick_count.wrapping_add(1); let now_ms = chrono::Utc::now().timestamp_millis(); state.misc.drain_expired_toasts(now_ms); if state.misc.tick_count.is_multiple_of(10) { let todo_path = state.session_dir.join("todo.md"); if let Ok(content) = std::fs::read_to_string(&todo_path) { if content != state.misc.todo_content { state.misc.todo_content = content; state.dirty = true; } } else if !state.misc.todo_content.is_empty() { state.misc.todo_content.clear(); state.dirty = true; } } // Background API connectivity check — runs on a background thread // every ~1s while disconnected, every ~30s while connected, so the // status bar reflects real API availability without user input. let check_interval = if state.misc.api_connected { 600 } else { 20 }; if state.misc.tick_count.is_multiple_of(check_interval) { spawn_api_connectivity_check(state); } crate::app::review::maybe_run_staleness_sweep(state); if let Some(ref rt) = state.session_runtime { let _ = crate::app::review::process_pending_lessons( &rt.session_dir, &state.memory_dir, ); } // Drain LSP provision progress messages into toast notifications. // Collect messages under the lock, then push toasts outside it to avoid // a borrow-conflict with state.push_toast (which also accesses state). let pending: Vec = state .lsp_provision_msgs .lock() .ok() .map(|mut q| q.drain(..).collect()) .unwrap_or_default(); for msg in &pending { let kind = if msg.contains("not available") || msg.contains("failed") { ToastKind::Warning } else if msg.contains("connected") || msg.contains("✓") { ToastKind::Success } else { ToastKind::Info }; state.push_toast(Toast::new(kind, msg.clone())); } let events: Vec = { if let Ok(mut q) = state.turn_events.lock() { q.drain(..).collect() } else { Vec::new() } }; let mut turn_finished = false; for event in events { match event { TurnEvent::AssistantMessage(msg) => { state.misc.thinking = false; state.misc.api_connected = true; let display_content = msg.content.clone().unwrap_or_default(); if !display_content.is_empty() { state.push_transcript( crate::app::state::rest::ChatMessageDisplay::new( Role::Assistant, display_content, ), ); } if let Some(ref mut rt) = state.session_runtime { rt.push_message(msg); } } TurnEvent::ToolResult { tool_call_id, tool_name, output, is_error, path, } => { state.misc.thinking = false; let display_path = path.unwrap_or_default(); let display = if tool_name == "read" { let line_count = output.lines().count(); if display_path.is_empty() { format!("read: {line_count} line(s)") } else { format!("read: {display_path} ({line_count} lines)") } } else { format!("{tool_name}: {output}") }; state.push_transcript( crate::app::state::rest::ChatMessageDisplay::new(Role::Tool, display), ); if let Some(ref mut rt) = state.session_runtime { rt.push_message(ChatMessage::tool_result( tool_call_id.clone(), output.clone(), )); rt.tool_call_results .push(crate::app::state::runtime::ToolCallResult { tool_call_id, tool_name, output, is_error, duration_ms: 0, }); } } TurnEvent::SystemNote { kind, message } => { if kind == "edits" { if let Some(ref mut rt) = state.session_runtime { if let Ok(count) = message.parse::() { rt.edit_count += count; } } if should_trigger_review(state, crate::app::state::types::Origin::Main) { trigger_review(state); } } else if kind == "review" { state.misc.lesson_running = false; let counted = if let Some(ref mut rt) = state.session_runtime { refresh_lesson_counters(&state.memory_dir, rt); true } else { false }; if let Some(ref mut rt) = state.session_runtime { if counted { rt.consecutive_empty_reviews = 0; } else { rt.consecutive_empty_reviews += 1; } } state.push_toast(Toast::new(ToastKind::Info, message)); } else if kind == "task_retry" { state.push_transcript( crate::app::state::rest::ChatMessageDisplay::new( Role::System, message.clone(), ), ); state.push_toast(Toast::new( ToastKind::Info, "Auto-continuing unfinished tasks...".to_string(), )); if let Some(ref mut rt) = state.session_runtime { rt.push_message(ChatMessage::system(message.clone())); } } else if kind == "connectivity" { state.misc.api_connected = message == "connected"; } else if kind == "hive_mind_converged" { if let Some(ref mut rt) = state.session_runtime { rt.hive_mind_converged = true; } } else if kind == "pipeline" { // Clear old workflow agents when a new pipeline starts. if message == HIVE_MIND_KICKOFF_NOTE { state.workflow_engine.agents.clear(); state.workflow_engine.findings.clear(); } // popup removed, no overlay to reset state.push_toast(Toast { kind: ToastKind::Info, message: message.clone(), created_at: chrono::Utc::now().timestamp_millis(), lifetime_ms: 12000, }); state.dirty = true; } else if kind == "bg-test-gen" { let escalated = message.starts_with("ESCALATED:"); state.push_toast(Toast { kind: if escalated { ToastKind::Error } else { ToastKind::Info }, message: message.clone(), created_at: chrono::Utc::now().timestamp_millis(), lifetime_ms: if escalated { 30000 } else { 8000 }, }); state.dirty = true; } else if kind == "bg-arch-review" || kind == "bg-security-review" { let escalated = message.starts_with("ESCALATED:"); state.push_toast(Toast { kind: if escalated { ToastKind::Error } else { ToastKind::Info }, message: message.clone(), created_at: chrono::Utc::now().timestamp_millis(), lifetime_ms: if escalated { 30000 } else { 10000 }, }); state.dirty = true; } else if kind == "workflow_done" { state.push_toast(Toast { kind: ToastKind::Success, message: message.clone(), created_at: chrono::Utc::now().timestamp_millis(), lifetime_ms: 10000, }); state.push_transcript( crate::app::state::rest::ChatMessageDisplay::new( Role::System, format!("✓ {message}"), ), ); // overlay removed state.dirty = true; } else if kind == "workflow_error" { state.push_toast(Toast { kind: ToastKind::Error, message: message.clone(), created_at: chrono::Utc::now().timestamp_millis(), lifetime_ms: 12000, }); state.push_transcript( crate::app::state::rest::ChatMessageDisplay::new( Role::System, format!("✗ {message}"), ), ); // overlay removed state.dirty = true; } else { state.push_toast(Toast::new(ToastKind::Info, message)); } } TurnEvent::StreamStart => { state.misc.thinking = false; state.misc.api_connected = true; state.push_transcript( crate::app::state::rest::ChatMessageDisplay::new( Role::Assistant, String::new(), ), ); } TurnEvent::StreamToken(delta) => { if let Some(last) = state.transcript_cache.messages.last_mut() { if last.role == Role::Assistant { last.content.push_str(&delta); state.transcript_cache.dirty = true; } } } TurnEvent::StreamDone(msg) => { state.misc.thinking = false; if let Some(ref mut rt) = state.session_runtime { rt.push_message(msg); } } TurnEvent::Usage { tokens_in, tokens_out, } => { if let Some(ref mut rt) = state.session_runtime { rt.usage.tokens_in += tokens_in; rt.usage.tokens_out += tokens_out; rt.usage.last_tokens_in = tokens_in; rt.usage.last_tokens_out = tokens_out; rt.usage.api_calls += 1; } } TurnEvent::ReviewUsage { tokens_in, tokens_out, } => { if let Some(ref mut rt) = state.session_runtime { rt.usage.tokens_in += tokens_in; rt.usage.tokens_out += tokens_out; rt.usage.review_tokens += tokens_in + tokens_out; rt.usage.api_calls += 1; } } TurnEvent::Error(msg) => { state.misc.api_connected = false; let long_toast = Toast { kind: ToastKind::Error, message: msg.clone(), created_at: chrono::Utc::now().timestamp_millis(), lifetime_ms: 15000, }; state.push_toast(long_toast); state.push_transcript( crate::app::state::rest::ChatMessageDisplay::new( Role::System, format!("Error: {msg}"), ), ); turn_finished = true; } TurnEvent::Done => { state.misc.thinking = false; turn_finished = true; } TurnEvent::Compacted(new_msgs) => { if let Some(ref mut rt) = state.session_runtime { rt.messages = new_msgs; state.push_toast(Toast::new( ToastKind::Info, "History auto-compacted by AI.".to_string(), )); state.dirty = true; } } TurnEvent::WorkflowAgentUpdate { agent_id, agent_name, status, } => { // Upsert the agent in the workflow engine roster. // Running agents are pushed as new entries; status // updates find the existing entry by id and replace it. use crate::app::workflow::engine::WorkflowAgent; if let Some(existing) = state .workflow_engine .agents .iter_mut() .find(|a| a.id == agent_id) { existing.status = status; } else { state.workflow_engine.agents.push(WorkflowAgent { id: agent_id, name: agent_name, status, }); } // popup removed state.dirty = true; } } } if turn_finished { maybe_trigger_review(state); } if turn_finished || state.dirty { state.dirty = true; } }