use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::tool_ui::{
PatchDiffLineKind, PatchFileOperation, PatchFileUiData, ReplyDisposition, ReplySubject,
WebSearchUiAction,
};
use super::{
ActivityCell, LiveActivityCell,
apps::{BrowserActivityCell, LiveBrowserActivityCell, WebSearchActivityCell},
common::{
AssistantActivityCell, CodingEditActivityCell, CodingOpenProjectActivityCell,
CodingReviewActivityCell, ErrorActivityCell, ExploredActivityCell,
FinalMessageSeparatorActivityCell, GenericAppActivityCell, RuntimeStatusActivityCell,
TerminalWaitActivityCell, ThinkingActivityCell, UserActivityCell,
},
exec::{ExecResultActivityCell, LiveExecActivityCell},
messages::{PatchActivityCell, ReplyActivityCell, TelegramActivityCell},
plan::{PlanActivityCell, PlanStepDisplayStatus},
primitive::{ActivatePrimitiveActivityCell, CreatePrimitiveSpecActivityCell},
};
pub const WEB_ACTIVITY_VERSION: u8 = 2;
pub fn default_web_activity_version() -> u8 {
WEB_ACTIVITY_VERSION
}
pub fn sync_web_activity_state(state: &mut crate::dashboard::DashboardState) {
let previous_live_items = state
.live_web_activity_items
.iter()
.map(|entry| (entry.item.id.clone(), entry.item.clone()))
.collect::<std::collections::HashMap<_, _>>();
state.web_activity_version = WEB_ACTIVITY_VERSION;
state.web_activity_items = state.activity_history.items.clone();
let mut live_cells = state.live_activity_cells.clone();
super::append_runtime_status_live_cell(&mut live_cells, state);
let mut live_items = render_live_web_activity_items(&live_cells);
for entry in &mut live_items {
preserve_item_timestamps(&mut entry.item, &previous_live_items);
}
state.live_web_activity_items = live_items;
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct WebActivityItem {
pub web_activity_version: u8,
pub id: String,
pub kind: WebActivityKind,
pub status: WebActivityStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_hint: Option<String>,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<WebActivityActor>,
pub created_at: i64,
pub updated_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<WebActivitySource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<WebActivityTool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocks: Vec<WebActivityBlock>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub detail_blocks: Vec<WebActivityBlock>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<WebActivityError>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cell: Option<ActivityCell>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WebActivityKind {
Message,
Tool,
App,
Plan,
Primitive,
Memory,
Patch,
WebSearch,
Warning,
Error,
Unknown,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WebActivityStatus {
Pending,
Running,
Completed,
Failed,
Dismissed,
Unknown,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WebActivityActor {
User,
Assistant,
Telegram,
Tool,
System,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct WebActivitySource {
pub source_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct WebActivityTool {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_preview: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_preview: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub affected_files: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WebActivityBlock {
Text {
text: String,
},
Code {
code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
language: Option<String>,
},
Kv {
entries: Vec<WebActivityKvEntry>,
},
List {
items: Vec<String>,
},
Diff {
files: Vec<WebActivityDiffFile>,
},
Link {
label: String,
url: String,
},
Artifact {
label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mime_type: Option<String>,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct WebActivityKvEntry {
pub key: String,
pub value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct WebActivityDiffFile {
pub path: String,
pub operation: String,
pub added_lines: usize,
pub removed_lines: usize,
pub lines: Vec<WebActivityDiffLine>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct WebActivityDiffLine {
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_lineno: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_lineno: Option<usize>,
pub text: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct WebActivityError {
pub message: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub details: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct LiveWebActivityItem {
pub key: String,
pub item: WebActivityItem,
}
pub fn render_live_web_activity_items(cells: &[LiveActivityCell]) -> Vec<LiveWebActivityItem> {
cells
.iter()
.enumerate()
.map(|(index, live_cell)| {
let key = if live_cell.key.is_empty() {
format!("live-{index}")
} else {
live_cell.key.clone()
};
let id = format!("live-{key}");
LiveWebActivityItem {
key,
item: web_activity_item_from_cell(&live_cell.cell, &id, true),
}
})
.collect()
}
pub fn web_activity_item_from_cell(cell: &ActivityCell, id: &str, live: bool) -> WebActivityItem {
let now = chrono::Utc::now().timestamp_millis();
let mut item = WebActivityItem {
web_activity_version: WEB_ACTIVITY_VERSION,
id: id.to_string(),
kind: WebActivityKind::Unknown,
status: if live {
WebActivityStatus::Running
} else {
WebActivityStatus::Completed
},
ui_hint: None,
title: "Activity".to_string(),
actor: None,
created_at: now,
updated_at: now,
source: None,
tool: None,
blocks: Vec::new(),
detail_blocks: Vec::new(),
error: None,
metadata: None,
cell: Some(cell.clone()),
};
match cell {
ActivityCell::Assistant(cell) => apply_assistant_cell(&mut item, cell),
ActivityCell::FinalMessageSeparator(cell) => {
apply_final_message_separator_cell(&mut item, cell)
}
ActivityCell::User(cell) => apply_user_cell(&mut item, cell),
ActivityCell::Browser(cell) => apply_browser_cell(&mut item, cell),
ActivityCell::LiveBrowser(cell) => apply_live_browser_cell(&mut item, cell),
ActivityCell::WebSearch(cell) => apply_web_search_cell(&mut item, cell),
ActivityCell::CodingOpenProject(cell) => apply_coding_open_project_cell(&mut item, cell),
ActivityCell::Explored(cell) => apply_explored_cell(&mut item, cell),
ActivityCell::CodingEdit(cell) => apply_coding_edit_cell(&mut item, cell),
ActivityCell::CodingReview(cell) => apply_coding_review_cell(&mut item, cell),
ActivityCell::GenericApp(cell) => apply_generic_app_cell(&mut item, cell),
ActivityCell::PlanResult(cell) => apply_plan_cell(&mut item, cell),
ActivityCell::CreatePrimitiveSpecResult(cell) => {
apply_create_primitive_spec_cell(&mut item, cell)
}
ActivityCell::ActivatePrimitiveResult(cell) => {
apply_activate_primitive_cell(&mut item, cell)
}
ActivityCell::ExecResult(cell) => apply_exec_cell(&mut item, cell),
ActivityCell::LiveExec(cell) => apply_live_exec_cell(&mut item, cell),
ActivityCell::Patch(cell) => apply_file_edit_cell(&mut item, cell),
ActivityCell::Telegram(cell) => apply_telegram_cell(&mut item, cell),
ActivityCell::Reply(cell) => apply_reply_cell(&mut item, cell),
ActivityCell::TerminalWait(cell) => apply_terminal_wait_cell(&mut item, cell),
ActivityCell::Warning(cell) => apply_warning_cell(&mut item, cell),
ActivityCell::Error(cell) => apply_error_cell(&mut item, cell),
ActivityCell::Thinking(cell) => apply_thinking_cell(&mut item, cell),
ActivityCell::RuntimeStatus(cell) => apply_runtime_status_cell(&mut item, cell),
}
item.ui_hint = Some(activity_cell_variant_name(cell).to_string());
if matches!(item.kind, WebActivityKind::Error) {
item.status = WebActivityStatus::Failed;
}
item
}
fn activity_cell_variant_name(cell: &ActivityCell) -> &'static str {
match cell {
ActivityCell::Assistant(_) => "Assistant",
ActivityCell::FinalMessageSeparator(_) => "FinalMessageSeparator",
ActivityCell::User(_) => "User",
ActivityCell::Browser(_) => "Browser",
ActivityCell::LiveBrowser(_) => "LiveBrowser",
ActivityCell::WebSearch(_) => "WebSearch",
ActivityCell::CodingOpenProject(_) => "CodingOpenProject",
ActivityCell::Explored(_) => "Explored",
ActivityCell::CodingEdit(_) => "CodingEdit",
ActivityCell::CodingReview(_) => "CodingReview",
ActivityCell::GenericApp(_) => "GenericApp",
ActivityCell::PlanResult(_) => "PlanResult",
ActivityCell::CreatePrimitiveSpecResult(_) => "CreatePrimitiveSpecResult",
ActivityCell::ActivatePrimitiveResult(_) => "ActivatePrimitiveResult",
ActivityCell::ExecResult(_) => "ExecResult",
ActivityCell::LiveExec(_) => "LiveExec",
ActivityCell::Patch(_) => "FileEdit",
ActivityCell::Telegram(_) => "Telegram",
ActivityCell::Reply(_) => "Reply",
ActivityCell::TerminalWait(_) => "TerminalWait",
ActivityCell::Warning(_) => "Warning",
ActivityCell::Error(_) => "Error",
ActivityCell::Thinking(_) => "Thinking",
ActivityCell::RuntimeStatus(_) => "RuntimeStatus",
}
}
fn apply_assistant_cell(item: &mut WebActivityItem, cell: &AssistantActivityCell) {
item.kind = WebActivityKind::Message;
item.actor = Some(WebActivityActor::Assistant);
item.title = crate::dashboard::dashboard_agent_name();
item.blocks = text_blocks(cell.body_lines.clone());
}
fn apply_final_message_separator_cell(
item: &mut WebActivityItem,
cell: &FinalMessageSeparatorActivityCell,
) {
item.kind = WebActivityKind::Message;
item.actor = Some(WebActivityActor::System);
item.ui_hint = Some("final-message-separator".to_string());
item.title = "Worked".to_string();
item.blocks = text_blocks(vec![
cell.elapsed_seconds
.map(|seconds| format!("Worked for {}", format_elapsed_seconds_compact(seconds)))
.unwrap_or_else(|| "Worked".to_string()),
]);
}
fn format_elapsed_seconds_compact(elapsed_seconds: u64) -> String {
if elapsed_seconds < 60 {
return format!("{elapsed_seconds}s");
}
if elapsed_seconds < 3600 {
let minutes = elapsed_seconds / 60;
let seconds = elapsed_seconds % 60;
return format!("{minutes}m {seconds:02}s");
}
let hours = elapsed_seconds / 3600;
let minutes = (elapsed_seconds % 3600) / 60;
let seconds = elapsed_seconds % 60;
format!("{hours}h {minutes:02}m {seconds:02}s")
}
fn apply_thinking_cell(item: &mut WebActivityItem, cell: &ThinkingActivityCell) {
item.kind = WebActivityKind::Message;
item.actor = Some(WebActivityActor::Assistant);
item.title = "Thinking".to_string();
item.blocks = text_blocks(primary_lines("Thinking", &cell.body_lines));
if let Some(ref full) = cell.full_body {
item.ui_hint = Some("thinking-collapsible".to_string());
item.detail_blocks = text_blocks(vec![full.clone()]);
}
}
fn apply_user_cell(item: &mut WebActivityItem, cell: &UserActivityCell) {
item.kind = WebActivityKind::Message;
item.actor = Some(WebActivityActor::User);
item.title = "User".to_string();
item.blocks = text_blocks(
cell.full_body
.clone()
.map(|full| vec![full])
.unwrap_or_else(|| primary_lines(&cell.title, &cell.body_lines)),
);
item.blocks
.extend(
cell.image_attachments
.iter()
.map(|attachment| WebActivityBlock::Artifact {
label: attachment.label.clone(),
uri: Some(attachment.uri.clone()),
mime_type: Some(attachment.mime_type.clone()),
}),
);
}
fn apply_telegram_cell(item: &mut WebActivityItem, cell: &TelegramActivityCell) {
item.kind = WebActivityKind::Message;
item.actor = Some(WebActivityActor::Telegram);
item.title = if cell.title.trim().is_empty() {
"Telegram".to_string()
} else {
cell.title.clone()
};
item.source = Some(WebActivitySource {
source_type: "telegram".to_string(),
label: Some(item.title.clone()),
});
item.blocks = text_blocks(cell.message_lines.clone());
item.detail_blocks = text_blocks(cell.detail_lines.clone());
if item.blocks.is_empty() {
item.blocks = text_blocks(vec![item.title.clone()]);
}
}
fn apply_reply_cell(item: &mut WebActivityItem, cell: &ReplyActivityCell) {
item.kind = WebActivityKind::Message;
item.actor = Some(WebActivityActor::Assistant);
item.status = match cell.disposition {
ReplyDisposition::Resolved => WebActivityStatus::Completed,
ReplyDisposition::Dismissed => WebActivityStatus::Dismissed,
ReplyDisposition::Failed => WebActivityStatus::Failed,
};
item.title = match (cell.subject.clone(), cell.disposition.clone()) {
(ReplySubject::Notice, ReplyDisposition::Resolved) => "Resolved Notice".to_string(),
(ReplySubject::Notice, ReplyDisposition::Dismissed) => "Dismissed Notice".to_string(),
(ReplySubject::Notice, ReplyDisposition::Failed) => "Failed Notice".to_string(),
(_, ReplyDisposition::Resolved) => "Agent reply".to_string(),
(_, ReplyDisposition::Dismissed) => "Dismissed message".to_string(),
(_, ReplyDisposition::Failed) => "Failed reply".to_string(),
};
item.blocks = text_blocks(cell.message_lines.clone());
}
fn apply_exec_cell(item: &mut WebActivityItem, cell: &ExecResultActivityCell) {
item.kind = WebActivityKind::Tool;
item.actor = Some(WebActivityActor::Tool);
item.title = if cell.title.trim().is_empty() {
"Tool result".to_string()
} else {
cell.title.clone()
};
let exit_code = cell.meta.as_deref().and_then(parse_exit_code);
item.status = match exit_code {
Some(0) | None => WebActivityStatus::Completed,
Some(_) => WebActivityStatus::Failed,
};
item.tool = Some(WebActivityTool {
name: "terminal".to_string(),
app: Some("Terminal".to_string()),
input_preview: Some(item.title.clone()),
output_preview: compact_preview(&cell.output_lines),
output_ref: None,
duration_ms: None,
exit_code,
affected_files: Vec::new(),
});
item.blocks = if cell.output_lines.is_empty() {
text_blocks(vec!["(no output)".to_string()])
} else {
output_blocks(&cell.output_lines)
};
item.detail_blocks = kv_block(optional_kv_entries(vec![
("meta", cell.meta.clone()),
("command", Some(cell.title.clone())),
]));
}
fn apply_live_exec_cell(item: &mut WebActivityItem, cell: &LiveExecActivityCell) {
item.kind = WebActivityKind::Tool;
item.actor = Some(WebActivityActor::Tool);
item.status = WebActivityStatus::Running;
item.title = if cell.title.trim().is_empty() {
"Tool running".to_string()
} else {
cell.title.clone()
};
let duration_ms = cell.started_at_ms.and_then(|started_at_ms| {
let now = chrono::Utc::now().timestamp_millis();
(now >= started_at_ms).then_some((now - started_at_ms) as u64)
});
item.created_at = cell.started_at_ms.unwrap_or(item.created_at);
item.tool = Some(WebActivityTool {
name: "terminal".to_string(),
app: Some("Terminal".to_string()),
input_preview: compact_preview(&cell.call_lines).or_else(|| Some(item.title.clone())),
output_preview: compact_preview(&cell.output_lines),
output_ref: None,
duration_ms,
exit_code: cell.meta.as_deref().and_then(parse_exit_code),
affected_files: Vec::new(),
});
item.blocks = if cell.output_lines.is_empty() {
text_blocks(vec!["running...".to_string()])
} else {
output_blocks(&cell.output_lines)
};
item.detail_blocks = kv_block(optional_kv_entries(vec![
("meta", cell.meta.clone()),
("input", compact_preview(&cell.call_lines)),
]));
}
fn apply_terminal_wait_cell(item: &mut WebActivityItem, cell: &TerminalWaitActivityCell) {
item.kind = WebActivityKind::Tool;
item.actor = Some(WebActivityActor::Tool);
item.status = WebActivityStatus::Running;
item.title = if cell.title.trim().is_empty() {
"Terminal wait".to_string()
} else {
cell.title.clone()
};
item.tool = Some(WebActivityTool {
name: "terminal".to_string(),
app: Some("Terminal".to_string()),
input_preview: Some(item.title.clone()),
output_preview: compact_preview(&cell.body_lines),
output_ref: None,
duration_ms: None,
exit_code: None,
affected_files: Vec::new(),
});
item.blocks = text_blocks(cell.body_lines.clone());
item.detail_blocks = kv_block(optional_kv_entries(vec![("meta", cell.meta.clone())]));
}
fn apply_browser_cell(item: &mut WebActivityItem, cell: &BrowserActivityCell) {
item.kind = WebActivityKind::Tool;
item.actor = Some(WebActivityActor::Tool);
item.title = if cell.title.trim().is_empty() {
"Browser snapshot".to_string()
} else {
cell.title.clone()
};
item.tool = Some(WebActivityTool {
name: "browser".to_string(),
app: Some("Browser".to_string()),
input_preview: cell.url.clone(),
output_preview: Some(
[
cell.line_count.map(|value| format!("{value} lines")),
cell.ref_count.map(|value| format!("{value} refs")),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" · "),
)
.filter(|value| !value.is_empty()),
output_ref: cell.url.clone(),
duration_ms: None,
exit_code: None,
affected_files: Vec::new(),
});
item.blocks = cell
.url
.as_ref()
.map(|url| {
vec![WebActivityBlock::Link {
label: compact_url(url),
url: url.clone(),
}]
})
.unwrap_or_default();
item.detail_blocks = kv_block(optional_kv_entries(vec![
("lines", cell.line_count.map(|value| value.to_string())),
("refs", cell.ref_count.map(|value| value.to_string())),
]));
}
fn apply_live_browser_cell(item: &mut WebActivityItem, cell: &LiveBrowserActivityCell) {
item.kind = WebActivityKind::Tool;
item.actor = Some(WebActivityActor::Tool);
item.status = WebActivityStatus::Running;
item.title = if cell.title.trim().is_empty() {
"Browser action".to_string()
} else {
cell.title.clone()
};
item.tool = Some(WebActivityTool {
name: "browser".to_string(),
app: Some("Browser".to_string()),
input_preview: cell.url.clone(),
output_preview: compact_preview(&cell.body_lines),
output_ref: cell.url.clone(),
duration_ms: None,
exit_code: None,
affected_files: Vec::new(),
});
item.blocks = if let Some(url) = &cell.url {
vec![WebActivityBlock::Link {
label: compact_url(url),
url: url.clone(),
}]
} else {
text_blocks(cell.body_lines.clone())
};
item.detail_blocks = text_blocks(cell.body_lines.clone());
}
fn apply_generic_app_cell(item: &mut WebActivityItem, cell: &GenericAppActivityCell) {
apply_simple_tool_item(
item,
"app",
Some("App"),
cell.title.clone(),
cell.body_lines.clone(),
);
}
fn apply_explored_cell(item: &mut WebActivityItem, cell: &ExploredActivityCell) {
item.kind = WebActivityKind::Tool;
item.actor = Some(WebActivityActor::Tool);
item.title = cell.title.clone();
let call_lines = cell
.calls
.iter()
.map(|call| format!("{} {}", call.tool_name, call.summary))
.collect::<Vec<_>>();
item.tool = Some(WebActivityTool {
name: "explored".to_string(),
app: None,
input_preview: Some(format!("{} call(s)", cell.calls.len())),
output_preview: compact_preview(&call_lines),
output_ref: Some(cell.stable_id.clone()),
duration_ms: None,
exit_code: None,
affected_files: Vec::new(),
});
item.blocks = if call_lines.is_empty() {
text_blocks(vec!["No explored tool calls yet".to_string()])
} else {
vec![WebActivityBlock::List { items: call_lines }]
};
item.detail_blocks = text_blocks(
cell.calls
.iter()
.flat_map(|call| {
let mut lines = vec![format!("{} {}", call.tool_name, call.summary)];
lines.extend(call.detail_lines.iter().map(|line| format!(" {line}")));
lines
})
.collect(),
);
}
fn apply_coding_edit_cell(item: &mut WebActivityItem, cell: &CodingEditActivityCell) {
let tool_name = cell.tool_name.as_deref().unwrap_or("edit_code");
let tool_app = cell.tool_app.as_deref().unwrap_or("Coding");
let include_impact =
tool_name == "edit_code" || cell.propagation_count > 0 || !cell.impact_lines.is_empty();
item.kind = WebActivityKind::Patch;
item.actor = Some(WebActivityActor::Tool);
item.ui_hint = Some("coding-edit code-change".to_string());
item.title = if cell.title.trim().is_empty() {
"Edit".to_string()
} else {
cell.title.clone()
};
item.metadata = Some(serde_json::json!({
"kind": if tool_name == "edit_file" { "file-edit" } else { "coding-edit" },
"category": "code-change",
}));
let affected_files = cell
.diff_files
.iter()
.map(|file| file.path.clone())
.collect::<Vec<_>>();
let output_preview = if include_impact {
format!(
"+{} -{} · {} propagation review(s)",
cell.added_lines, cell.removed_lines, cell.propagation_count
)
} else {
format!("+{} -{}", cell.added_lines, cell.removed_lines)
};
item.tool = Some(WebActivityTool {
name: tool_name.to_string(),
app: Some(tool_app.to_string()),
input_preview: Some(cell.selector.clone()),
output_preview: Some(output_preview),
output_ref: Some(cell.stable_id.clone()),
duration_ms: None,
exit_code: None,
affected_files,
});
let mut entries = vec![WebActivityKvEntry {
key: "Target".to_string(),
value: cell.selector.clone(),
}];
if let Some(file) = cell.file.as_deref() {
entries.push(WebActivityKvEntry {
key: "File".to_string(),
value: file.to_string(),
});
}
entries.push(WebActivityKvEntry {
key: "Changes".to_string(),
value: format!("+{} -{}", cell.added_lines, cell.removed_lines),
});
if include_impact {
entries.push(WebActivityKvEntry {
key: "Impact".to_string(),
value: format!("{} propagation review(s)", cell.propagation_count),
});
}
item.blocks = vec![WebActivityBlock::Kv { entries }];
if !cell.diff_files.is_empty() {
item.blocks.push(WebActivityBlock::Diff {
files: cell
.diff_files
.iter()
.map(web_diff_file_from_patch_file)
.collect(),
});
}
item.detail_blocks = if cell.impact_lines.is_empty() {
Vec::new()
} else {
vec![WebActivityBlock::List {
items: cell.impact_lines.clone(),
}]
};
}
fn apply_coding_review_cell(item: &mut WebActivityItem, cell: &CodingReviewActivityCell) {
item.kind = WebActivityKind::Tool;
item.actor = Some(WebActivityActor::Tool);
item.ui_hint = Some("coding-review propagation-review".to_string());
item.title = cell.title.clone();
item.metadata = Some(serde_json::json!({
"kind": "coding-review",
"category": "propagation-review",
"review_pending": cell.review_pending,
}));
item.tool = Some(WebActivityTool {
name: "next_review".to_string(),
app: Some("Coding".to_string()),
input_preview: None,
output_preview: Some(cell.summary.clone()),
output_ref: None,
duration_ms: None,
exit_code: None,
affected_files: Vec::new(),
});
}
fn apply_coding_open_project_cell(
item: &mut WebActivityItem,
cell: &CodingOpenProjectActivityCell,
) {
item.kind = WebActivityKind::App;
item.actor = Some(WebActivityActor::Tool);
item.title = "Opened Coding Project".to_string();
item.tool = Some(WebActivityTool {
name: "open_project".to_string(),
app: Some("Coding".to_string()),
input_preview: Some(cell.project_root.clone()),
output_preview: compact_preview(&cell.detail_lines),
output_ref: None,
duration_ms: None,
exit_code: None,
affected_files: Vec::new(),
});
item.blocks = kv_block(vec![WebActivityKvEntry {
key: "project_root".to_string(),
value: cell.project_root.clone(),
}]);
item.detail_blocks = text_blocks(cell.detail_lines.clone());
}
fn apply_simple_tool_item(
item: &mut WebActivityItem,
tool_name: &str,
app: Option<&str>,
title: String,
lines: Vec<String>,
) {
item.kind = WebActivityKind::Tool;
item.actor = Some(WebActivityActor::Tool);
item.title = if title.trim().is_empty() {
"Tool".to_string()
} else {
title
};
item.tool = Some(WebActivityTool {
name: tool_name.to_string(),
app: app.map(ToString::to_string),
input_preview: Some(item.title.clone()),
output_preview: compact_preview(&lines),
output_ref: None,
duration_ms: None,
exit_code: None,
affected_files: Vec::new(),
});
item.blocks = text_blocks(lines);
}
fn apply_plan_cell(item: &mut WebActivityItem, cell: &PlanActivityCell) {
item.kind = WebActivityKind::Plan;
item.actor = Some(WebActivityActor::System);
item.title = "Plan".to_string();
let mut blocks = Vec::new();
if let Some(explanation) = cell.explanation.as_deref()
&& !explanation.trim().is_empty()
{
blocks.extend(text_blocks(vec![explanation.trim().to_string()]));
}
blocks.push(WebActivityBlock::List {
items: if cell.steps.is_empty() {
vec!["No active plan.".to_string()]
} else {
cell.steps
.iter()
.map(|step| format!("{} {}", plan_status_marker(step.status), step.text))
.collect()
},
});
item.blocks = blocks;
item.metadata = Some(serde_json::json!({
"explanation": cell.explanation.clone(),
"steps": cell.steps.iter().map(|step| serde_json::json!({
"status": plan_status_name(step.status),
"text": step.text.clone(),
})).collect::<Vec<_>>()
}));
}
fn apply_create_primitive_spec_cell(
item: &mut WebActivityItem,
cell: &CreatePrimitiveSpecActivityCell,
) {
item.kind = WebActivityKind::Primitive;
item.actor = Some(WebActivityActor::System);
item.title = format!("Created Primitive Spec: {}", cell.primitive_id);
item.blocks = kv_block(vec![WebActivityKvEntry {
key: "primitive_id".to_string(),
value: cell.primitive_id.clone(),
}]);
}
fn apply_activate_primitive_cell(item: &mut WebActivityItem, cell: &ActivatePrimitiveActivityCell) {
item.kind = WebActivityKind::Primitive;
item.actor = Some(WebActivityActor::System);
item.title = format!("Activated Primitive: {}", cell.primitive_id);
item.blocks = kv_block(vec![WebActivityKvEntry {
key: "primitive_id".to_string(),
value: cell.primitive_id.clone(),
}]);
}
fn apply_file_edit_cell(item: &mut WebActivityItem, cell: &PatchActivityCell) {
item.kind = WebActivityKind::Patch;
item.actor = Some(WebActivityActor::Tool);
item.title = if cell.summary_line.trim().is_empty() {
"File Edit".to_string()
} else {
cell.summary_line.clone()
};
let affected_files = cell
.files
.iter()
.map(|file| file.path.clone())
.collect::<Vec<_>>();
item.tool = Some(WebActivityTool {
name: "edit_file".to_string(),
app: Some("Workspace".to_string()),
input_preview: Some(item.title.clone()),
output_preview: Some(format_patch_summary(&cell.files)),
output_ref: None,
duration_ms: None,
exit_code: None,
affected_files,
});
item.blocks = vec![WebActivityBlock::Diff {
files: cell
.files
.iter()
.map(web_diff_file_from_patch_file)
.collect(),
}];
}
fn apply_runtime_status_cell(item: &mut WebActivityItem, cell: &RuntimeStatusActivityCell) {
item.kind = WebActivityKind::App;
item.actor = Some(WebActivityActor::System);
item.status = WebActivityStatus::Running;
item.title = if cell.label.trim().is_empty() {
"Working".to_string()
} else {
cell.label.trim().to_string()
};
item.blocks = cell
.detail
.as_deref()
.filter(|detail| !detail.trim().is_empty())
.map(|detail| text_blocks(vec![detail.to_string()]))
.unwrap_or_default();
}
fn apply_error_cell(item: &mut WebActivityItem, cell: &ErrorActivityCell) {
item.kind = WebActivityKind::Error;
item.actor = Some(WebActivityActor::Tool);
item.status = WebActivityStatus::Failed;
item.title = if cell.title.trim().is_empty() {
"Error".to_string()
} else {
cell.title.clone()
};
item.error = Some(WebActivityError {
message: item.title.clone(),
details: cell.body_lines.clone(),
});
item.blocks = text_blocks(primary_lines(&cell.title, &cell.body_lines));
}
fn apply_warning_cell(item: &mut WebActivityItem, cell: &ErrorActivityCell) {
item.kind = WebActivityKind::Warning;
item.actor = Some(WebActivityActor::System);
item.status = WebActivityStatus::Completed;
item.title = if cell.title.trim().is_empty() {
"Warning".to_string()
} else {
cell.title.clone()
};
item.blocks = text_blocks(primary_lines(&cell.title, &cell.body_lines));
}
fn apply_web_search_cell(item: &mut WebActivityItem, cell: &WebSearchActivityCell) {
item.kind = WebActivityKind::WebSearch;
item.actor = Some(WebActivityActor::Tool);
item.status = match cell.action {
WebSearchUiAction::Searching => WebActivityStatus::Running,
WebSearchUiAction::Searched => WebActivityStatus::Completed,
};
item.title = match cell.action {
WebSearchUiAction::Searching => format!("Searching the web: {}", cell.query),
WebSearchUiAction::Searched => format!("Searched the web: {}", cell.query),
};
item.tool = Some(WebActivityTool {
name: "web_search".to_string(),
app: Some("Browser".to_string()),
input_preview: Some(cell.query.clone()),
output_preview: compact_preview(&cell.body_lines).or_else(|| cell.url.clone()),
output_ref: cell.url.clone(),
duration_ms: None,
exit_code: None,
affected_files: Vec::new(),
});
let mut lines = Vec::new();
if let Some(url) = cell.url.as_deref() {
lines.push(url.to_string());
}
lines.extend(cell.body_lines.clone());
item.blocks = text_blocks(lines);
}
fn web_diff_file_from_patch_file(file: &PatchFileUiData) -> WebActivityDiffFile {
WebActivityDiffFile {
path: file.path.clone(),
operation: match &file.operation {
PatchFileOperation::Add => "add",
PatchFileOperation::Delete => "delete",
PatchFileOperation::Update => "update",
}
.to_string(),
added_lines: file.added_lines,
removed_lines: file.removed_lines,
lines: file
.diff_lines
.iter()
.map(|line| WebActivityDiffLine {
kind: match &line.kind {
PatchDiffLineKind::Context => "context",
PatchDiffLineKind::Delete => "delete",
PatchDiffLineKind::Add => "add",
PatchDiffLineKind::HunkBreak => "hunk_break",
}
.to_string(),
old_lineno: line.old_lineno,
new_lineno: line.new_lineno,
text: line.text.clone(),
})
.collect(),
}
}
fn preserve_item_timestamps(
item: &mut WebActivityItem,
previous_items: &std::collections::HashMap<String, WebActivityItem>,
) {
let Some(previous) = previous_items.get(&item.id) else {
return;
};
item.created_at = previous.created_at;
let mut comparable = item.clone();
comparable.updated_at = previous.updated_at;
if comparable == *previous {
item.updated_at = previous.updated_at;
}
}
fn text_blocks(lines: Vec<String>) -> Vec<WebActivityBlock> {
let text = lines
.into_iter()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n");
if text.is_empty() {
Vec::new()
} else {
vec![WebActivityBlock::Text { text }]
}
}
fn output_blocks(lines: &[String]) -> Vec<WebActivityBlock> {
let output = lines
.iter()
.map(|line| line.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n");
if output.trim().is_empty() {
text_blocks(vec!["(no output)".to_string()])
} else {
vec![WebActivityBlock::Code {
code: output,
language: None,
}]
}
}
fn kv_block(entries: Vec<WebActivityKvEntry>) -> Vec<WebActivityBlock> {
if entries.is_empty() {
Vec::new()
} else {
vec![WebActivityBlock::Kv { entries }]
}
}
fn optional_kv_entries(entries: Vec<(&str, Option<String>)>) -> Vec<WebActivityKvEntry> {
entries
.into_iter()
.filter_map(|(key, value)| {
let value = value?;
if value.trim().is_empty() {
None
} else {
Some(WebActivityKvEntry {
key: key.to_string(),
value,
})
}
})
.collect()
}
fn primary_lines(title: &str, body_lines: &[String]) -> Vec<String> {
let mut lines = Vec::new();
if !title.trim().is_empty() {
lines.push(title.to_string());
}
lines.extend(body_lines.iter().cloned());
lines
}
fn compact_preview(lines: &[String]) -> Option<String> {
let preview = lines
.iter()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.take(3)
.collect::<Vec<_>>()
.join(" · ");
if preview.is_empty() {
None
} else if preview.chars().count() > 240 {
Some(preview.chars().take(240).collect::<String>() + "...")
} else {
Some(preview)
}
}
fn compact_url(url: &str) -> String {
let compact = url.trim().replace('\n', "");
if compact.chars().count() > 88 {
compact.chars().take(88).collect::<String>() + "..."
} else {
compact
}
}
fn parse_exit_code(meta: &str) -> Option<i32> {
let normalized = meta.replace("exit=", " exit=");
normalized
.split_whitespace()
.find_map(|part| part.strip_prefix("exit="))?
.parse::<i32>()
.ok()
}
fn plan_status_marker(status: PlanStepDisplayStatus) -> &'static str {
match status {
PlanStepDisplayStatus::Pending => "○",
PlanStepDisplayStatus::InProgress => "●",
PlanStepDisplayStatus::Completed => "●",
}
}
fn plan_status_name(status: PlanStepDisplayStatus) -> &'static str {
match status {
PlanStepDisplayStatus::Pending => "pending",
PlanStepDisplayStatus::InProgress => "in_progress",
PlanStepDisplayStatus::Completed => "completed",
}
}
fn format_patch_summary(files: &[PatchFileUiData]) -> String {
let added = files.iter().map(|file| file.added_lines).sum::<usize>();
let removed = files.iter().map(|file| file.removed_lines).sum::<usize>();
let file_count = files.len();
format!("{file_count} file(s), +{added} -{removed}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dashboard::{DashboardRuntimeActivity, DashboardState};
#[test]
fn sync_web_activity_adds_runtime_status_live_item() {
let mut state = DashboardState {
runtime_activity: DashboardRuntimeActivity::default()
.with_runtime_turn(Some("model request".to_string()), Some(1_000)),
..DashboardState::default()
};
sync_web_activity_state(&mut state);
assert!(state.web_activity_items.is_empty());
assert_eq!(state.live_web_activity_items.len(), 1);
let live_item = &state.live_web_activity_items[0];
assert_eq!(live_item.key, "runtime-status");
assert_eq!(live_item.item.title, "Working");
assert_eq!(live_item.item.status, WebActivityStatus::Running);
assert_eq!(live_item.item.ui_hint.as_deref(), Some("RuntimeStatus"));
}
#[test]
fn web_activity_tool_duration_json_round_trips() {
let tool = WebActivityTool {
name: "terminal".to_string(),
app: Some("Terminal".to_string()),
input_preview: None,
output_preview: None,
output_ref: None,
duration_ms: Some(123),
exit_code: Some(0),
affected_files: Vec::new(),
};
let encoded = serde_json::to_string(&tool).expect("serialize web activity tool");
let decoded: WebActivityTool =
serde_json::from_str(&encoded).expect("deserialize web activity tool");
assert_eq!(decoded.duration_ms, Some(123));
}
#[test]
fn coding_edit_web_activity_renders_as_patch_with_tool_metadata() {
let item = web_activity_item_from_cell(
&ActivityCell::CodingEdit(CodingEditActivityCell {
stable_id: "file-edit-call".to_string(),
title: "Edited File".to_string(),
tool_name: Some("edit_file".to_string()),
tool_app: Some("Workspace".to_string()),
selector: "hash-anchored file edit".to_string(),
file: Some("README.md".to_string()),
added_lines: 1,
removed_lines: 1,
propagation_count: 0,
impact_lines: Vec::new(),
diff_files: vec![PatchFileUiData {
path: "README.md".to_string(),
operation: PatchFileOperation::Update,
added_lines: 1,
removed_lines: 1,
diff_lines: Vec::new(),
}],
}),
"item-1",
false,
);
assert_eq!(item.kind, WebActivityKind::Patch);
let tool = item.tool.expect("coding edit web tool metadata");
assert_eq!(tool.name, "edit_file");
assert_eq!(tool.app.as_deref(), Some("Workspace"));
assert_eq!(tool.affected_files, vec!["README.md".to_string()]);
}
}