diff --git a/AGENTS.md b/AGENTS.md index b20ec9db..fda133ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,13 @@ Environment variables: - Per-event Bedrock diagnostic logs written to `bedrock-diagnostics.log` in the active Warp log directory - Set `GALAXY_TOOL_DIAGNOSTICS=1` to enable verbose local tool queue/execution debug logs and cancellation backtraces. These diagnostics are disabled during routine operation. +### Local AI Activity Inspector + +- Open `/ai-activity` or **Show AI Activity** in the Command Palette to start an app-wide, in-memory recording of model requests/results, compaction, tools, permission assessments, crosscheck reviews, and background-job lifecycle events. +- Each event carries its owning conversation/session where available. Filter by activity or click a session label; click the activity to inspect wrapped, selectable details. Recording continues after closing the inspector until paused, and Clear removes retained events without affecting running work. +- Capture is off until first opened, independent of remote-logging settings, and bounded to 2,000 events / 16 MiB of retained entries and 262,144 characters per capture. Truncation and eviction are explicit. Transport headers/provider credentials are excluded; user-supplied model/tool content may appear in the local capture. +- Requests are Galaxy's provider-neutral snapshots, not exact upstream HTTP payloads. ACP exposes only the traffic Galaxy exchanges with the external agent; the external agent's internal model requests and compaction are not visible. Provider token usage is marked as reported only when supplied by the runtime. + ### Remote Logging: Pulling Galaxy Logs When investigating Galaxy behavior, retrieve recent remote logs from diff --git a/app/src/ai/activity/mod.rs b/app/src/ai/activity/mod.rs new file mode 100644 index 00000000..89f29e42 --- /dev/null +++ b/app/src/ai/activity/mod.rs @@ -0,0 +1,395 @@ +//! Local, opt-in inspection of Galaxy's AI traffic and execution lifecycle. +//! +//! Payloads describe Galaxy's provider-neutral requests/results, not HTTP wire +//! traffic. No transport headers or provider credentials are captured here. + +#[cfg(not(target_family = "wasm"))] +pub(crate) mod view; + +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use serde_json::{json, Value}; + +/// Limits bound both retention and the work spent serializing a single event. +pub(crate) const MAX_PAYLOAD_CHARS: usize = 256 * 1024; +const MAX_ITEMS: usize = 2_000; +const MAX_RETAINED_BYTES: usize = 16 * 1024 * 1024; +const MAX_LABELS: usize = 4_096; + +static RECORDING: AtomicBool = AtomicBool::new(false); +static STORE: LazyLock> = LazyLock::new(Mutex::default); + +#[derive(Clone)] +pub(crate) struct ActivityEntry { + pub id: u64, + pub timestamp: DateTime, + pub session_id: Option, + pub session_label: String, + pub category: String, + pub event: String, + pub summary: String, + pub model: Option, + pub duration_ms: Option, + pub detail: String, +} + +pub(crate) struct ActivitySnapshot { + pub recording: bool, + pub revision: u64, + pub dropped: usize, + pub entries: Vec>, +} + +#[derive(Default)] +struct ActivityStore { + entries: VecDeque>, + labels: HashMap, + label_order: VecDeque, + sessions: HashMap, + correlation_order: VecDeque, + retained_bytes: usize, + next_id: u64, + revision: u64, + dropped: usize, + recording: bool, + generation: u64, +} + +pub(crate) fn is_recording() -> bool { + RECORDING.load(Ordering::Relaxed) +} + +pub(crate) fn set_recording(recording: bool) { + if let Ok(mut store) = STORE.lock() { + store.set_recording(recording); + RECORDING.store(recording, Ordering::Relaxed); + } +} + +/// Stops neither running work nor recording; it only clears retained diagnostics. +pub(crate) fn clear() { + if let Ok(mut store) = STORE.lock() { + store.clear(); + } +} + +pub(crate) fn snapshot() -> ActivitySnapshot { + let store = STORE.lock().unwrap_or_else(|error| error.into_inner()); + ActivitySnapshot { + recording: store.recording, + revision: store.revision, + dropped: store.dropped, + entries: store.entries.iter().cloned().collect(), + } +} + +/// Titles are bounded separately from payloads, and retained across Clear so +/// subsequent tool/background events keep their owning session's name. +pub(crate) fn register_session(session_id: impl Into, label: impl Into) { + if !is_recording() { + return; + } + let session_id = session_id.into(); + if session_id.is_empty() || session_id.len() > 256 { + return; + } + let label = bounded_text(&label.into(), 200); + if label.trim().is_empty() { + return; + } + if let Ok(mut store) = STORE.lock() { + if store.recording { + store.register_session(session_id, label); + } + } +} + +pub(crate) fn record(event: &str, summary: impl Into, context: Value) { + if !is_recording() { + return; + } + record_serializable(event, summary, context, &()); +} + +/// Serialize by reference with early termination, without first cloning a +/// conversation or materializing an unbounded serde_json::Value. +pub(crate) fn record_serializable( + event: &str, + summary: impl Into, + context: Value, + payload: &(impl Serialize + ?Sized), +) { + if !is_recording() { + return; + } + let Some(generation) = STORE + .lock() + .ok() + .and_then(|store| store.capture_generation()) + else { + return; + }; + let timestamp = Utc::now(); + #[derive(Serialize)] + struct Capture<'a, T: Serialize + ?Sized> { + context: &'a Value, + payload: &'a T, + } + let captured = + super::remote_logging::prefix_bounded_payload_context(MAX_PAYLOAD_CHARS, |writer| { + serde_json::to_writer_pretty( + writer, + &Capture { + context: &context, + payload, + }, + ) + }); + let mut detail = captured["payload"].as_str().unwrap_or_default().to_owned(); + if captured["truncated"].as_bool() == Some(true) { + detail.push_str( + "\n\n[Capture truncated at 262,144 characters; remaining payload was not serialized.]", + ); + } + if let Some(error) = captured.get("serialization_error") { + detail = format!("Capture could not be serialized: {error}"); + } + let Ok(mut store) = STORE.lock() else { + return; + }; + store.push_captured( + generation, + event, + summary.into(), + &context, + detail, + timestamp, + ); +} + +impl ActivityStore { + fn set_recording(&mut self, recording: bool) { + if self.recording != recording { + self.generation = self.generation.wrapping_add(1); + self.recording = recording; + self.revision = self.revision.wrapping_add(1); + } + } + + fn clear(&mut self) { + self.entries.clear(); + self.retained_bytes = 0; + self.dropped = 0; + self.generation = self.generation.wrapping_add(1); + self.revision = self.revision.wrapping_add(1); + } + + fn capture_generation(&self) -> Option { + self.recording.then_some(self.generation) + } + + fn register_session(&mut self, session_id: String, label: String) { + let label = format!( + "{} · {}", + bounded_text(&label, 185), + session_id.chars().take(8).collect::() + ); + if self.labels.get(&session_id) == Some(&label) { + return; + } + if !self.labels.contains_key(&session_id) { + if self.labels.len() >= MAX_LABELS { + if let Some(oldest) = self.label_order.pop_front() { + self.labels.remove(&oldest); + } + } + self.label_order.push_back(session_id.clone()); + } + self.labels.insert(session_id.clone(), label.clone()); + for entry in &mut self.entries { + if entry.session_id.as_deref() == Some(&session_id) { + self.retained_bytes -= entry_size(entry); + Arc::make_mut(entry).session_label.clone_from(&label); + self.retained_bytes += entry_size(entry); + } + } + self.evict(); + self.revision = self.revision.wrapping_add(1); + } + + fn push_captured( + &mut self, + generation: u64, + event: &str, + summary: String, + context: &Value, + detail: String, + timestamp: DateTime, + ) { + // Clear and pause/resume invalidate captures that started before the + // transition, even when serialization finishes after recording resumes. + if self.capture_generation() != Some(generation) { + return; + } + let mut session_id = session_id(context); + let correlation = correlation_keys(context); + if session_id.is_none() { + session_id = correlation + .iter() + .find_map(|key| self.sessions.get(key).cloned()); + } + if let Some(id) = &session_id { + for key in correlation { + if !self.sessions.contains_key(&key) { + if self.sessions.len() >= MAX_LABELS { + if let Some(oldest) = self.correlation_order.pop_front() { + self.sessions.remove(&oldest); + } + } + self.correlation_order.push_back(key.clone()); + } + self.sessions.insert(key, id.clone()); + } + } + let session_label = session_id.as_ref().map_or_else( + || "App background activity".to_owned(), + |id| { + self.labels.get(id).cloned().unwrap_or_else(|| { + format!("Session {}", id.chars().take(8).collect::()) + }) + }, + ); + let entry = Arc::new(ActivityEntry { + id: self.next_id, + timestamp, + session_id, + session_label, + category: category(event).to_owned(), + event: bounded_text(event, 160), + summary: bounded_text(&summary, 400), + model: context + .get("model_id") + .or_else(|| context.get("model")) + .and_then(Value::as_str) + .map(|model| bounded_text(model, 200)), + duration_ms: context + .get("elapsed_ms") + .or_else(|| context.get("duration_ms")) + .and_then(Value::as_u64), + detail, + }); + self.next_id = self.next_id.wrapping_add(1); + self.retained_bytes += entry_size(&entry); + self.entries.push_back(entry); + self.evict(); + self.revision = self.revision.wrapping_add(1); + } + + fn evict(&mut self) { + while self.entries.len() > MAX_ITEMS || self.retained_bytes > MAX_RETAINED_BYTES { + if let Some(evicted) = self.entries.pop_front() { + self.retained_bytes -= entry_size(&evicted); + self.dropped += 1; + } + } + } +} + +/// Entry retention counts metadata as well as captured content. The two +/// identity registries are separately bounded by count and string length. +fn entry_size(entry: &ActivityEntry) -> usize { + std::mem::size_of::() + + entry.session_id.as_ref().map_or(0, String::capacity) + + entry.session_label.capacity() + + entry.category.capacity() + + entry.event.capacity() + + entry.summary.capacity() + + entry.model.as_ref().map_or(0, String::capacity) + + entry.detail.capacity() +} + +fn correlation_keys(context: &Value) -> Vec { + ["stream_id", "request_id", "provider_run_id", "run_id"] + .into_iter() + .filter_map(|key| { + let value = context.get(key)?.as_str()?; + // Overlong identifiers cannot be allowed to grow the registry or + // be truncated into another session's correlation key. + (!value.is_empty() && value.len() <= 256).then(|| format!("{key}:{value}")) + }) + .collect() +} + +fn session_id(context: &Value) -> Option { + [ + context.get("conversation_id"), + context.pointer("/tool/conversation_id"), + context.pointer("/identifiers/client_conversation_id"), + context.get("parent_conversation_id"), + ] + .into_iter() + .flatten() + .filter_map(Value::as_str) + .find(|id| !id.is_empty() && id.len() <= 256) + .map(str::to_owned) +} + +fn category(event: &str) -> &'static str { + if event.contains("compact") || event.contains("summar") { + "Compaction" + } else if event.contains("risk_review") + || event.contains("risk_assessment") + || event.contains("permission") + { + "Permissions" + } else if event.contains("tool") || event.contains("action") { + "Tools" + } else if event.contains("command") || event.contains("job") { + "Background jobs" + } else if event.contains("model") || event.contains("llm") { + "Completions" + } else { + "Lifecycle" + } +} + +fn bounded_text(text: &str, limit: usize) -> String { + text.chars().take(limit).collect() +} + +/// Reuses existing operational metadata while keeping remote logging consent +/// and credentials independent from this local inspector. +pub(crate) fn record_log(record: &super::remote_logging::RemoteLogRecord) { + if !is_recording() { + return; + } + let event = record + .context + .get("event") + .and_then(Value::as_str) + .unwrap_or("activity"); + record_serializable( + event, + &record.message, + json!({ + "conversation_id": session_id(&record.context), + "stream_id": record.context.get("stream_id"), + "request_id": record.context.get("request_id"), + "provider_run_id": record.context.get("provider_run_id"), + "run_id": record.context.get("run_id"), + "duration_ms": record.context.get("duration_ms"), + "model_id": record.context.get("model_id"), + "elapsed_ms": record.context.get("elapsed_ms"), + "level": record.level.as_str(), + }), + &record.context, + ); +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/app/src/ai/activity/mod_tests.rs b/app/src/ai/activity/mod_tests.rs new file mode 100644 index 00000000..b70e15a2 --- /dev/null +++ b/app/src/ai/activity/mod_tests.rs @@ -0,0 +1,190 @@ +use super::*; + +fn recording_store() -> ActivityStore { + let mut store = ActivityStore::default(); + store.set_recording(true); + store +} + +fn capture(store: &mut ActivityStore, context: Value, detail: String) { + store.push_captured( + store.capture_generation().unwrap(), + "model_response", + "Model completed".to_owned(), + &context, + detail, + Utc::now(), + ); +} + +#[test] +fn clear_and_pause_resume_invalidate_in_flight_captures() { + let mut store = recording_store(); + let first_generation = store.capture_generation().unwrap(); + store.clear(); + store.push_captured( + first_generation, + "tool_result", + "Late".to_owned(), + &json!({}), + "old".to_owned(), + Utc::now(), + ); + assert!(store.entries.is_empty()); + + let second_generation = store.capture_generation().unwrap(); + store.set_recording(false); + assert!(store.capture_generation().is_none()); + store.set_recording(true); + store.push_captured( + second_generation, + "tool_result", + "Late".to_owned(), + &json!({}), + "old".to_owned(), + Utc::now(), + ); + assert!(store.entries.is_empty()); + capture(&mut store, json!({}), "new".to_owned()); + assert_eq!(store.entries.len(), 1); +} + +#[test] +fn retention_evicts_oldest_entries_by_count_and_allocated_size() { + let mut store = recording_store(); + for _ in 0..MAX_ITEMS + 3 { + capture(&mut store, json!({}), String::new()); + } + assert_eq!(store.entries.len(), MAX_ITEMS); + assert_eq!(store.dropped, 3); + assert_eq!(store.entries.front().unwrap().id, 3); + + store.clear(); + let detail = "x".repeat(1024 * 1024); + for _ in 0..20 { + capture(&mut store, json!({}), detail.clone()); + } + assert!(store.retained_bytes <= MAX_RETAINED_BYTES); + assert_eq!( + store.retained_bytes, + store + .entries + .iter() + .map(|entry| entry_size(entry)) + .sum::() + ); + assert!(store.dropped > 0); + // Metadata also consumes the budget, so sixteen 1 MiB payloads do not fit. + assert!(store.entries.len() < 16); +} + +#[test] +fn session_names_propagate_to_prior_entries_and_correlated_responses() { + let mut store = recording_store(); + capture( + &mut store, + json!({"conversation_id": "session-12345", "stream_id": "stream-1"}), + String::new(), + ); + store.register_session("session-12345".to_owned(), "Inspect build".to_owned()); + let previous_snapshot = store.entries.front().unwrap().clone(); + capture(&mut store, json!({"stream_id": "stream-1"}), String::new()); + let response = store.entries.back().unwrap(); + assert_eq!(response.session_id.as_deref(), Some("session-12345")); + assert_eq!(response.session_label, "Inspect build · session-"); + assert_eq!(previous_snapshot.session_label, response.session_label); + + let bytes_before_rename = store.retained_bytes; + store.register_session( + "session-12345".to_owned(), + "Longer build inspection title".to_owned(), + ); + assert!(store.retained_bytes > bytes_before_rename); + assert_eq!( + store.retained_bytes, + store + .entries + .iter() + .map(|entry| entry_size(entry)) + .sum::() + ); +} + +#[test] +fn correlation_rejects_oversized_ids_and_preserves_explicit_ownership() { + let mut store = recording_store(); + let overlong = "s".repeat(100_000); + capture( + &mut store, + json!({"conversation_id": "session-a", "stream_id": overlong}), + String::new(), + ); + assert!(store.sessions.is_empty()); + capture(&mut store, json!({"stream_id": overlong}), String::new()); + assert!(store.entries.back().unwrap().session_id.is_none()); + + capture( + &mut store, + json!({"conversation_id": "session-a", "request_id": "request-1"}), + String::new(), + ); + capture( + &mut store, + json!({"conversation_id": "session-b", "request_id": "request-1"}), + String::new(), + ); + assert_eq!( + store.entries.back().unwrap().session_id.as_deref(), + Some("session-b") + ); + assert_eq!( + session_id(&json!({"conversation_id": "", "tool": {"conversation_id": "owner"}})), + Some("owner".to_owned()) + ); +} + +#[test] +fn risk_assessments_share_the_permissions_filter() { + assert_eq!(category("tool_risk_assessment_finished"), "Permissions"); + assert_eq!(category("risk_review_request"), "Permissions"); + assert_eq!(category("provider_compaction_requested"), "Compaction"); + assert_eq!( + category("shell_command_terminal_completed"), + "Background jobs" + ); +} + +#[test] +fn long_recordings_keep_accepting_new_session_and_request_identities() { + let mut store = recording_store(); + for index in 0..MAX_LABELS + 2 { + let owner = format!("session-{index}"); + store.register_session(owner.clone(), format!("Task {index}")); + capture( + &mut store, + json!({"conversation_id": owner, "request_id": format!("request-{index}")}), + String::new(), + ); + } + assert_eq!(store.labels.len(), MAX_LABELS); + assert_eq!(store.label_order.len(), MAX_LABELS); + assert_eq!(store.sessions.len(), MAX_LABELS); + assert_eq!(store.correlation_order.len(), MAX_LABELS); + assert!(!store.labels.contains_key("session-0")); + assert!(!store.sessions.contains_key("request_id:request-0")); + + let latest = MAX_LABELS + 1; + capture( + &mut store, + json!({"request_id": format!("request-{latest}")}), + String::new(), + ); + let response = store.entries.back().unwrap(); + assert_eq!( + response.session_id.as_deref(), + Some(format!("session-{latest}").as_str()) + ); + assert!(response + .session_label + .starts_with(&format!("Task {latest}"))); +} diff --git a/app/src/ai/activity/view.rs b/app/src/ai/activity/view.rs new file mode 100644 index 00000000..1c3ea2dd --- /dev/null +++ b/app/src/ai/activity/view.rs @@ -0,0 +1,692 @@ +//! App-wide live browser for local AI diagnostics. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; + +use galaxyui::elements::{ + Border, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, + Container, CrossAxisAlignment, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, + MouseStateHandle, Padding, ParentElement, RowBackground, ScrollbarWidth, Table, + TableColumnWidth, TableConfig, TableHeader, TableStateHandle, TableVerticalSizing, Text, +}; +use galaxyui::fonts::{Properties, Weight}; +use galaxyui::platform::Cursor; +use galaxyui::r#async::Timer; +use galaxyui::text_layout::ClipConfig; +use galaxyui::{ + AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, +}; + +use super::{ActivityEntry, ActivitySnapshot}; +use crate::appearance::Appearance; +use crate::editor::{ + EditorOptions, EditorView, Event as EditorEvent, InteractionState, PlainTextEditorViewAction, + SingleLineEditorOptions, TextOptions, +}; +use crate::modal::ModalAction; +use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme, SecondaryTheme}; + +const PAGE_SIZE: usize = 200; + +#[derive(Clone, Debug)] +pub enum AIActivityAction { + ToggleRecording, + Clear, + Select(u64), + FilterSession(Option), + FilterCategory(Option), + ShowMore, +} + +pub struct AIActivityView { + snapshot: ActivitySnapshot, + search: ViewHandle, + detail: ViewHandle, + recording_button: ViewHandle, + clear_button: ViewHandle, + all_sessions_button: ViewHandle, + more_button: ViewHandle, + category_buttons: Vec<(Option, ViewHandle)>, + selected: Option>, + session_filter: Option, + category_filter: Option, + rows: HashMap, + display_limit: usize, + visible: bool, + refresh_scheduled: bool, + list_scroll: ClippedScrollStateHandle, + detail_scroll: ClippedScrollStateHandle, + filters_scroll: ClippedScrollStateHandle, + table_state: TableStateHandle, +} + +impl AIActivityView { + pub fn new(ctx: &mut ViewContext) -> Self { + let search = ctx.add_typed_action_view(|ctx| { + let mut editor = EditorView::single_line(SingleLineEditorOptions::default(), ctx); + editor.set_placeholder_text("Search activity, model, or session", ctx); + editor + }); + ctx.subscribe_to_view(&search, |me, _, event, ctx| match event { + EditorEvent::Edited(_) => { + me.display_limit = PAGE_SIZE; + me.synchronize_rows(ctx); + } + EditorEvent::Escape => ctx.dispatch_typed_action(&ModalAction::Close), + _ => {} + }); + let appearance = Appearance::as_ref(ctx); + let font = appearance.monospace_font_family(); + let size = appearance.monospace_font_size(); + let detail = ctx.add_typed_action_view(|ctx| { + let mut editor = EditorView::new( + EditorOptions { + text: TextOptions { + font_family_override: Some(font), + font_size_override: Some(size), + ..Default::default() + }, + autogrow: true, + soft_wrap: true, + supports_vim_mode: false, + use_settings_line_height_ratio: true, + ..Default::default() + }, + ctx, + ); + editor.set_interaction_state(InteractionState::Selectable, ctx); + editor + }); + ctx.subscribe_to_view(&detail, |_, _, event, ctx| { + if matches!(event, EditorEvent::Escape) { + ctx.dispatch_typed_action(&ModalAction::Close); + } + }); + let recording_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Pause recording", SecondaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::ToggleRecording)) + }); + let clear_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Clear", SecondaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::Clear)) + }); + let all_sessions_button = ctx.add_typed_action_view(|_| { + ActionButton::new("All sessions", NakedTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::FilterSession(None))) + }); + let more_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Show more", SecondaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::ShowMore)) + }); + let category_buttons = [ + (None, "All activity"), + (Some("Completions"), "Models"), + (Some("Compaction"), "Compaction"), + (Some("Tools"), "Tools"), + (Some("Permissions"), "Permissions"), + (Some("Background jobs"), "Jobs"), + (Some("Lifecycle"), "Lifecycle"), + ] + .into_iter() + .map(|(category, label)| { + let category = category.map(str::to_owned); + let value = category.clone(); + let button = ctx.add_typed_action_view(|_| { + ActionButton::new(label, NakedTheme) + .with_size(ButtonSize::Small) + .on_click(move |ctx| { + ctx.dispatch_typed_action(AIActivityAction::FilterCategory(value.clone())) + }) + }); + (category, button) + }) + .collect(); + Self { + // Workspace modals are constructed while hidden. Do not retain global + // payloads until this inspector is actually opened. + snapshot: ActivitySnapshot { + recording: super::is_recording(), + revision: 0, + dropped: 0, + entries: Vec::new(), + }, + search, + detail, + recording_button, + clear_button, + all_sessions_button, + more_button, + category_buttons, + selected: None, + session_filter: None, + category_filter: None, + rows: HashMap::new(), + display_limit: PAGE_SIZE, + visible: false, + refresh_scheduled: false, + list_scroll: Default::default(), + detail_scroll: Default::default(), + filters_scroll: Default::default(), + table_state: Default::default(), + } + } + + pub fn on_open(&mut self, ctx: &mut ViewContext) { + // Opening the inspector is the explicit opt-in to in-memory payload capture. + if super::snapshot().revision == 0 { + super::set_recording(true); + } + self.visible = true; + self.refresh(ctx); + self.update_recording_button(ctx); + self.synchronize_rows(ctx); + self.schedule_refresh(ctx); + ctx.focus(&self.search); + } + + pub fn on_close(&mut self, ctx: &mut ViewContext) { + self.visible = false; + self.snapshot.entries.clear(); + self.rows.clear(); + self.clear_detail(ctx); + self.list_scroll = Default::default(); + self.table_state = Default::default(); + // Leave any pending refresh scheduled: it will observe `visible`, or + // resume on a rapid reopen, without creating a second polling chain. + ctx.notify(); + } + + fn schedule_refresh(&mut self, ctx: &mut ViewContext) { + if self.refresh_scheduled || !self.visible { + return; + } + self.refresh_scheduled = true; + ctx.spawn( + async { Timer::after(Duration::from_millis(500)).await }, + |me, _, ctx| { + me.refresh_scheduled = false; + if me.visible { + me.refresh(ctx); + me.schedule_refresh(ctx); + } + }, + ); + } + + fn refresh(&mut self, ctx: &mut ViewContext) { + let snapshot = super::snapshot(); + let changed = snapshot.revision != self.snapshot.revision + || snapshot.recording != self.snapshot.recording; + self.snapshot = snapshot; + if changed { + if self.selected.as_ref().is_some_and(|selected| { + !self + .snapshot + .entries + .iter() + .any(|entry| entry.id == selected.id) + }) { + self.clear_detail(ctx); + } + self.update_recording_button(ctx); + self.synchronize_rows(ctx); + } + } + + fn clear_detail(&mut self, ctx: &mut ViewContext) { + self.selected = None; + self.detail.update(ctx, |editor, ctx| { + editor.set_interaction_state(InteractionState::Editable, ctx); + editor.clear_buffer_and_reset_undo_stack(ctx); + editor.set_interaction_state(InteractionState::Selectable, ctx); + }); + self.detail_scroll = Default::default(); + } + + fn update_recording_button(&self, ctx: &mut ViewContext) { + self.recording_button.update(ctx, |button, ctx| { + button.set_label( + if self.snapshot.recording { + "Pause recording" + } else { + "Record" + }, + ctx, + ); + }); + } + + fn filtered_entries(&self, app: &AppContext) -> Vec> { + let query = self + .search + .as_ref(app) + .buffer_text(app) + .trim() + .to_lowercase(); + self.snapshot + .entries + .iter() + .rev() + .filter(|entry| { + self.session_filter + .as_ref() + .is_none_or(|session| entry.session_id.as_ref() == Some(session)) + && self + .category_filter + .as_ref() + .is_none_or(|category| entry.category == *category) + && (query.is_empty() + || [ + &entry.session_label, + &entry.summary, + &entry.event, + &entry.category, + ] + .into_iter() + .any(|field| field.to_lowercase().contains(&query)) + || entry + .session_id + .as_ref() + .is_some_and(|id| id.to_lowercase().contains(&query)) + || entry + .model + .as_ref() + .is_some_and(|model| model.to_lowercase().contains(&query))) + }) + .cloned() + .collect() + } + + fn synchronize_rows(&mut self, ctx: &mut ViewContext) { + let ids = self + .filtered_entries(ctx) + .into_iter() + .take(self.display_limit) + .map(|entry| entry.id) + .collect::>(); + self.rows.retain(|id, _| ids.contains(id)); + for id in ids { + self.rows.entry(id).or_default(); + } + for (category, button) in &self.category_buttons { + button.update(ctx, |button, ctx| { + button.set_active(*category == self.category_filter, ctx) + }); + } + self.all_sessions_button.update(ctx, |button, ctx| { + button.set_active(self.session_filter.is_none(), ctx); + }); + ctx.notify(); + } + + fn select_entry(&mut self, id: u64, ctx: &mut ViewContext) { + let Some(entry) = self + .snapshot + .entries + .iter() + .find(|entry| entry.id == id) + .cloned() + else { + return; + }; + let session_id = entry.session_id.as_deref().unwrap_or("Unscoped"); + let text = format!( + "{}\nSession: {}\nSession ID: {}\nTime: {}\nEvent: {}\n\n{}", + entry.summary, + entry.session_label, + session_id, + entry + .timestamp + .with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M:%S%.3f %Z"), + entry.event, + readable_detail(&entry), + ); + self.detail.update(ctx, |editor, ctx| { + editor.set_interaction_state(InteractionState::Editable, ctx); + editor.clear_buffer_and_reset_undo_stack(ctx); + editor.insert_with_styles(&text, &[], PlainTextEditorViewAction::ReplaceBuffer, ctx); + editor.set_interaction_state(InteractionState::Selectable, ctx); + }); + self.selected = Some(entry); + self.detail_scroll = Default::default(); + ctx.notify(); + } + + fn render_table(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let entries = self + .filtered_entries(app) + .into_iter() + .take(self.display_limit) + .collect::>(); + if entries.is_empty() { + let message = if self.snapshot.entries.is_empty() { + "Waiting for AI activity. Continue a conversation in any session to capture requests, responses, tools, and compaction." + } else { + "No activity matches these filters." + }; + return Container::new( + Text::new( + message, + appearance.ui_font_family(), + appearance.ui_font_size(), + ) + .soft_wrap(true) + .with_color(theme.sub_text_color(theme.background()).into()) + .finish(), + ) + .with_padding(Padding::uniform(20.)) + .finish(); + } + let headers = [ + ("Time", TableColumnWidth::Fixed(102.)), + ("Session", TableColumnWidth::Flex(1.)), + ("Type", TableColumnWidth::Fixed(100.)), + ("Activity", TableColumnWidth::Flex(1.8)), + ("Model", TableColumnWidth::Flex(1.)), + ("Duration", TableColumnWidth::Fixed(88.)), + ] + .into_iter() + .map(|(label, width)| { + TableHeader::new( + Text::new( + label, + appearance.ui_font_family(), + appearance.ui_font_size() - 1., + ) + .with_style(Properties::default().weight(Weight::Semibold)) + .with_color(theme.nonactive_ui_text_color().into()) + .finish(), + ) + .with_width(width) + }) + .collect(); + let rows = self.rows.clone(); + let selected = self.selected.as_ref().map(|entry| entry.id); + let table = Table::new(self.table_state.clone(), 0., 0.) + .with_headers(headers) + .with_row_count(entries.len()) + .with_row_render_fn(move |index, app| { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let entry = &entries[index]; + let id = entry.id; + let Some((row_mouse, session_mouse)) = rows.get(&id) else { + return Vec::new(); + }; + let text = |value: String| { + Text::new_inline( + value, + appearance.ui_font_family(), + appearance.ui_font_size() - 1., + ) + .with_clip(ClipConfig::ellipsis()) + .with_color(theme.active_ui_text_color().into()) + .finish() + }; + let session_id = entry.session_id.clone(); + let session = text(entry.session_label.clone()); + let session = if session_id.is_some() { + Hoverable::new(session_mouse.clone(), move |_| session) + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(AIActivityAction::FilterSession( + session_id.clone(), + )) + }) + .with_cursor(Cursor::PointingHand) + .finish() + } else { + session + }; + let summary = entry.summary.clone(); + let activity = Hoverable::new(row_mouse.clone(), move |_| { + Text::new_inline( + summary, + appearance.ui_font_family(), + appearance.ui_font_size(), + ) + .with_clip(ClipConfig::ellipsis()) + .with_style(Properties::default().weight(if selected == Some(id) { + Weight::Semibold + } else { + Weight::Normal + })) + .with_color(theme.active_ui_text_color().into()) + .finish() + }) + .on_click(move |ctx, _, _| ctx.dispatch_typed_action(AIActivityAction::Select(id))) + .with_cursor(Cursor::PointingHand) + .finish(); + vec![ + text( + entry + .timestamp + .with_timezone(&chrono::Local) + .format("%H:%M:%S%.3f") + .to_string(), + ), + session, + text(entry.category.clone()), + activity, + text(entry.model.clone().unwrap_or_else(|| "—".to_owned())), + text( + entry + .duration_ms + .map(|ms| format!("{:.2} s", ms as f64 / 1000.)) + .unwrap_or_else(|| "—".to_owned()), + ), + ] + }) + .with_config(TableConfig { + border_width: 1., + border_color: theme.outline().into(), + outer_border: true, + column_dividers: false, + row_dividers: true, + cell_padding: 10., + header_background: theme.surface_2().into(), + row_background: RowBackground::uniform(theme.surface_1().into()), + fixed_header: false, + vertical_sizing: TableVerticalSizing::ExpandToContent, + measure_body_cells_for_intrinsic_widths: false, + }); + ClippedScrollable::vertical( + self.list_scroll.clone(), + table.finish(), + ScrollbarWidth::Auto, + theme.nonactive_ui_detail().into(), + theme.active_ui_detail().into(), + galaxyui::elements::Fill::None, + ) + .finish() + } +} + +/// Legacy/ACP and projected tool arguments arrive as bounded Debug strings. +/// Unwrap those strings for reading while retaining their operational metadata. +/// A truncated capture is deliberately left as text rather than parsed as JSON. +fn readable_detail(entry: &ActivityEntry) -> String { + let Ok(capture) = serde_json::from_str::(&entry.detail) else { + return entry.detail.clone(); + }; + let payload = &capture["payload"]; + let raw = if entry.event == "tool_call" { + payload.as_str() + } else if matches!( + entry.event.as_str(), + "raw_model_request" | "raw_model_response" + ) { + payload + .pointer("/raw_payload/payload") + .and_then(serde_json::Value::as_str) + } else { + None + }; + let Some(raw) = raw else { + return entry.detail.clone(); + }; + let decoded = serde_json::from_str::(raw).unwrap_or_else(|_| raw.to_owned()); + let mut metadata = capture; + if entry.event == "tool_call" { + metadata["payload"] = serde_json::json!("See captured content below"); + } else if let Some(raw_payload) = metadata + .pointer_mut("/payload/raw_payload") + .and_then(serde_json::Value::as_object_mut) + { + raw_payload.remove("payload"); + } + format!( + "{}\n\nCaptured content:\n{decoded}", + serde_json::to_string_pretty(&metadata).unwrap_or_default() + ) +} + +impl Entity for AIActivityView { + type Event = (); +} + +impl View for AIActivityView { + fn ui_name() -> &'static str { + "AIActivityView" + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let small_text = |text: String| { + Text::new( + text, + appearance.ui_font_family(), + appearance.ui_font_size() - 1., + ) + .soft_wrap(true) + .with_color(theme.sub_text_color(theme.background()).into()) + .finish() + }; + let search = Container::new(Clipped::new(ChildView::new(&self.search).finish()).finish()) + .with_horizontal_padding(10.) + .with_vertical_padding(8.) + .with_border(Border::all(1.).with_border_color(theme.outline().into())) + .finish(); + let mut filters = Flex::row() + .with_spacing(4.) + .with_cross_axis_alignment(CrossAxisAlignment::Center); + for (_, button) in &self.category_buttons { + filters.add_child(ChildView::new(button).finish()); + } + filters.add_child(ChildView::new(&self.all_sessions_button).finish()); + let filters = ConstrainedBox::new( + ClippedScrollable::horizontal( + self.filters_scroll.clone(), + filters.finish(), + ScrollbarWidth::Auto, + theme.nonactive_ui_detail().into(), + theme.active_ui_detail().into(), + galaxyui::elements::Fill::None, + ) + .finish(), + ) + .with_height(36.) + .finish(); + let session_label = self.session_filter.as_ref().map(|id| { + self.snapshot + .entries + .iter() + .find(|entry| entry.session_id.as_ref() == Some(id)) + .map(|entry| entry.session_label.clone()) + .unwrap_or_else(|| id.clone()) + }); + let count = self.filtered_entries(app).len(); + let mut footer = Flex::row() + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(small_text(format!( + "{} of {count} events · {}{}", + self.display_limit.min(count), + session_label + .map(|label| format!("Session: {label}")) + .unwrap_or_else(|| "All sessions · click a session to filter".to_owned()), + if self.snapshot.dropped > 0 { + format!(" · {} older events discarded", self.snapshot.dropped) + } else { + String::new() + } + ))); + if count > self.display_limit { + footer.add_child(ChildView::new(&self.more_button).finish()); + } + let detail = if self.selected.is_some() { + ClippedScrollable::vertical( + self.detail_scroll.clone(), + Container::new(ChildView::new(&self.detail).finish()) + .with_padding(Padding::uniform(12.)) + .finish(), + ScrollbarWidth::Auto, + theme.nonactive_ui_detail().into(), + theme.active_ui_detail().into(), + galaxyui::elements::Fill::None, + ) + .finish() + } else { + Container::new(small_text("Click an activity to inspect its request, response, or lifecycle details. Text is selectable and wrapped.".to_owned())) + .with_padding(Padding::uniform(16.)).finish() + }; + Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Max).with_spacing(10.) + .with_child(Flex::row().with_spacing(8.).with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(Expanded::new(1., search).finish()) + .with_child(ChildView::new(&self.recording_button).finish()) + .with_child(ChildView::new(&self.clear_button).finish()).finish()) + .with_child(filters) + .with_child(small_text(format!("{} · Local Galaxy request/result snapshots, up to 2,000 events / 16 MiB and 256 Ki characters per event. Recording continues when this window closes.", + if self.snapshot.recording { "Recording all sessions" } else { "Recording paused" }))) + .with_child(Expanded::new(1., self.render_table(app)).finish()) + .with_child(footer.finish()) + .with_child(Expanded::new(1., Container::new(detail) + .with_background(theme.background()) + .with_border(Border::all(1.).with_border_color(theme.outline().into())).finish()).finish()) + .finish() + } +} + +impl TypedActionView for AIActivityView { + type Action = AIActivityAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + AIActivityAction::ToggleRecording => { + super::set_recording(!super::snapshot().recording); + self.refresh(ctx); + } + AIActivityAction::Clear => { + super::clear(); + self.clear_detail(ctx); + self.refresh(ctx); + } + AIActivityAction::Select(id) => self.select_entry(*id, ctx), + AIActivityAction::FilterSession(session) => { + self.session_filter = session.clone(); + self.display_limit = PAGE_SIZE; + self.list_scroll = Default::default(); + self.synchronize_rows(ctx); + } + AIActivityAction::FilterCategory(category) => { + self.category_filter = category.clone(); + self.display_limit = PAGE_SIZE; + self.list_scroll = Default::default(); + self.synchronize_rows(ctx); + } + AIActivityAction::ShowMore => { + self.display_limit += PAGE_SIZE; + self.synchronize_rows(ctx); + } + } + } +} diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index bbe01533..a5b9ba20 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -821,6 +821,18 @@ fn log_tool_event( ); } +/// Stream the app's projected tool arguments into the inspector's bounded +/// writer instead of allocating an unbounded Debug string first. +#[cfg(not(target_family = "wasm"))] +struct ActivityToolArguments<'a>(&'a AIAgentActionType); + +#[cfg(not(target_family = "wasm"))] +impl serde::Serialize for ActivityToolArguments<'_> { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(&format_args!("{:#?}", self.0)) + } +} + pub struct BlocklistAIActionModel { executor: ModelHandle, active_session: ModelHandle, @@ -2029,6 +2041,22 @@ impl BlocklistAIActionModel { action.id, std::mem::discriminant(&action.action) ); + #[cfg(not(target_family = "wasm"))] + if crate::ai::activity::is_recording() { + crate::ai::activity::record_serializable( + "tool_call", + format!("Tool requested: {}", action_tool_name(action)), + serde_json::json!({ + "conversation_id": conversation_id.to_string(), + "action_id": action.id.to_string(), + "task_id": action.task_id.to_string(), + "tool_name": action_tool_name(action), + "payload_kind": "projected_tool_arguments", + "requires_result": action.requires_result, + }), + &ActivityToolArguments(&action.action), + ); + } } #[cfg(not(target_family = "wasm"))] log_tool_event( @@ -2616,6 +2644,41 @@ impl BlocklistAIActionModel { { tool_result.content = reason; } + #[cfg(not(target_family = "wasm"))] + if crate::ai::activity::is_recording() { + #[derive(serde::Serialize)] + struct ResultPayload<'a> { + content: &'a str, + status: &'a ToolResultStatus, + image_count: usize, + image_bytes_omitted: usize, + } + crate::ai::activity::record_serializable( + "tool_result", + format!( + "Tool result: {}", + action_result_type_name(&action_result.result) + ), + serde_json::json!({ + "conversation_id": conversation_id.to_string(), + "action_id": action_result.id.to_string(), + "task_id": action_result.task_id.to_string(), + "result_type": action_result_type_name(&action_result.result), + "permission_denied": permission_denied, + "payload_kind": "provider_neutral_tool_result", + }), + &ResultPayload { + content: &tool_result.content, + status: &tool_result.status, + image_count: tool_result.images.len(), + image_bytes_omitted: tool_result + .images + .iter() + .map(|image| image.data.len()) + .sum(), + }, + ); + } if execution_ref.is_none() { self.finished_tool_results .entry(conversation_id) diff --git a/app/src/ai/blocklist/action_model/risk_review.rs b/app/src/ai/blocklist/action_model/risk_review.rs index 1e3516c9..26966488 100644 --- a/app/src/ai/blocklist/action_model/risk_review.rs +++ b/app/src/ai/blocklist/action_model/risk_review.rs @@ -692,7 +692,18 @@ impl BlocklistAIActionModel { request.max_output_tokens = Some(u64::from(REVIEW_MAX_OUTPUT_TOKENS)); let started = Instant::now(); ctx.spawn( - assess(provider_config, request, cancel, control), + assess( + provider_config, + request, + cancel, + control, + #[cfg(not(target_family = "wasm"))] + serde_json::json!({ + "conversation_id": conversation_id.to_string(), + "action_id": action.id.to_string(), + "request_id": token.to_string(), + }), + ), move |me, result, ctx| me.finish_action_review(key, token, started, result, ctx), ); None @@ -1010,18 +1021,41 @@ async fn assess( request: TurnRequest, sender: TurnCommandSender, control: TurnControl, + #[cfg(not(target_family = "wasm"))] activity_identity: serde_json::Value, ) -> anyhow::Result { anyhow::ensure!( serde_json::to_vec(&request)?.len() <= MAX_REVIEW_INPUT_BYTES, "action is too large for a reliable bounded assessment" ); + #[cfg(not(target_family = "wasm"))] + let started = Instant::now(); + #[cfg(not(target_family = "wasm"))] + let activity_model_id = request.model.as_str().to_string(); + #[cfg(not(target_family = "wasm"))] + let activity_context = || { + let mut context = activity_identity.clone(); + context["model_id"] = serde_json::json!(activity_model_id); + context["elapsed_ms"] = serde_json::json!(started.elapsed().as_millis()); + context + }; let review = async { let model_id = request.model.as_str().to_string(); let runtime = provider_runtime_for_request(provider_config, &request).await?; + #[cfg(not(target_family = "wasm"))] + if crate::ai::activity::is_recording() { + crate::ai::activity::record_serializable( + "risk_review_request", + "Permission assessment request", + activity_context(), + &request, + ); + } let mut stream = runtime.start_turn(request, control).await?; let mut text = String::new(); let mut reasoning_bytes = 0usize; let mut output_tokens = None; + #[cfg(not(target_family = "wasm"))] + let mut response_usage = None; while let Some(event) = stream.next().await { match event? { AgentEvent::TextDelta { text: delta } => { @@ -1031,12 +1065,25 @@ async fn assess( ); text.push_str(&delta); } - AgentEvent::TurnStopped { - reason: StopReason::Completed, - } => return Assessment::parse(&text).map_err(|error| anyhow::anyhow!( - "{error} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len() - )), AgentEvent::TurnStopped { reason } => { + #[cfg(not(target_family = "wasm"))] + if crate::ai::activity::is_recording() { + crate::ai::activity::record_serializable( + "risk_review_response", + "Permission assessment response", + activity_context(), + &serde_json::json!({ + "text": text, + "usage": response_usage, + "stop_reason": reason, + }), + ); + } + if reason == StopReason::Completed { + return Assessment::parse(&text).map_err(|error| anyhow::anyhow!( + "{error} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len() + )); + } anyhow::bail!("permission reviewer stopped with {reason:?} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len()) } AgentEvent::Tool { .. } => { @@ -1050,6 +1097,10 @@ async fn assess( } AgentEvent::UsageUpdated { usage } => { output_tokens = Some(usage.output_tokens); + #[cfg(not(target_family = "wasm"))] + { + response_usage = Some(usage); + } } AgentEvent::TurnStarted { .. } | AgentEvent::KeepAlive @@ -1066,6 +1117,18 @@ async fn assess( let timeout = FutureExt::fuse(Timer::after(REVIEW_TIMEOUT)); futures::pin_mut!(review, timeout); let result = futures::select_biased! { result = review => result, _ = timeout => Err(anyhow::anyhow!("assessment timed out")) }; + #[cfg(not(target_family = "wasm"))] + if crate::ai::activity::is_recording() { + if let Err(error) = &result { + let mut context = activity_context(); + context["error"] = serde_json::json!(crate::ai::remote_logging::sanitize_error(error)); + crate::ai::activity::record( + "risk_review_error", + "Permission assessment failed", + context, + ); + } + } let _ = sender.try_send(TurnCommand::Cancel); result } diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index 75b809db..0b260f20 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -37,6 +37,7 @@ use persistence::model::ProviderConversationContext; use session_sharing_protocol::common::ParticipantId; use settings::Setting; pub use slash_command::*; +use uuid::Uuid; use warp_multi_agent_api::{message, Task, ToolType}; use warpui::r#async::{SpawnedFutureHandle, Timer}; use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; @@ -53,6 +54,8 @@ use super::orchestration_events::{OrchestrationEventService, OrchestrationEventS use super::orchestration_topology::descendant_conversation_ids_in_spawn_order; use super::queued_query::{QueuedQueryId, QueuedQueryModel}; use super::{BlocklistAIInputModel, ResponseStreamId}; +#[cfg(not(target_family = "wasm"))] +use crate::ai::activity; use crate::ai::agent::api::{self, ServerConversationToken}; use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; use crate::ai::agent::task::TaskId; @@ -416,6 +419,49 @@ fn progressive_summary_messages(summary: String) -> Vec { async fn collect_progressive_summary( provider_config: ProviderConfig, request: TurnRequest, + mut activity_context: serde_json::Value, +) -> anyhow::Result<(String, Usage)> { + let started_at = Instant::now(); + activity_context["model_id"] = serde_json::json!(request.model.as_str()); + activity_context["request_id"] = serde_json::json!(Uuid::new_v4().to_string()); + activity_context["snapshot_kind"] = serde_json::json!("provider_neutral"); + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + activity::record_serializable( + "compaction_request", + "Compaction model request", + activity_context.clone(), + &request, + ); + } + let result = collect_progressive_summary_inner(provider_config, request).await; + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + activity_context["elapsed_ms"] = serde_json::json!(started_at.elapsed().as_millis() as u64); + match &result { + Ok((summary, usage)) => activity::record_serializable( + "compaction_response", + "Compaction model response", + activity_context, + &(summary, usage), + ), + Err(error) => { + activity_context["error"] = + serde_json::json!(remote_logging::sanitize_error(error)); + activity::record( + "compaction_error", + "Compaction request failed", + activity_context, + ); + } + } + } + result +} + +async fn collect_progressive_summary_inner( + provider_config: ProviderConfig, + request: TurnRequest, ) -> anyhow::Result<(String, Usage)> { let (command_sender, control) = turn_control(); let startup = async move { @@ -5482,6 +5528,17 @@ impl BlocklistAIController { )); }; + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + let title = conversation + .title() + .unwrap_or_else(|| "Untitled session".to_string()); + let label = conversation + .agent_name() + .map_or_else(|| title.clone(), |name| format!("{name}: {title}")); + activity::register_session(conversation.id().to_string(), label); + } + let active_tasks = conversation.compute_active_tasks(); ( @@ -7483,6 +7540,7 @@ impl BlocklistAIController { }), }, ); + let summary_activity_identity = identity.clone(); ctx.spawn( async move { let mut errors = Vec::new(); @@ -7493,7 +7551,17 @@ impl BlocklistAIController { ); request.system_prompt = Some(PROGRESSIVE_SUMMARY_PROMPT.to_string()); request.max_output_tokens = Some(u64::from(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS)); - match collect_progressive_summary(candidate.provider_config, request).await { + match collect_progressive_summary( + candidate.provider_config, + request, + serde_json::json!({ + "conversation_id": conversation_id, + "provider_run_id": summary_activity_identity.run_id.as_str(), + "stream_id": summary_activity_identity.stream_id.as_str(), + }), + ) + .await + { Ok((summary, usage)) => { return (plan, Ok((summary, usage, candidate.request_model_id))); } @@ -7764,6 +7832,31 @@ impl BlocklistAIController { if let Some((usage, summarizer_model_id)) = recorded_usage { self.record_progressive_summary_usage(conversation_id, usage, summarizer_model_id, ctx); } + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + activity::record( + if summary_applied { + "compaction_applied" + } else { + "compaction_discarded" + }, + if summary_applied { + "Compaction applied at model boundary" + } else { + "Compaction was not applied" + }, + serde_json::json!({ + "conversation_id": conversation_id, + "applied": summary_applied, + "error": failure.as_deref().map(remote_logging::sanitize_error), + "summarized_message_count": plan.split_point, + "trigger_context_tokens": plan.trigger_context_tokens, + "context_tokens_after": failure_context_tokens, + "context_usage_before": plan.context_usage, + "waiting_for_context": waiting_for_context, + }), + ); + } if waiting_for_context && !summary_applied { self.fail_active_provider_run( conversation_id, @@ -7931,6 +8024,20 @@ impl BlocklistAIController { conversation_id: AIConversationId, ctx: &mut ModelContext, ) { + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + if let Some(conversation) = + BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id) + { + let title = conversation + .title() + .unwrap_or_else(|| "Untitled session".to_string()); + let label = conversation + .agent_name() + .map_or_else(|| title.clone(), |name| format!("{name}: {title}")); + activity::register_session(conversation_id.to_string(), label); + } + } // A follow-up owns a new generation. Its history must never be blocked // by, or replaced with, a detached summary from the previous generation. // Cancellation also discards summaries before they can fail recovery. @@ -7952,6 +8059,18 @@ impl BlocklistAIController { .remove(&conversation_id) .is_some() { + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + activity::record( + "compaction_discarded", + "Compaction generation ended", + serde_json::json!({ + "conversation_id": conversation_id, + "stale_generation": stale_summary, + "cancelled": cancelling, + }), + ); + } BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| { if let Some(conversation) = history.conversation_mut(&conversation_id) { conversation.set_has_pending_progressive_summary(false); @@ -7987,6 +8106,8 @@ impl BlocklistAIController { let Some(mut run) = slot.run.take() else { return; }; + #[cfg(not(target_family = "wasm"))] + run.coordinator.set_activity_session_id(conversation_id); let checkpoint_template = match ActiveProviderRunCheckpoint::from_active_run(&run) { Ok(checkpoint) => checkpoint, Err(error) => { @@ -9529,6 +9650,17 @@ impl BlocklistAIController { if BlocklistAIHistoryModel::as_ref(ctx).conversation_status(&conversation_id) != Some(&ConversationStatus::Success) { + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + activity::record( + "compaction_discarded", + "Compaction discarded for unfinished session", + serde_json::json!({ + "conversation_id": conversation_id, + "reason": "conversation did not complete successfully", + }), + ); + } return; } @@ -9554,6 +9686,13 @@ impl BlocklistAIController { "[progressive-summary] Discarding stale idle summary for conversation {:?}; the snapshotted prefix no longer matches", conversation_id, ); + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + activity::record("compaction_discarded", "Compaction prefix no longer matches", serde_json::json!({ + "conversation_id": conversation_id, + "reason": "snapshotted prefix no longer matches conversation history", + })); + } return; } if !summary_reduces_context { @@ -9561,6 +9700,13 @@ impl BlocklistAIController { "[progressive-summary] Idle summary did not reduce context for conversation {:?}", conversation_id, ); + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + activity::record("compaction_discarded", "Compaction did not reduce context", serde_json::json!({ + "conversation_id": conversation_id, + "reason": "summary did not reduce context", + })); + } return; } @@ -9587,6 +9733,16 @@ impl BlocklistAIController { conversation_id, }); conversation.write_updated_conversation_state(ctx); + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + activity::record("compaction_applied", "Compaction applied after session completed", serde_json::json!({ + "conversation_id": conversation_id, + "context_tokens_after": new_context_tokens, + "context_limit": plan.active_context_limit, + "retained_message_count": retained_count, + "summarized_message_count": plan.split_point, + })); + } log::info!( "[progressive-summary] Applied background summary after active run completion {:?}: ~{} tokens ({:.1}% of {} context), {} tail messages preserved", conversation_id, @@ -11361,6 +11517,21 @@ impl BlocklistAIController { candidate_names, ); + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + activity::record( + "compaction_started", + "Background compaction started", + serde_json::json!({ + "conversation_id": conversation_id, + "summarized_message_count": messages_count, + "retained_message_count": retained_messages_count, + "context_limit": active_context_limit, + "persistent_context_tokens": persistent_context_tokens, + "trigger_usage": PROGRESSIVE_SUMMARY_TRIGGER_USAGE, + }), + ); + } let summarize_messages = vec![ConversationMessage { role: MessageRole::User, content: MessageContent::Text(summarize_content), @@ -11376,7 +11547,11 @@ impl BlocklistAIController { ); request.system_prompt = Some(summarize_prompt.to_string()); request.max_output_tokens = Some(u64::from(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS)); - match collect_progressive_summary(candidate.provider_config, request).await { + match collect_progressive_summary( + candidate.provider_config, + request, + serde_json::json!({ "conversation_id": conversation_id }), + ).await { Ok((summary, usage)) => { return Ok((summary, usage, candidate.request_model_id)); } @@ -11449,6 +11624,16 @@ impl BlocklistAIController { conversation.set_context_window_usage(new_usage.clamp(0.0, 1.0)); conversation.set_current_context_tokens(new_context_tokens); + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + activity::record("compaction_applied", "Compaction applied to session history", serde_json::json!({ + "conversation_id": conversation_id, + "context_tokens_after": new_context_tokens, + "context_limit": active_context_limit, + "summarized_message_count": drain_count, + "retained_message_count": conversation.bedrock_message_history().len(), + })); + } log::info!( "[progressive-summary] Post-summary: ~{} tokens ({:.1}% of {} context), {} messages retained", new_context_tokens, diff --git a/app/src/ai/blocklist/controller/response_stream.rs b/app/src/ai/blocklist/controller/response_stream.rs index 75921421..ccdf9b96 100644 --- a/app/src/ai/blocklist/controller/response_stream.rs +++ b/app/src/ai/blocklist/controller/response_stream.rs @@ -430,11 +430,19 @@ impl ResponseStream { &self, request_id: Uuid, payload_kind: &str, - raw_payload: impl AsRef, + raw_payload: &impl std::fmt::Debug, ctx: &mut ModelContext, ) { let Some(raw_payload) = remote_logging::raw_model_payload_context(ctx, |max_chars| { - remote_logging::tail_limited_payload_context(raw_payload.as_ref(), max_chars) + struct DebugPayload<'a, T>(&'a T); + impl serde::Serialize for DebugPayload<'_, T> { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(&format_args!("{:#?}", self.0)) + } + } + remote_logging::prefix_bounded_payload_context(max_chars, |writer| { + serde_json::to_writer_pretty(writer, &DebugPayload(raw_payload)) + }) }) else { return; }; @@ -956,7 +964,7 @@ impl ResponseStream { self.log_raw_model_response( request_id, event_type_name, - format!("{response_event:#?}"), + response_event, ctx, ); if let Some(event_type) = &response_event.r#type { diff --git a/app/src/ai/crosscheck/reviewer.rs b/app/src/ai/crosscheck/reviewer.rs index 234e60af..3a213b29 100644 --- a/app/src/ai/crosscheck/reviewer.rs +++ b/app/src/ai/crosscheck/reviewer.rs @@ -118,7 +118,17 @@ impl CrosscheckReviewer { // Spawn the reviewer request asynchronously let conversation_id_for_task = conversation_id; ctx.spawn( - Self::invoke_reviewer(agent_output, model_id, provider_config), + Self::invoke_reviewer( + agent_output, + model_id, + provider_config, + #[cfg(not(target_family = "wasm"))] + serde_json::json!({ + "conversation_id": conversation_id, + "iteration": current_iteration, + "max_iterations": max, + }), + ), move |me, reviewer_response, ctx| { me.handle_reviewer_response( conversation_id_for_task, @@ -206,6 +216,70 @@ impl CrosscheckReviewer { agent_output: String, model_id: String, provider_config: ProviderConfig, + #[cfg(not(target_family = "wasm"))] mut activity_context: serde_json::Value, + ) -> Result { + #[cfg(not(target_family = "wasm"))] + let started_at = instant::Instant::now(); + #[cfg(not(target_family = "wasm"))] + if crate::ai::activity::is_recording() { + let effective_model = match &provider_config { + ProviderConfig::OpenAI(config) => config.model.as_deref().unwrap_or(&model_id), + ProviderConfig::Bedrock(_) | ProviderConfig::None => &model_id, + }; + activity_context["model_id"] = serde_json::json!(effective_model); + activity_context["request_id"] = serde_json::json!(uuid::Uuid::new_v4().to_string()); + activity_context["snapshot_kind"] = serde_json::json!("reviewer_input"); + #[derive(serde::Serialize)] + struct ReviewerInput<'a> { + system_prompt: &'a str, + user_prompt_prefix: &'a str, + agent_output: &'a str, + max_output_tokens: u32, + } + crate::ai::activity::record_serializable( + "crosscheck_model_request", + "Crosscheck reviewer request", + activity_context.clone(), + &ReviewerInput { + system_prompt: prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT, + user_prompt_prefix: "Please review the following agent output:\n\n---\n\n", + agent_output: &agent_output, + max_output_tokens: REVIEWER_MAX_OUTPUT_TOKENS, + }, + ); + } + let response = Self::invoke_reviewer_inner(agent_output, model_id, provider_config).await; + #[cfg(not(target_family = "wasm"))] + if crate::ai::activity::is_recording() { + activity_context["elapsed_ms"] = + serde_json::json!(started_at.elapsed().as_millis() as u64); + // These compatibility helpers return text only, with no usage report. + activity_context["usage_reported"] = serde_json::json!(false); + match &response { + Ok(text) => crate::ai::activity::record_serializable( + "crosscheck_model_response", + "Crosscheck reviewer response", + activity_context, + text, + ), + Err(error) => { + activity_context["error"] = + serde_json::json!(crate::ai::remote_logging::sanitize_error(error)); + crate::ai::activity::record( + "crosscheck_model_error", + "Crosscheck reviewer failed", + activity_context, + ); + } + } + } + response + } + + async fn invoke_reviewer_inner( + agent_output: String, + model_id: String, + provider_config: ProviderConfig, ) -> Result { match provider_config { ProviderConfig::OpenAI(config) @@ -217,7 +291,8 @@ impl CrosscheckReviewer { reasoning_effort: config.reasoning_effort, max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)), }, - ).with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver()); + ) + .with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver()); runtime .complete_text( prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(), diff --git a/app/src/ai/mod.rs b/app/src/ai/mod.rs index 521efbf3..0df3a0df 100644 --- a/app/src/ai/mod.rs +++ b/app/src/ai/mod.rs @@ -4,6 +4,8 @@ //! The side panel Warp AI implementation lives in `super::ai_assistant`. #[cfg(not(target_family = "wasm"))] pub(crate) mod acp; +#[cfg(not(target_family = "wasm"))] +pub(crate) mod activity; pub(crate) mod active_agent_views_model; pub(crate) mod agent; pub(crate) mod agent_conversations_model; diff --git a/app/src/ai/remote_logging.rs b/app/src/ai/remote_logging.rs index 00bb7929..d37a2882 100644 --- a/app/src/ai/remote_logging.rs +++ b/app/src/ai/remote_logging.rs @@ -43,7 +43,7 @@ pub(crate) enum RemoteLogLevel { } impl RemoteLogLevel { - fn as_str(self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { Self::Info => "info", Self::Warn => "warn", @@ -99,6 +99,10 @@ pub(crate) fn log_model_event(ctx: &mut ModelContext, record: RemoteLogRec where M: Entity, { + super::activity::record_log(&record); + if is_raw_payload(&record) && !remote_payloads_enabled(AISettings::as_ref(ctx)) { + return; + } let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else { return; }; @@ -126,6 +130,10 @@ where /// Views must log without updating AISettings: its singleton cannot be read /// while it is temporarily borrowed by a model update. pub(crate) fn log_view_event(ctx: &mut ViewContext, record: RemoteLogRecord) { + super::activity::record_log(&record); + if is_raw_payload(&record) && !remote_payloads_enabled(AISettings::as_ref(ctx)) { + return; + } let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else { return; }; @@ -219,17 +227,34 @@ where M: Entity, { let settings = AISettings::as_ref(ctx); - if !*settings.remote_logging_enabled.value() - || !*settings.remote_logging_log_model_payloads.value() - || settings.remote_logging_api_key.value().trim().is_empty() - { + let remote_enabled = remote_payloads_enabled(settings); + let local_enabled = super::activity::is_recording(); + if !remote_enabled && !local_enabled { return None; } - let max_chars = (*settings.remote_logging_model_payload_max_chars.value()).max(1); + let remote_max = (*settings.remote_logging_model_payload_max_chars.value()).max(1); + let max_chars = if remote_enabled { + remote_max + } else { + super::activity::MAX_PAYLOAD_CHARS + }; Some(build_payload(max_chars)) } +fn remote_payloads_enabled(settings: &AISettings) -> bool { + *settings.remote_logging_enabled.value() + && *settings.remote_logging_log_model_payloads.value() + && !settings.remote_logging_api_key.value().trim().is_empty() +} + +fn is_raw_payload(record: &RemoteLogRecord) -> bool { + matches!( + record.context.get("event").and_then(Value::as_str), + Some("raw_model_request" | "raw_model_response") + ) +} + fn enrich_context(mut context: Value) -> Value { let Value::Object(ref mut map) = context else { return json!({ diff --git a/app/src/ai/runtime/provider_run_coordinator.rs b/app/src/ai/runtime/provider_run_coordinator.rs index 40889d09..b35b38af 100644 --- a/app/src/ai/runtime/provider_run_coordinator.rs +++ b/app/src/ai/runtime/provider_run_coordinator.rs @@ -12,12 +12,15 @@ use galaxy_agent_core::{ ModelFailureDisposition, PendingToolBatch, ProviderModelCall, ProviderRequestProfile, ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunOutcome, ProviderRunPhase, ProviderRunProtocolError, ProviderRunState, ProviderRunStep, RunEpoch, - RuntimeKind, StopReason, TaskListSnapshot, ToolEvent, TurnCommand, TurnControl, TurnRequest, Usage, + RuntimeKind, StopReason, TaskListSnapshot, ToolEvent, TurnCommand, TurnControl, TurnRequest, + Usage, }; use instant::Instant; use warpui::r#async::Timer; use super::task_progress::{add_task_list_context, initial_task_list, task_list_from_plan_batch}; +#[cfg(not(target_family = "wasm"))] +use crate::ai::activity; use crate::ai::agent::conversation::AIConversationId; #[cfg(not(target_family = "wasm"))] use crate::ai::model_output_logging; @@ -285,6 +288,8 @@ pub(crate) struct ProviderRunCoordinator { model_event_idle_timeout: Duration, context_compaction_requested: bool, max_context_tokens: Option, + #[cfg(not(target_family = "wasm"))] + activity_session_id: Option, } impl ProviderRunCoordinator { @@ -327,6 +332,8 @@ impl ProviderRunCoordinator { model_event_idle_timeout: PROVIDER_MODEL_EVENT_IDLE_TIMEOUT, context_compaction_requested: false, max_context_tokens: None, + #[cfg(not(target_family = "wasm"))] + activity_session_id: None, }) } @@ -336,6 +343,30 @@ impl ProviderRunCoordinator { self.model_event_idle_timeout = event_idle; } + /// Local inspection identity only; never changes provider requests or checkpoints. + #[cfg(not(target_family = "wasm"))] + pub(crate) fn set_activity_session_id(&mut self, conversation_id: AIConversationId) { + self.activity_session_id = Some(conversation_id.to_string()); + } + + #[cfg(not(target_family = "wasm"))] + fn activity_context( + &self, + call: &ProviderModelCall, + profile: &ProviderRunProfile, + ) -> serde_json::Value { + serde_json::json!({ + "conversation_id": self.activity_session_id, + "provider_run_id": call.work_id.run_id.as_str(), + "provider_epoch": call.work_id.epoch.get(), + "profile": call.profile.as_str(), + "runtime_id": profile.runtime.descriptor().id, + "model_id": profile.request.model.as_str(), + "retry_attempt": call.retry_attempt, + "snapshot_kind": "provider_neutral", + }) + } + pub(crate) fn run(&self) -> &ProviderRun { &self.run } @@ -730,6 +761,15 @@ impl ProviderRunCoordinator { profile.max_context_tokens.or(self.max_context_tokens), self.run.task_list(), ); + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + activity::record_serializable( + "model_request", + "Model request", + self.activity_context(&call, &profile), + &request, + ); + } let started_at = Instant::now(); let (attempt_sender, attempt_control) = turn_control(); let start_future = profile.runtime.start_turn(request, attempt_control).fuse(); @@ -779,6 +819,8 @@ impl ProviderRunCoordinator { }; futures::pin_mut!(stream); let mut buffer = ModelTurnBuffer::default(); + #[cfg(not(target_family = "wasm"))] + let mut usage_reported = false; loop { let next_event = stream.next().fuse(); @@ -1014,6 +1056,10 @@ impl ProviderRunCoordinator { return Ok(()); } buffer.usage.clone_from(&usage); + #[cfg(not(target_family = "wasm"))] + { + usage_reported = true; + } if !self .project_or_fail_acknowledged( ProviderRunProjection::ModelEvent { @@ -1056,6 +1102,19 @@ impl ProviderRunCoordinator { { return Ok(()); } + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + let mut context = self.activity_context(&call, &profile); + context["elapsed_ms"] = serde_json::json!(elapsed_millis(started_at)); + context["stop_reason"] = serde_json::json!(&reason); + context["usage_reported"] = serde_json::json!(usage_reported); + activity::record_serializable( + "model_response", + "Model response", + context, + &buffer, + ); + } if reason == StopReason::Cancelled { self.run.cancel("provider model call was cancelled")?; return Ok(()); @@ -1159,6 +1218,15 @@ impl ProviderRunCoordinator { "retry_attempt": call.retry_attempt, "payload": &error, })); + #[cfg(not(target_family = "wasm"))] + if activity::is_recording() { + let mut context = self.activity_context(call, profile); + context["elapsed_ms"] = serde_json::json!(elapsed_millis(started_at)); + context["error_kind"] = serde_json::json!(&error.kind); + context["error"] = + serde_json::json!(crate::ai::remote_logging::sanitize_error(&error.message)); + activity::record("model_error", "Model request failed", context); + } let error_message = error.message.clone(); if error.kind == AgentErrorKind::ContextWindowExceeded { self.run.prepare_context_compaction(&call.work_id)?; @@ -1243,7 +1311,7 @@ impl ProviderRunCoordinator { } } -#[derive(Default)] +#[derive(Default, serde::Serialize)] struct ModelTurnBuffer { started: bool, text: String, diff --git a/app/src/search/slash_command_menu/static_commands/commands.rs b/app/src/search/slash_command_menu/static_commands/commands.rs index 8650ad7e..09d1df31 100644 --- a/app/src/search/slash_command_menu/static_commands/commands.rs +++ b/app/src/search/slash_command_menu/static_commands/commands.rs @@ -104,6 +104,15 @@ pub const BACKGROUND_JOBS: StaticCommand = StaticCommand { argument: None, }; +pub const AI_ACTIVITY: StaticCommand = StaticCommand { + name: "/ai-activity", + description: "Inspect model requests, responses, compaction, and tools across sessions", + icon_path: "bundled/svg/dataflow.svg", + availability: Availability::empty(), + auto_enter_ai_mode: false, + argument: None, +}; + pub static ADD_PROMPT: LazyLock = LazyLock::new(|| StaticCommand { name: "/add-prompt", description: "Add new Agent prompt", @@ -713,6 +722,7 @@ fn all_commands() -> Vec { if !cfg!(target_family = "wasm") { commands.extend([EDIT.clone(), EXPORT_TO_FILE.clone()]); + commands.push(AI_ACTIVITY); } if cfg!(all(feature = "local_fs", not(target_family = "wasm"))) { commands.push(BACKGROUND_JOBS); diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 10458f18..94b8e7ef 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1861,6 +1861,13 @@ pub fn init(app: &mut AppContext) { ) .with_enabled(|| ContextFlag::NetworkLogConsole.is_enabled())]); + #[cfg(not(target_family = "wasm"))] + app.register_editable_bindings([EditableBinding::new( + "workspace:open_ai_activity", + "Show AI Activity", + WorkspaceAction::OpenAIActivity, + )]); + app.register_editable_bindings([EditableBinding::new( "input:clear_screen", "Clear screen", diff --git a/app/src/terminal/input/slash_commands/data_source/mod.rs b/app/src/terminal/input/slash_commands/data_source/mod.rs index 63a6295d..014a9251 100644 --- a/app/src/terminal/input/slash_commands/data_source/mod.rs +++ b/app/src/terminal/input/slash_commands/data_source/mod.rs @@ -209,7 +209,8 @@ impl SlashCommandDataSource { /// Slash commands that are available in CLI agent rich input mode. /// Add command names here to make them accessible when composing prompts /// for a running CLI agent (Claude Code, Codex, etc.). - const CLI_AGENT_INPUT_ALLOWED_COMMANDS: &[&str] = &["/prompts", "/skills", "/background-jobs"]; + const CLI_AGENT_INPUT_ALLOWED_COMMANDS: &[&str] = + &["/prompts", "/skills", "/background-jobs", "/ai-activity"]; fn is_cloud_mode(&self, ctx: &AppContext) -> bool { self.is_cloud_mode_v2 diff --git a/app/src/terminal/input/slash_commands/mod.rs b/app/src/terminal/input/slash_commands/mod.rs index 4f4bb49d..2a163c76 100644 --- a/app/src/terminal/input/slash_commands/mod.rs +++ b/app/src/terminal/input/slash_commands/mod.rs @@ -782,6 +782,9 @@ impl Input { _background_jobs if command.name == commands::BACKGROUND_JOBS.name => { ctx.dispatch_typed_action(&TerminalAction::OpenBackgroundJobs { job_id: None }); } + _ai_activity if command.name == commands::AI_ACTIVITY.name => { + ctx.dispatch_typed_action(&WorkspaceAction::OpenAIActivity); + } _ if command.name == commands::HOST.name => { if !self.is_cloud_mode_input_v2_composing(ctx) { return false; diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index efd3a2e6..c590cb66 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -875,6 +875,8 @@ pub enum WorkspaceAction { /// Opens (or focuses) the in-app network log pane as a right-split of the /// active pane group. Gated on `ContextFlag::NetworkLogConsole`. OpenNetworkLogPane, + /// Opens the app-wide inspector for model, compaction, and tool activity. + OpenAIActivity, } impl From<&WorkspaceAction> for LoginGatedFeature { @@ -1195,7 +1197,8 @@ impl WorkspaceAction { | ShowHandoffEnvironmentCreationModal | ShowCloudModeV2EnvironmentCreationModal | OpenCreateAuthSecretModal { .. } - | OpenNetworkLogPane => false, + | OpenNetworkLogPane + | OpenAIActivity => false, #[cfg(debug_assertions)] ShowHoaOnboardingFlow => false, #[cfg(target_family = "wasm")] diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index e8d4a46d..110f0e93 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -155,6 +155,8 @@ use super::util::{ }; use super::{util, ActiveSession, TabBarDropTargetData, TabBarLocation, WorkspaceRegistry}; use crate::ai::active_agent_views_model::ActiveAgentViewsModel; +#[cfg(not(target_family = "wasm"))] +use crate::ai::activity::view::AIActivityView; use crate::ai::agent::api::ServerConversationToken; #[cfg(not(target_family = "wasm"))] use crate::ai::agent::conversation::AIAgentHarness; @@ -1060,6 +1062,8 @@ pub struct Workspace { new_worktree_modal: ModalViewState>, #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] background_jobs_modal: ModalViewState>, + #[cfg(not(target_family = "wasm"))] + ai_activity_modal: ModalViewState>, close_session_confirmation_dialog: ViewHandle, rewind_confirmation_dialog: ViewHandle, delete_conversation_confirmation_dialog: ViewHandle, @@ -2181,6 +2185,43 @@ impl Workspace { ModalViewState::new(modal) } + #[cfg(not(target_family = "wasm"))] + fn build_ai_activity_modal( + ctx: &mut ViewContext, + ) -> ModalViewState> { + let body = ctx.add_typed_action_view(AIActivityView::new); + let modal = ctx.add_typed_action_view(|ctx| { + Modal::new(Some("AI Activity".to_owned()), body, ctx) + .with_modal_style(UiComponentStyles { + width: Some(1120.), + height: Some(760.), + ..Default::default() + }) + .with_header_style(UiComponentStyles { + height: Some(58.), + ..Default::default() + }) + .with_body_style(UiComponentStyles { + padding: Some(Coords::uniform(12.)), + height: Some(680.), + ..Default::default() + }) + .with_max_height_percentage(0.9) + .with_dismiss_on_click() + }); + ctx.subscribe_to_view(&modal, |me, _, event, ctx| { + if matches!(event, ModalEvent::Close) { + me.ai_activity_modal.close(); + me.ai_activity_modal.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |view, ctx| view.on_close(ctx)); + }); + me.focus_active_tab(ctx); + ctx.notify(); + } + }); + ModalViewState::new(modal) + } + fn build_remove_tab_config_confirmation_dialog( ctx: &mut ViewContext, ) -> ViewHandle { @@ -3026,6 +3067,8 @@ impl Workspace { let new_worktree_modal = Self::build_new_worktree_modal(ctx); #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] let background_jobs_modal = Self::build_background_jobs_modal(ctx); + #[cfg(not(target_family = "wasm"))] + let ai_activity_modal = Self::build_ai_activity_modal(ctx); let session_config_modal = Self::build_session_config_modal(ctx); @@ -3427,6 +3470,8 @@ impl Workspace { new_worktree_modal, #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] background_jobs_modal, + #[cfg(not(target_family = "wasm"))] + ai_activity_modal, close_session_confirmation_dialog, rewind_confirmation_dialog, delete_conversation_confirmation_dialog, @@ -24012,6 +24057,16 @@ impl TypedActionView for Workspace { OpenNetworkLogPane => { self.open_network_log_pane(ctx); } + OpenAIActivity => { + #[cfg(not(target_family = "wasm"))] + { + self.ai_activity_modal.view.update(ctx, |modal, ctx| { + modal.body().update(ctx, |view, ctx| view.on_open(ctx)); + }); + self.ai_activity_modal.open(); + ctx.notify(); + } + } FixSettingsWithOz { error_description } => { use crate::ai::skills::SkillManager; let modify_settings_skill = SkillManager::as_ref(ctx) @@ -26869,6 +26924,10 @@ impl View for Workspace { if self.background_jobs_modal.is_open() { stack.add_child(self.background_jobs_modal.render()); } + #[cfg(not(target_family = "wasm"))] + if self.ai_activity_modal.is_open() { + stack.add_child(self.ai_activity_modal.render()); + } if self.workflow_modal.as_ref(app).is_open() { stack.add_child(ChildView::new(&self.workflow_modal).finish());