Add app-wide AI activity inspector
This commit is contained in:
@@ -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<Mutex<ActivityStore>> = LazyLock::new(Mutex::default);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ActivityEntry {
|
||||
pub id: u64,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub session_id: Option<String>,
|
||||
pub session_label: String,
|
||||
pub category: String,
|
||||
pub event: String,
|
||||
pub summary: String,
|
||||
pub model: Option<String>,
|
||||
pub duration_ms: Option<u64>,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
pub(crate) struct ActivitySnapshot {
|
||||
pub recording: bool,
|
||||
pub revision: u64,
|
||||
pub dropped: usize,
|
||||
pub entries: Vec<Arc<ActivityEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ActivityStore {
|
||||
entries: VecDeque<Arc<ActivityEntry>>,
|
||||
labels: HashMap<String, String>,
|
||||
label_order: VecDeque<String>,
|
||||
sessions: HashMap<String, String>,
|
||||
correlation_order: VecDeque<String>,
|
||||
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<String>, label: impl Into<String>) {
|
||||
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<String>, 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<String>,
|
||||
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<u64> {
|
||||
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::<String>()
|
||||
);
|
||||
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<Utc>,
|
||||
) {
|
||||
// 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::<String>())
|
||||
})
|
||||
},
|
||||
);
|
||||
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::<ActivityEntry>()
|
||||
+ 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<String> {
|
||||
["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<String> {
|
||||
[
|
||||
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;
|
||||
@@ -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::<usize>()
|
||||
);
|
||||
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::<usize>()
|
||||
);
|
||||
}
|
||||
|
||||
#[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}")));
|
||||
}
|
||||
@@ -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<String>),
|
||||
FilterCategory(Option<String>),
|
||||
ShowMore,
|
||||
}
|
||||
|
||||
pub struct AIActivityView {
|
||||
snapshot: ActivitySnapshot,
|
||||
search: ViewHandle<EditorView>,
|
||||
detail: ViewHandle<EditorView>,
|
||||
recording_button: ViewHandle<ActionButton>,
|
||||
clear_button: ViewHandle<ActionButton>,
|
||||
all_sessions_button: ViewHandle<ActionButton>,
|
||||
more_button: ViewHandle<ActionButton>,
|
||||
category_buttons: Vec<(Option<String>, ViewHandle<ActionButton>)>,
|
||||
selected: Option<Arc<ActivityEntry>>,
|
||||
session_filter: Option<String>,
|
||||
category_filter: Option<String>,
|
||||
rows: HashMap<u64, (MouseStateHandle, MouseStateHandle)>,
|
||||
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>) -> 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<Self>) {
|
||||
// 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>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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>) {
|
||||
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>) {
|
||||
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<Arc<ActivityEntry>> {
|
||||
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<Self>) {
|
||||
let ids = self
|
||||
.filtered_entries(ctx)
|
||||
.into_iter()
|
||||
.take(self.display_limit)
|
||||
.map(|entry| entry.id)
|
||||
.collect::<HashSet<_>>();
|
||||
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<Self>) {
|
||||
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<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let entries = self
|
||||
.filtered_entries(app)
|
||||
.into_iter()
|
||||
.take(self.display_limit)
|
||||
.collect::<Vec<_>>();
|
||||
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::<serde_json::Value>(&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::<String>(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<dyn Element> {
|
||||
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<Self>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.collect_str(&format_args!("{:#?}", self.0))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BlocklistAIActionModel {
|
||||
executor: ModelHandle<BlocklistAIActionExecutor>,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Assessment> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<ConversationMessage> {
|
||||
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<Self>,
|
||||
) {
|
||||
#[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,
|
||||
|
||||
@@ -430,11 +430,19 @@ impl ResponseStream {
|
||||
&self,
|
||||
request_id: Uuid,
|
||||
payload_kind: &str,
|
||||
raw_payload: impl AsRef<str>,
|
||||
raw_payload: &impl std::fmt::Debug,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
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<T: std::fmt::Debug> serde::Serialize for DebugPayload<'_, T> {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
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 {
|
||||
|
||||
@@ -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<String, String> {
|
||||
#[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<String, String> {
|
||||
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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<M>(ctx: &mut ModelContext<M>, 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<V: View>(ctx: &mut ViewContext<V>, 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!({
|
||||
|
||||
@@ -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<u32>,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
activity_session_id: Option<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<StaticCommand> = LazyLock::new(|| StaticCommand {
|
||||
name: "/add-prompt",
|
||||
description: "Add new Agent prompt",
|
||||
@@ -713,6 +722,7 @@ fn all_commands() -> Vec<StaticCommand> {
|
||||
|
||||
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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<Modal<NewWorktreeModal>>,
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
background_jobs_modal: ModalViewState<Modal<BackgroundJobsView>>,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
ai_activity_modal: ModalViewState<Modal<AIActivityView>>,
|
||||
close_session_confirmation_dialog: ViewHandle<CloseSessionConfirmationDialog>,
|
||||
rewind_confirmation_dialog: ViewHandle<RewindConfirmationDialog>,
|
||||
delete_conversation_confirmation_dialog: ViewHandle<DeleteConversationConfirmationDialog>,
|
||||
@@ -2181,6 +2185,43 @@ impl Workspace {
|
||||
ModalViewState::new(modal)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn build_ai_activity_modal(
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ModalViewState<Modal<AIActivityView>> {
|
||||
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<Self>,
|
||||
) -> ViewHandle<RemoveTabConfigConfirmationDialog> {
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user