use serde_json::Value;
use super::format::{
collab_status_line, command_action_lines, dynamic_tool_detail_text, file_change_lines,
format_json_value, is_exploration_action_set, mcp_detail_text, preview_text,
};
use super::{
PREVIEW_LIMIT, ThreadRenderNode, final_separator_exists, render_node_id, render_notice_node,
render_collab_title, replace_item_nodes, set_inline_message_from_last_node, turn_has_activity,
};
use crate::state::helpers::optional_string;
pub(super) fn upsert_nodes_from_item(
snapshot: &mut super::ThreadRenderSnapshot,
turn_id: Option<&str>,
item: &Value,
streaming: bool,
) {
let item_id = optional_string(item, "id");
let Some(item_type) = item.get("type").and_then(Value::as_str) else {
return;
};
let nodes = match item_type {
"userMessage" => vec![ThreadRenderNode::UserMessage {
id: render_node_id(turn_id, item_id.as_deref(), "user"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
text: render_user_message_text(item),
}],
"agentMessage" => render_agent_message_nodes(snapshot, turn_id, item, streaming),
"plan" => vec![ThreadRenderNode::ProposedPlan {
id: render_node_id(turn_id, item_id.as_deref(), "proposed-plan"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: "Proposed plan".to_string(),
text: optional_string(item, "text").unwrap_or_default(),
}],
"reasoning" => vec![ThreadRenderNode::ReasoningSummary {
id: render_node_id(turn_id, item_id.as_deref(), "reasoning"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: "Thinking".to_string(),
text: render_reasoning_text(item),
}],
"commandExecution" => vec![render_command_execution_node(turn_id, item, streaming)],
"fileChange" => vec![render_file_change_node(turn_id, item)],
"mcpToolCall" => vec![render_mcp_tool_call_node(turn_id, item)],
"dynamicToolCall" => vec![render_dynamic_tool_call_node(turn_id, item)],
"collabAgentToolCall" => vec![render_collab_event_node(turn_id, item)],
"webSearch" => vec![render_web_search_node(turn_id, item)],
"imageView" => vec![render_view_image_node(turn_id, item)],
"imageGeneration" => vec![render_image_generation_node(turn_id, item)],
"enteredReviewMode" => vec![render_notice_node(
turn_id,
item_id,
"Entered review mode",
optional_string(item, "review"),
)],
"exitedReviewMode" => vec![render_notice_node(
turn_id,
item_id,
"Exited review mode",
optional_string(item, "review"),
)],
"contextCompaction" => vec![render_notice_node(
turn_id,
item_id,
"Context compacted",
None,
)],
"hookPrompt" => vec![render_notice_node(
turn_id,
item_id,
"Hook prompt",
Some(render_hook_prompt_text(item)),
)],
_ => vec![render_notice_node(
turn_id,
item_id,
item_type,
format_json_value(item).map(|value| preview_text(&value, PREVIEW_LIMIT)),
)],
};
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
replace_item_nodes(snapshot, item_id, nodes);
} else {
snapshot.nodes.extend(nodes);
}
set_inline_message_from_last_node(snapshot);
}
fn render_agent_message_nodes(
snapshot: &mut super::ThreadRenderSnapshot,
turn_id: Option<&str>,
item: &Value,
streaming: bool,
) -> Vec<ThreadRenderNode> {
let item_id = optional_string(item, "id");
let phase = optional_string(item, "phase");
let mut nodes = Vec::new();
if phase.as_deref() == Some("final_answer")
&& turn_has_activity(snapshot, turn_id)
&& !final_separator_exists(snapshot, turn_id)
{
nodes.push(ThreadRenderNode::FinalSeparator {
id: render_node_id(turn_id, item_id.as_deref(), "final-separator"),
turn_id: turn_id.map(ToOwned::to_owned),
});
}
nodes.push(ThreadRenderNode::AssistantMarkdown {
id: render_node_id(turn_id, item_id.as_deref(), "assistant"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
text: optional_string(item, "text").unwrap_or_default(),
phase,
streaming,
});
nodes
}
fn render_command_execution_node(
turn_id: Option<&str>,
item: &Value,
streaming: bool,
) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
let state = optional_string(item, "status").unwrap_or_else(|| {
if streaming {
"inProgress".to_string()
} else {
"completed".to_string()
}
});
let exploring = is_exploration_action_set(item.get("commandActions").unwrap_or(&Value::Null));
let title = match (exploring, state.as_str()) {
(true, "inProgress") => "Exploring",
(true, _) => "Explored",
(false, "inProgress") => "Running",
_ => "Ran",
};
ThreadRenderNode::ExecGroup {
id: render_node_id(turn_id, item_id.as_deref(), "exec"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: title.to_string(),
state,
commands: command_action_lines(
item.get("commandActions").unwrap_or(&Value::Null),
item.get("command").and_then(Value::as_str),
),
output_text: optional_string(item, "aggregatedOutput"),
exit_code: item.get("exitCode").and_then(Value::as_i64),
}
}
fn render_file_change_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
let state = optional_string(item, "status").unwrap_or_else(|| "completed".to_string());
ThreadRenderNode::FileChange {
id: render_node_id(turn_id, item_id.as_deref(), "file-change"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: if state == "inProgress" {
"Editing"
} else {
"Edited"
}
.to_string(),
state,
changes: file_change_lines(item.get("changes").unwrap_or(&Value::Null)),
}
}
fn render_mcp_tool_call_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
let state = optional_string(item, "status").unwrap_or_else(|| "completed".to_string());
let subtitle = format!(
"{}.{}({})",
optional_string(item, "server").unwrap_or_else(|| "mcp".to_string()),
optional_string(item, "tool").unwrap_or_else(|| "tool".to_string()),
format_json_value(item.get("arguments").unwrap_or(&Value::Null)).unwrap_or_default()
);
ThreadRenderNode::McpToolCall {
id: render_node_id(turn_id, item_id.as_deref(), "mcp"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: if state == "inProgress" { "Calling" } else { "Called" }.to_string(),
state,
subtitle,
detail: mcp_detail_text(item.get("result"), item.get("error")),
}
}
fn render_dynamic_tool_call_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
let state = optional_string(item, "status").unwrap_or_else(|| "completed".to_string());
ThreadRenderNode::DynamicToolCall {
id: render_node_id(turn_id, item_id.as_deref(), "dynamic-tool"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: if state == "inProgress" { "Calling" } else { "Called" }.to_string(),
state: state.clone(),
subtitle: format!(
"{}({})",
optional_string(item, "tool").unwrap_or_else(|| "tool".to_string()),
format_json_value(item.get("arguments").unwrap_or(&Value::Null)).unwrap_or_default()
),
detail: dynamic_tool_detail_text(
item.get("contentItems"),
item.get("success").and_then(Value::as_bool),
),
}
}
fn render_collab_event_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
let tool = optional_string(item, "tool").unwrap_or_else(|| "agent".to_string());
let status = optional_string(item, "status").unwrap_or_else(|| "completed".to_string());
let receiver_ids = item
.get("receiverThreadIds")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
let title = render_collab_title(&tool, &status, &receiver_ids);
let mut detail_lines = optional_string(item, "prompt")
.map(|prompt| vec![preview_text(&prompt, 160)])
.unwrap_or_default();
if tool == "wait" && status != "inProgress" {
if let Some(agents) = item.get("agentsStates").and_then(Value::as_object) {
detail_lines = agents
.iter()
.map(|(agent_id, agent_status)| collab_status_line(agent_id, agent_status))
.collect();
}
} else if let Some(model) = optional_string(item, "model") {
let reasoning = optional_string(item, "reasoningEffort").unwrap_or_default();
let suffix = if reasoning.is_empty() {
model
} else {
format!("{model} {reasoning}")
};
detail_lines.push(suffix);
}
ThreadRenderNode::CollabEvent {
id: render_node_id(turn_id, item_id.as_deref(), "collab"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title,
detail_lines,
}
}
fn render_web_search_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
let action = item.get("action").unwrap_or(&Value::Null);
let (title, detail) = match action.get("type").and_then(Value::as_str) {
Some("openPage") => ("Opened page", optional_string(action, "url")),
Some("findInPage") => (
"Found in page",
Some(
[optional_string(action, "pattern"), optional_string(action, "url")]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" @ "),
),
),
_ => ("Searched web", optional_string(item, "query")),
};
ThreadRenderNode::WebSearch {
id: render_node_id(turn_id, item_id.as_deref(), "web-search"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: title.to_string(),
state: "completed".to_string(),
detail,
}
}
fn render_view_image_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
ThreadRenderNode::ViewImage {
id: render_node_id(turn_id, item_id.as_deref(), "view-image"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: "Viewed image".to_string(),
path: optional_string(item, "path").unwrap_or_default(),
}
}
fn render_image_generation_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
ThreadRenderNode::ImageGeneration {
id: render_node_id(turn_id, item_id.as_deref(), "image-generation"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: "Image generation".to_string(),
state: optional_string(item, "status").unwrap_or_default(),
prompt: optional_string(item, "revisedPrompt"),
result: optional_string(item, "result").unwrap_or_default(),
saved_path: optional_string(item, "savedPath"),
}
}
fn render_user_message_text(item: &Value) -> String {
let mut parts = Vec::new();
for content in item
.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
match content.get("type").and_then(Value::as_str) {
Some("text") => parts.push(optional_string(content, "text").unwrap_or_default()),
Some("image") => parts.push(optional_string(content, "url").unwrap_or_default()),
Some("localImage") => parts.push(optional_string(content, "path").unwrap_or_default()),
Some("skill") | Some("mention") => {
let text = [optional_string(content, "name"), optional_string(content, "path")]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
if !text.is_empty() {
parts.push(text);
}
}
_ => {}
}
}
parts.join("\n")
}
fn render_reasoning_text(item: &Value) -> String {
let summary = item
.get("summary")
.and_then(Value::as_array)
.map(|items| {
items.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !summary.is_empty() {
return summary.join("\n");
}
item.get("content")
.and_then(Value::as_array)
.map(|items| {
items.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
}
fn render_hook_prompt_text(item: &Value) -> String {
item.get("fragments")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(format_json_value)
.collect::<Vec<_>>()
.join("\n")
}