Complete agent monitoring and Galaxy Control integration
- expose command-monitor conversations and preserve visible agent transcripts - add bounded polling and a dedicated shell interrupt tool - improve direct-provider images, skills, tool history, and usage handling - package and brand Galaxy Control across releases, installers, persistence, and docs
This commit is contained in:
@@ -345,7 +345,7 @@ impl AgentInputFooter {
|
||||
let file_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("", AgentInputButtonTheme)
|
||||
.with_icon(Icon::Plus)
|
||||
.with_tooltip("Attach file")
|
||||
.with_tooltip("Attach files or images")
|
||||
.with_size(button_size)
|
||||
.with_tooltip_alignment(TooltipAlignment::Left)
|
||||
.on_click(|ctx| {
|
||||
|
||||
@@ -24,10 +24,19 @@ use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspace::{RestoreConversationLayout, WorkspaceAction, WorkspaceRegistry};
|
||||
|
||||
const DIRECT_PROVIDER_AGENT_OUTPUT_DELIMITER: &str = "\n\nAgent output:\n";
|
||||
|
||||
fn canonical_agent_id(agent_id: &str) -> &str {
|
||||
agent_id
|
||||
.split_once(DIRECT_PROVIDER_AGENT_OUTPUT_DELIMITER)
|
||||
.map_or(agent_id, |(agent_id, _)| agent_id)
|
||||
}
|
||||
|
||||
pub(crate) fn conversation_id_for_agent_id(
|
||||
agent_id: &str,
|
||||
app: &AppContext,
|
||||
) -> Option<AIConversationId> {
|
||||
let agent_id = canonical_agent_id(agent_id);
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
history_model
|
||||
.conversation_id_for_agent_id(agent_id)
|
||||
@@ -36,6 +45,13 @@ pub(crate) fn conversation_id_for_agent_id(
|
||||
agent_id.to_string(),
|
||||
))
|
||||
})
|
||||
.or_else(|| {
|
||||
let conversation_id = AIConversationId::try_from(agent_id.to_string()).ok()?;
|
||||
history_model
|
||||
.conversation(&conversation_id)
|
||||
.is_some()
|
||||
.then_some(conversation_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// True if the conversation is open in some other visible pane. Hidden
|
||||
@@ -303,3 +319,7 @@ pub(crate) fn conversation_navigation_card_with_icon(
|
||||
|
||||
hoverable.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "orchestration_conversation_links_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use warpui::App;
|
||||
|
||||
use super::{canonical_agent_id, conversation_id_for_agent_id};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
|
||||
#[test]
|
||||
fn canonical_agent_id_preserves_plain_ids() {
|
||||
assert_eq!(canonical_agent_id("child-agent-id"), "child-agent-id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_agent_id_strips_direct_provider_inline_output() {
|
||||
assert_eq!(
|
||||
canonical_agent_id(
|
||||
"child-agent-id\n\nAgent output:\nFinished the task.\n\nAgent output:\nNested text"
|
||||
),
|
||||
"child-agent-id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_id_fallback_rejects_uuid_absent_from_local_history() {
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let unknown_conversation_id = AIConversationId::new();
|
||||
|
||||
let resolved =
|
||||
app.read(|ctx| conversation_id_for_agent_id(&unknown_conversation_id.to_string(), ctx));
|
||||
|
||||
assert_eq!(resolved, None);
|
||||
});
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Inline subagent panel rendered within the parent agent's chat flow.
|
||||
//!
|
||||
//! Shows a collapsible panel with the subagent's status, a mini-transcript of
|
||||
//! recent messages, and controls to expand to full view or cancel.
|
||||
//! recent messages, and controls to expand inline or open the full child view.
|
||||
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Empty, Flex, Hoverable,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{AppContext, EntityId, SingletonEntity};
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus, StatusColorStyle};
|
||||
use crate::ai::agent::AIAgentActionId;
|
||||
use crate::ai::blocklist::agent_view::orchestration_conversation_links::dispatch_focus_or_open_child_agent_pane;
|
||||
use crate::ai::blocklist::block::AIBlockAction;
|
||||
use crate::ai::blocklist::inline_action::inline_action_header::{
|
||||
ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
|
||||
@@ -23,9 +24,12 @@ use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
const MINI_TRANSCRIPT_MAX_LINES: usize = 8;
|
||||
const MINI_TRANSCRIPT_MAX_CHARS: usize = 120;
|
||||
const COMPLETION_SUMMARY_MAX_CHARS: usize = 300;
|
||||
const PANEL_MAX_HEIGHT: f32 = 200.;
|
||||
const PANEL_CORNER_RADIUS: f32 = 8.;
|
||||
|
||||
@@ -35,14 +39,18 @@ pub struct SubagentPanelState {
|
||||
pub conversation_id: AIConversationId,
|
||||
pub is_expanded: bool,
|
||||
pub header_mouse_state: MouseStateHandle,
|
||||
pub open_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl SubagentPanelState {
|
||||
pub fn new(conversation_id: AIConversationId) -> Self {
|
||||
Self {
|
||||
conversation_id,
|
||||
is_expanded: false,
|
||||
// The panel exists to expose the child agent's live conversation.
|
||||
// Start expanded so its responses are visible without another click.
|
||||
is_expanded: true,
|
||||
header_mouse_state: MouseStateHandle::default(),
|
||||
open_mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +59,7 @@ impl SubagentPanelState {
|
||||
pub fn render_subagent_inline_panel(
|
||||
state: &SubagentPanelState,
|
||||
action_id: &AIAgentActionId,
|
||||
self_terminal_view_id: EntityId,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
@@ -70,19 +79,52 @@ pub fn render_subagent_inline_panel(
|
||||
|
||||
// Header — always visible, click to toggle expand/collapse
|
||||
let header_mouse_state = state.header_mouse_state.clone();
|
||||
let open_mouse_state = state.open_mouse_state.clone();
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
let toggle_action_id = action_id.clone();
|
||||
let header_status = status.clone();
|
||||
let header_expanded = state.is_expanded;
|
||||
let toggle = Hoverable::new(header_mouse_state, move |_mouse_state| {
|
||||
render_panel_header(&agent_name, &header_status, header_expanded, panel_bg, app)
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AIBlockAction::ToggleSubagentPanel {
|
||||
action_id: toggle_action_id.clone(),
|
||||
});
|
||||
})
|
||||
.finish();
|
||||
let child_conversation_id = state.conversation_id;
|
||||
let open = icon_button(appearance, Icon::LinkExternal, false, open_mouse_state)
|
||||
.with_tooltip(move || {
|
||||
ui_builder
|
||||
.tool_tip("Open child conversation".to_string())
|
||||
.build()
|
||||
.finish()
|
||||
})
|
||||
.build()
|
||||
.on_click(move |ctx, app, _| {
|
||||
dispatch_focus_or_open_child_agent_pane(
|
||||
child_conversation_id,
|
||||
self_terminal_view_id,
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
})
|
||||
.finish();
|
||||
column.add_child(
|
||||
Hoverable::new(header_mouse_state, move |_mouse_state| {
|
||||
render_panel_header(&agent_name, &header_status, header_expanded, panel_bg, app)
|
||||
})
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AIBlockAction::ToggleSubagentPanel {
|
||||
action_id: toggle_action_id.clone(),
|
||||
});
|
||||
})
|
||||
.finish(),
|
||||
Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., toggle).finish())
|
||||
.with_child(
|
||||
Container::new(open)
|
||||
.with_padding_left(4.)
|
||||
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Body (mini-transcript) — only when expanded
|
||||
@@ -204,35 +246,44 @@ fn collect_mini_transcript(conversation_id: &AIConversationId, app: &AppContext)
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let messages = conversation.all_linearized_messages();
|
||||
for msg in messages.iter().rev().take(MINI_TRANSCRIPT_MAX_LINES * 2) {
|
||||
if let Some(text) = extract_message_text(msg) {
|
||||
let truncated = if text.len() > 120 {
|
||||
format!("{}...", &text[..117])
|
||||
} else {
|
||||
text
|
||||
};
|
||||
lines.push(truncated);
|
||||
if lines.len() >= MINI_TRANSCRIPT_MAX_LINES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
collect_visible_transcript(&messages, MINI_TRANSCRIPT_MAX_LINES)
|
||||
}
|
||||
|
||||
fn collect_visible_transcript(messages: &[&api::Message], max_lines: usize) -> Vec<String> {
|
||||
let mut lines = messages
|
||||
.iter()
|
||||
.rev()
|
||||
// Filter first, then apply the visible-line limit. A tool-heavy turn can
|
||||
// contain many internal messages between user/agent chat messages.
|
||||
.filter_map(|message| extract_message_text(message))
|
||||
.take(max_lines)
|
||||
.map(|text| truncate_with_ellipsis(&text, MINI_TRANSCRIPT_MAX_CHARS))
|
||||
.collect::<Vec<_>>();
|
||||
lines.reverse();
|
||||
lines
|
||||
}
|
||||
|
||||
fn truncate_with_ellipsis(text: &str, max_chars: usize) -> String {
|
||||
let mut chars = text.chars();
|
||||
let prefix = chars.by_ref().take(max_chars).collect::<String>();
|
||||
if chars.next().is_none() {
|
||||
return prefix;
|
||||
}
|
||||
|
||||
let visible_prefix_chars = max_chars.saturating_sub(3);
|
||||
let mut truncated = prefix
|
||||
.chars()
|
||||
.take(visible_prefix_chars)
|
||||
.collect::<String>();
|
||||
truncated.push_str(&".".repeat(max_chars.min(3)));
|
||||
truncated
|
||||
}
|
||||
|
||||
fn extract_message_text(msg: &api::Message) -> Option<String> {
|
||||
let message_content = msg.message.as_ref()?;
|
||||
match message_content {
|
||||
api::message::Message::AgentOutput(output) => {
|
||||
if output.text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(output.text.clone())
|
||||
}
|
||||
}
|
||||
api::message::Message::AgentOutput(_) => extract_agent_output_text(msg),
|
||||
api::message::Message::UserQuery(query) => {
|
||||
if query.query.is_empty() {
|
||||
None
|
||||
@@ -244,6 +295,13 @@ fn extract_message_text(msg: &api::Message) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_agent_output_text(msg: &api::Message) -> Option<String> {
|
||||
let api::message::Message::AgentOutput(output) = msg.message.as_ref()? else {
|
||||
return None;
|
||||
};
|
||||
(!output.text.is_empty()).then(|| output.text.clone())
|
||||
}
|
||||
|
||||
fn render_mini_transcript(
|
||||
lines: &[String],
|
||||
background: ColorU,
|
||||
@@ -285,20 +343,14 @@ fn get_completion_summary(conversation_id: &AIConversationId, app: &AppContext)
|
||||
return None;
|
||||
}
|
||||
|
||||
let messages = conversation.all_linearized_messages();
|
||||
for msg in messages.iter().rev() {
|
||||
if let Some(text) = extract_message_text(msg) {
|
||||
if !text.is_empty() {
|
||||
let truncated = if text.len() > 300 {
|
||||
format!("{}...", &text[..297])
|
||||
} else {
|
||||
text
|
||||
};
|
||||
return Some(truncated);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
completion_summary_from_messages(&conversation.all_linearized_messages())
|
||||
}
|
||||
|
||||
fn completion_summary_from_messages(messages: &[&api::Message]) -> Option<String> {
|
||||
messages.iter().rev().find_map(|message| {
|
||||
extract_agent_output_text(message)
|
||||
.map(|text| truncate_with_ellipsis(&text, COMPLETION_SUMMARY_MAX_CHARS))
|
||||
})
|
||||
}
|
||||
|
||||
fn render_summary_footer(summary: &str, _background: ColorU, app: &AppContext) -> Box<dyn Element> {
|
||||
@@ -333,3 +385,7 @@ fn render_summary_footer(summary: &str, _background: ColorU, app: &AppContext) -
|
||||
.with_padding_bottom(6.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "subagent_inline_panel_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
use super::{
|
||||
collect_visible_transcript, completion_summary_from_messages, extract_message_text,
|
||||
truncate_with_ellipsis, SubagentPanelState,
|
||||
};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
|
||||
fn message(content: api::message::Message) -> api::Message {
|
||||
api::Message {
|
||||
message: Some(content),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn user_query(text: &str) -> api::Message {
|
||||
message(api::message::Message::UserQuery(api::message::UserQuery {
|
||||
query: text.to_string(),
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
|
||||
fn system_query() -> api::Message {
|
||||
message(api::message::Message::SystemQuery(
|
||||
api::message::SystemQuery::default(),
|
||||
))
|
||||
}
|
||||
|
||||
fn agent_output(text: &str) -> api::Message {
|
||||
message(api::message::Message::AgentOutput(
|
||||
api::message::AgentOutput {
|
||||
text: text.to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn message_refs(messages: &[api::Message]) -> Vec<&api::Message> {
|
||||
messages.iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_with_ellipsis_preserves_short_text() {
|
||||
assert_eq!(
|
||||
truncate_with_ellipsis("Galaxy terminal", 20),
|
||||
"Galaxy terminal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_with_ellipsis_is_unicode_safe() {
|
||||
let truncated = truncate_with_ellipsis("🚀🚀🚀🚀🚀 Galaxy", 8);
|
||||
|
||||
assert_eq!(truncated.chars().count(), 8);
|
||||
assert!(truncated.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_with_ellipsis_handles_tiny_limits() {
|
||||
assert_eq!(truncate_with_ellipsis("Galaxy", 2), "..");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_panel_starts_expanded_so_agent_chat_is_visible() {
|
||||
let state = SubagentPanelState::new(AIConversationId::new());
|
||||
|
||||
assert!(state.is_expanded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_shows_user_and_agent_messages_but_hides_system_queries() {
|
||||
let system = system_query();
|
||||
let user = user_query("Please check the build");
|
||||
let agent = agent_output("The build is still running.");
|
||||
|
||||
assert_eq!(extract_message_text(&system), None);
|
||||
assert_eq!(
|
||||
extract_message_text(&user).as_deref(),
|
||||
Some("Please check the build")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_message_text(&agent).as_deref(),
|
||||
Some("The build is still running.")
|
||||
);
|
||||
assert_eq!(extract_message_text(&user_query("")), None);
|
||||
assert_eq!(extract_message_text(&agent_output("")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_system_messages_do_not_displace_agent_responses() {
|
||||
let mut messages = vec![agent_output("Visible response to a system request")];
|
||||
messages.extend((0..32).map(|_| system_query()));
|
||||
let refs = message_refs(&messages);
|
||||
|
||||
assert_eq!(
|
||||
collect_visible_transcript(&refs, 8),
|
||||
vec!["Visible response to a system request"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_limits_visible_messages_and_keeps_chronological_order() {
|
||||
let messages = (0..10)
|
||||
.map(|index| agent_output(&format!("response {index}")))
|
||||
.collect::<Vec<_>>();
|
||||
let refs = message_refs(&messages);
|
||||
|
||||
assert_eq!(
|
||||
collect_visible_transcript(&refs, 8),
|
||||
(2..10)
|
||||
.map(|index| format!("response {index}"))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_summary_uses_latest_agent_response() {
|
||||
let messages = vec![
|
||||
agent_output("Final assistant answer"),
|
||||
user_query("A trailing user message"),
|
||||
system_query(),
|
||||
];
|
||||
let refs = message_refs(&messages);
|
||||
|
||||
assert_eq!(
|
||||
completion_summary_from_messages(&refs).as_deref(),
|
||||
Some("Final assistant answer")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user