v1.4.0: Auto-compact streaming, Bedrock summarization support, subagent orchestration, and Galaxy rebrand continuation
Major features: - Auto-compact: triggers conversation summarization when context window >= 85%, compacts Bedrock message history to a summary pair, and tracks live context tokens - Bedrock summarization: plumbs `is_summarization` flag through translator/client/response pipeline, handles SummarizeConversation input type, and marks `summarized` in metadata - Session restore: rebuilds bedrock_message_history from persisted task messages via newly-public `convert_proto_message`, preventing empty history on reconnect - Subagent orchestration: adds SubagentQuestion/Answer/CompletionSummary event types, parent-child question routing with depth limits, retry counting, and drain methods - Summarization UI: inline SummarizationView in AI blocks with progress/finished states Refactors: - Rename WarpTheme → GalaxyTheme across ~100 files (rebrand continuation) - Rename warp_home_config_dir → galaxy_home_config_dir and related path functions - Predefined rules: replace "System Defined Rule #N" with descriptive names (e.g. "Correctness Over Speed", "Never Guess") and add lookup helpers - Usage view: replace cumulative input/output token display with live context tokens, cache hit rate calculation, and separate cache read/write stats - Telemetry: remove verbose doc comments, simplify trait definitions - Facts view: simplify delete permission check (always allow local deletion) - Remove warp_managed_paths_watcher.rs (dead code) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
eaa2ddc75e
commit
6f54e2cb30
@@ -6,7 +6,7 @@ use crate::ai::agent::ReadSkillResult;
|
||||
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType};
|
||||
use crate::ai::blocklist::action_model::AIConversationId;
|
||||
use crate::ai::skills::SkillManager;
|
||||
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
|
||||
use crate::galaxy_managed_paths_watcher::GalaxyManagedPathsWatcher;
|
||||
use ai::skills::{parse_skill, SkillReference};
|
||||
use galaxyui::App;
|
||||
use repo_metadata::{
|
||||
@@ -22,7 +22,7 @@ fn initialize_app(app: &mut App) {
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
||||
app.add_singleton_model(GalaxyManagedPathsWatcher::new_for_testing);
|
||||
app.add_singleton_model(SkillManager::new);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ mod inline_agent_view_header;
|
||||
// TODO: Move orchestration_conversation_links module import elsewhere.
|
||||
pub(crate) mod orchestration_conversation_links;
|
||||
pub mod shortcuts;
|
||||
pub(crate) mod subagent_inline_panel;
|
||||
mod zero_state_block;
|
||||
|
||||
pub use agent_input_footer::*;
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! 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.
|
||||
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Empty, Flex,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::blocklist::inline_action::inline_action_header::{
|
||||
ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
|
||||
};
|
||||
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::icons::Icon;
|
||||
|
||||
const MINI_TRANSCRIPT_MAX_LINES: usize = 8;
|
||||
const PANEL_MAX_HEIGHT: f32 = 200.;
|
||||
const PANEL_CORNER_RADIUS: f32 = 8.;
|
||||
|
||||
/// State for a single subagent inline panel instance.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubagentPanelState {
|
||||
pub conversation_id: AIConversationId,
|
||||
pub is_expanded: bool,
|
||||
pub header_mouse_state: MouseStateHandle,
|
||||
pub expand_button_mouse_state: MouseStateHandle,
|
||||
pub cancel_button_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl SubagentPanelState {
|
||||
pub fn new(conversation_id: AIConversationId) -> Self {
|
||||
Self {
|
||||
conversation_id,
|
||||
is_expanded: false,
|
||||
header_mouse_state: MouseStateHandle::default(),
|
||||
expand_button_mouse_state: MouseStateHandle::default(),
|
||||
cancel_button_mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the inline subagent panel for a child conversation.
|
||||
pub fn render_subagent_inline_panel(
|
||||
state: &SubagentPanelState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let Some(conversation) = history_model.conversation(&state.conversation_id) else {
|
||||
return Empty::new().finish();
|
||||
};
|
||||
|
||||
let status = conversation.status().clone();
|
||||
let agent_name = conversation
|
||||
.agent_name()
|
||||
.unwrap_or("Subagent")
|
||||
.to_string();
|
||||
|
||||
let panel_bg = blended_colors::neutral_2(theme);
|
||||
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
// Header — always visible
|
||||
column.add_child(render_panel_header(
|
||||
&agent_name,
|
||||
&status,
|
||||
state,
|
||||
panel_bg,
|
||||
app,
|
||||
));
|
||||
|
||||
// Body (mini-transcript) — only when expanded
|
||||
if state.is_expanded {
|
||||
let transcript_lines = collect_mini_transcript(&state.conversation_id, app);
|
||||
if !transcript_lines.is_empty() {
|
||||
column.add_child(render_mini_transcript(&transcript_lines, panel_bg, app));
|
||||
}
|
||||
}
|
||||
|
||||
// Footer — show summary when complete
|
||||
if status.is_done() {
|
||||
if let Some(summary) = get_completion_summary(&state.conversation_id, app) {
|
||||
column.add_child(render_summary_footer(&summary, panel_bg, app));
|
||||
}
|
||||
}
|
||||
|
||||
Container::new(column.finish())
|
||||
.with_background_color(panel_bg)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(PANEL_CORNER_RADIUS)))
|
||||
.with_margin_top(4.)
|
||||
.with_margin_bottom(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_panel_header(
|
||||
agent_name: &str,
|
||||
status: &ConversationStatus,
|
||||
state: &SubagentPanelState,
|
||||
_background: ColorU,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.monospace_font_size();
|
||||
let surface = theme.surface_2();
|
||||
|
||||
let mut header_row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
// Left: status icon + agent name + status text
|
||||
let mut left_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
let (icon, icon_color) = status.status_icon_and_color(theme);
|
||||
let status_icon_element = ConstrainedBox::new(
|
||||
galaxyui::elements::Icon::new(icon.into(), icon_color).finish(),
|
||||
)
|
||||
.with_width(icon_size(app))
|
||||
.with_height(icon_size(app))
|
||||
.finish();
|
||||
|
||||
left_side.add_child(
|
||||
Container::new(status_icon_element)
|
||||
.with_margin_right(ICON_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let name_color: ColorU = theme.main_text_color(surface).into();
|
||||
left_side.add_child(
|
||||
Text::new_inline(agent_name.to_string(), font_family, font_size)
|
||||
.with_color(name_color)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let status_text = match status {
|
||||
ConversationStatus::InProgress => "Working...",
|
||||
ConversationStatus::Success => "Complete",
|
||||
ConversationStatus::Error => "Error",
|
||||
ConversationStatus::Cancelled => "Cancelled",
|
||||
ConversationStatus::Blocked { .. } => "Blocked",
|
||||
};
|
||||
let status_text_color = blended_colors::text_disabled(theme, surface);
|
||||
left_side.add_child(
|
||||
Container::new(
|
||||
Text::new_inline(status_text.to_string(), font_family, font_size)
|
||||
.with_color(status_text_color)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
header_row.add_child(Shrinkable::new(1.0, left_side.finish()).finish());
|
||||
|
||||
// Right: collapse/expand chevron
|
||||
let mut right_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
let chevron_icon = if state.is_expanded {
|
||||
Icon::ChevronDown
|
||||
} else {
|
||||
Icon::ChevronRight
|
||||
};
|
||||
let chevron_color = blended_colors::text_disabled(theme, surface);
|
||||
let chevron = ConstrainedBox::new(
|
||||
galaxyui::elements::Icon::new(chevron_icon.into(), chevron_color).finish(),
|
||||
)
|
||||
.with_width(icon_size(app))
|
||||
.with_height(icon_size(app))
|
||||
.finish();
|
||||
right_side.add_child(
|
||||
Container::new(chevron)
|
||||
.with_margin_right(4.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
header_row.add_child(right_side.finish());
|
||||
|
||||
Container::new(header_row.finish())
|
||||
.with_padding_left(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_padding_top(INLINE_ACTION_HEADER_VERTICAL_PADDING)
|
||||
.with_padding_bottom(INLINE_ACTION_HEADER_VERTICAL_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn collect_mini_transcript(
|
||||
conversation_id: &AIConversationId,
|
||||
app: &AppContext,
|
||||
) -> Vec<String> {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let Some(conversation) = history_model.conversation(conversation_id) else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.reverse();
|
||||
lines
|
||||
}
|
||||
|
||||
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::UserQuery(query) => {
|
||||
if query.query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(query.query.clone())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_mini_transcript(
|
||||
lines: &[String],
|
||||
background: ColorU,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let text_color = blended_colors::text_disabled(theme, background);
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.monospace_font_size() - 1.;
|
||||
|
||||
let mut column = Flex::column();
|
||||
for line in lines {
|
||||
let prefixed = format!("> {line}");
|
||||
column.add_child(
|
||||
Text::new_inline(prefixed, font_family, font_size)
|
||||
.with_color(text_color)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
ConstrainedBox::new(
|
||||
Container::new(column.finish())
|
||||
.with_padding_left(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_padding_top(4.)
|
||||
.with_padding_bottom(4.)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(PANEL_MAX_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn get_completion_summary(
|
||||
conversation_id: &AIConversationId,
|
||||
app: &AppContext,
|
||||
) -> Option<String> {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let conversation = history_model.conversation(conversation_id)?;
|
||||
|
||||
if !conversation.status().is_done() {
|
||||
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
|
||||
}
|
||||
|
||||
fn render_summary_footer(
|
||||
summary: &str,
|
||||
_background: ColorU,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let surface = theme.surface_2();
|
||||
let text_color: ColorU = theme.main_text_color(surface).into();
|
||||
let label_color = blended_colors::text_disabled(theme, surface);
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.monospace_font_size();
|
||||
|
||||
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Start);
|
||||
row.add_child(
|
||||
Text::new_inline("Summary: ".to_string(), font_family, font_size)
|
||||
.with_color(label_color)
|
||||
.finish(),
|
||||
);
|
||||
row.add_child(
|
||||
Shrinkable::new(
|
||||
1.0,
|
||||
Text::new_inline(summary.to_string(), font_family, font_size)
|
||||
.with_color(text_color)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
Container::new(row.finish())
|
||||
.with_padding_left(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_padding_top(6.)
|
||||
.with_padding_bottom(6.)
|
||||
.finish()
|
||||
}
|
||||
@@ -90,6 +90,7 @@ use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::{
|
||||
use crate::ai::blocklist::inline_action::search_codebase::{
|
||||
SearchCodebaseView, SearchCodebaseViewEvent,
|
||||
};
|
||||
use crate::ai::blocklist::inline_action::summarization::SummarizationView;
|
||||
use crate::ai::blocklist::inline_action::web_fetch::WebFetchView;
|
||||
use crate::ai::blocklist::inline_action::web_search::WebSearchView;
|
||||
use crate::ai::facts::{AIFact, AIMemory, CloudAIFactModel};
|
||||
@@ -830,6 +831,9 @@ pub struct AIBlock {
|
||||
/// Map from web fetch message IDs to their view handles.
|
||||
web_fetch_views: HashMap<MessageId, ViewHandle<WebFetchView>>,
|
||||
|
||||
/// Map from summarization message IDs to their view handles.
|
||||
summarization_views: HashMap<MessageId, ViewHandle<SummarizationView>>,
|
||||
|
||||
/// Map from todo list IDs to their states.
|
||||
todo_list_states: HashMap<MessageId, TodoListElementState>,
|
||||
|
||||
@@ -1340,6 +1344,7 @@ impl AIBlock {
|
||||
search_codebase_view: Default::default(),
|
||||
web_search_views: Default::default(),
|
||||
web_fetch_views: Default::default(),
|
||||
summarization_views: Default::default(),
|
||||
requested_commands_to_auto_collapse: Default::default(),
|
||||
review_changes_button,
|
||||
open_all_comments_button,
|
||||
@@ -1801,6 +1806,9 @@ impl AIBlock {
|
||||
self.handle_web_fetch_messages(&output.messages, ctx);
|
||||
}
|
||||
|
||||
self.handle_summarization_messages(&output.messages, ctx);
|
||||
self.maybe_create_summarization_view_from_input(ctx);
|
||||
|
||||
for action in output.actions() {
|
||||
let new_action_ids: HashSet<AIAgentActionId> =
|
||||
output.actions().map(|action| action.id.clone()).collect();
|
||||
@@ -3489,6 +3497,84 @@ impl AIBlock {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_summarization_messages(
|
||||
&mut self,
|
||||
messages: &[AIAgentOutputMessage],
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
use crate::ai::agent::SummarizationType;
|
||||
|
||||
for message in messages {
|
||||
let AIAgentOutputMessageType::Summarization {
|
||||
finished_duration,
|
||||
summarization_type,
|
||||
..
|
||||
} = &message.message
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !matches!(summarization_type, SummarizationType::ConversationSummary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(view) = self.summarization_views.get(&message.id) {
|
||||
if finished_duration.is_some() {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.mark_finished();
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let is_finished = finished_duration.is_some();
|
||||
let view = ctx.add_view(|ctx| {
|
||||
let mut v = SummarizationView::new(ctx);
|
||||
if is_finished {
|
||||
v.mark_finished();
|
||||
}
|
||||
v
|
||||
});
|
||||
self.summarization_views.insert(message.id.clone(), view);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a SummarizationView when the exchange input is a SummarizeConversation.
|
||||
/// This handles the Bedrock path where no Summarization output message is emitted.
|
||||
fn maybe_create_summarization_view_from_input(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let is_summarize_input = self
|
||||
.model
|
||||
.inputs_to_render(ctx)
|
||||
.iter()
|
||||
.any(|i| matches!(i, AIAgentInput::SummarizeConversation { .. }));
|
||||
|
||||
if !is_summarize_input {
|
||||
return;
|
||||
}
|
||||
|
||||
let key = MessageId::new("__summarization_inline_view__".to_string());
|
||||
if self.summarization_views.contains_key(&key) {
|
||||
// Already created — check if we should mark it finished
|
||||
let is_complete = !self.model.status(ctx).is_streaming();
|
||||
if is_complete {
|
||||
if let Some(view) = self.summarization_views.get(&key) {
|
||||
view.update(ctx, |view, ctx| {
|
||||
if !view.is_finished {
|
||||
view.mark_finished();
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let view = ctx.add_view(|ctx| SummarizationView::new(ctx));
|
||||
self.summarization_views.insert(key, view);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Note this is called when the search codebase tool call definition finishes streaming, not when the search actually completes.
|
||||
fn handle_search_codebase_complete(
|
||||
&mut self,
|
||||
|
||||
@@ -86,7 +86,7 @@ use galaxy_core::ui::color::contrast::{
|
||||
foreground_color_with_minimum_contrast, MinimumAllowedContrast,
|
||||
};
|
||||
use galaxy_core::ui::color::Rgb;
|
||||
use galaxy_core::ui::theme::{Fill, WarpTheme};
|
||||
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
|
||||
use galaxyui::elements::{Highlight, HighlightedRange, Text};
|
||||
use galaxyui::fonts::Properties;
|
||||
use galaxyui::platform::Cursor;
|
||||
@@ -433,7 +433,7 @@ pub(crate) fn add_highlights_to_rich_text(
|
||||
find_context: Option<FindContext<'_>>,
|
||||
location_index: usize,
|
||||
line_count: usize,
|
||||
theme: &WarpTheme,
|
||||
theme: &GalaxyTheme,
|
||||
is_selecting: bool,
|
||||
is_action: bool,
|
||||
app: &AppContext,
|
||||
@@ -1080,6 +1080,7 @@ impl View for AIBlock {
|
||||
search_codebase_view: &self.search_codebase_view,
|
||||
web_search_views: &self.web_search_views,
|
||||
web_fetch_views: &self.web_fetch_views,
|
||||
summarization_views: &self.summarization_views,
|
||||
review_changes_button: &self.review_changes_button,
|
||||
open_all_comments_button: &self.open_all_comments_button,
|
||||
dismiss_suggestion_button: &self.dismiss_suggestion_button,
|
||||
|
||||
@@ -88,6 +88,7 @@ use crate::{
|
||||
},
|
||||
requested_command::RequestedCommand,
|
||||
search_codebase::SearchCodebaseView,
|
||||
summarization::SummarizationView,
|
||||
suggested_unit_tests::SuggestedUnitTestsView,
|
||||
web_fetch::WebFetchView,
|
||||
web_search::WebSearchView,
|
||||
@@ -177,6 +178,7 @@ pub(crate) struct Props<'a> {
|
||||
pub(super) search_codebase_view: &'a HashMap<AIAgentActionId, ViewHandle<SearchCodebaseView>>,
|
||||
pub(super) web_search_views: &'a HashMap<MessageId, ViewHandle<WebSearchView>>,
|
||||
pub(super) web_fetch_views: &'a HashMap<MessageId, ViewHandle<WebFetchView>>,
|
||||
pub(super) summarization_views: &'a HashMap<MessageId, ViewHandle<SummarizationView>>,
|
||||
pub(super) review_changes_button: &'a ViewHandle<ActionButton>,
|
||||
pub(super) open_all_comments_button: &'a ViewHandle<ActionButton>,
|
||||
pub(super) dismiss_suggestion_button: &'a ViewHandle<ActionButton>,
|
||||
@@ -210,6 +212,23 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
||||
let conversation_status = props.model.conversation(app).map(|c| c.status());
|
||||
let is_conversation_in_progress = conversation_status.is_some_and(|s| s.is_in_progress());
|
||||
|
||||
// If this is a summarization request, render the inline SummarizationView at the top
|
||||
// regardless of output status. This handles the Bedrock path where no Summarization
|
||||
// output message type is emitted.
|
||||
let is_summarize_input = props
|
||||
.model
|
||||
.inputs_to_render(app)
|
||||
.iter()
|
||||
.any(|i| matches!(i, AIAgentInput::SummarizeConversation { .. }));
|
||||
if is_summarize_input {
|
||||
let key = crate::ai::agent::MessageId::new(
|
||||
"__summarization_inline_view__".to_string(),
|
||||
);
|
||||
if let Some(summarization_view) = props.summarization_views.get(&key) {
|
||||
output_items.add_child(ChildView::new(summarization_view).finish());
|
||||
}
|
||||
}
|
||||
|
||||
let status = props.model.status(app);
|
||||
match status {
|
||||
// Ignore errors if the response is not yet complete-- it could be a deserialization
|
||||
@@ -807,21 +826,28 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
||||
} if matches!(
|
||||
summarization_type,
|
||||
SummarizationType::ConversationSummary
|
||||
) && !are_all_text_sections_empty(&text.sections) =>
|
||||
) =>
|
||||
{
|
||||
let header_text = "Conversation summarized".to_string();
|
||||
if let Some(element) = render_collapsible_block(
|
||||
output_message,
|
||||
header_text,
|
||||
&text.sections,
|
||||
finished_duration.is_some(),
|
||||
props,
|
||||
&mut has_rendered_first_text_section,
|
||||
&mut text_section_index,
|
||||
&mut code_section_index,
|
||||
app,
|
||||
) {
|
||||
output_items.add_child(element);
|
||||
if let Some(summarization_view) =
|
||||
props.summarization_views.get(&output_message.id)
|
||||
{
|
||||
output_items
|
||||
.add_child(ChildView::new(summarization_view).finish());
|
||||
} else if !are_all_text_sections_empty(&text.sections) {
|
||||
let header_text = "Conversation summarized".to_string();
|
||||
if let Some(element) = render_collapsible_block(
|
||||
output_message,
|
||||
header_text,
|
||||
&text.sections,
|
||||
finished_duration.is_some(),
|
||||
props,
|
||||
&mut has_rendered_first_text_section,
|
||||
&mut text_section_index,
|
||||
&mut code_section_index,
|
||||
app,
|
||||
) {
|
||||
output_items.add_child(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
AIAgentOutputMessageType::WebSearch(web_search_status) => {
|
||||
@@ -3210,19 +3236,18 @@ fn render_usage_button(props: Props, app: &AppContext) -> Box<dyn Element> {
|
||||
};
|
||||
|
||||
let context_usage = conversation.context_window_usage();
|
||||
let total_input = conversation.total_input_tokens();
|
||||
let current_context = conversation.current_context_tokens();
|
||||
let cache_read = conversation.total_cache_read_tokens();
|
||||
let cache_write = conversation.total_cache_write_tokens();
|
||||
let cache_miss = conversation.cache_miss_tokens();
|
||||
let cost_cents = conversation.total_cost_cents();
|
||||
|
||||
let max_context: u32 = if context_usage > 0.0 {
|
||||
(total_input as f32 / context_usage).round() as u32
|
||||
(current_context as f32 / context_usage).round() as u32
|
||||
} else {
|
||||
200_000
|
||||
};
|
||||
let context_pct = context_usage * 100.0;
|
||||
let cache_total = cache_read + cache_write + cache_miss;
|
||||
let cache_total = cache_read + cache_write;
|
||||
let cache_hit_pct = if cache_total > 0 {
|
||||
(cache_read as f64 / cache_total as f64) * 100.0
|
||||
} else {
|
||||
@@ -3230,14 +3255,13 @@ fn render_usage_button(props: Props, app: &AppContext) -> Box<dyn Element> {
|
||||
};
|
||||
|
||||
let usage_text = format!(
|
||||
"Context: {:.1}% ({} / {}) | Cache: {:.1}% (R: {}, W: {}, M: {}) | Cost: ${:.2}",
|
||||
"Context: {:.1}% ({} / {}) | Cache: {:.1}% (R: {}, W: {}) | Cost: ${:.2}",
|
||||
context_pct,
|
||||
format_token_count(total_input),
|
||||
format_token_count(current_context),
|
||||
format_token_count(max_context),
|
||||
cache_hit_pct,
|
||||
format_token_count(cache_read),
|
||||
format_token_count(cache_write),
|
||||
format_token_count(cache_miss),
|
||||
cost_cents / 100.0,
|
||||
);
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ fn render_linked_code_block_internal(
|
||||
let open_button = render_button(
|
||||
appearance,
|
||||
Icon::LinkExternal,
|
||||
"Open in Warp",
|
||||
"Open in Galaxy",
|
||||
mouse_handles.open_button,
|
||||
code_clone.clone(),
|
||||
on_open,
|
||||
|
||||
@@ -1995,6 +1995,9 @@ impl BlocklistAIController {
|
||||
request_params.parent_agent_id = parent_agent_id;
|
||||
request_params.agent_name = agent_name;
|
||||
request_params.bedrock_message_history = bedrock_history;
|
||||
request_params.is_summarization = request_input
|
||||
.all_inputs()
|
||||
.any(|input| matches!(input, AIAgentInput::SummarizeConversation { .. }));
|
||||
|
||||
let server_conversation_token_for_identifiers =
|
||||
conversation_data.server_conversation_token.clone();
|
||||
@@ -2020,9 +2023,13 @@ impl BlocklistAIController {
|
||||
let input_contains_user_query = request_input
|
||||
.all_inputs()
|
||||
.any(|input| input.is_user_query());
|
||||
let input_is_summarization = request_input
|
||||
.all_inputs()
|
||||
.any(|input| matches!(input, AIAgentInput::SummarizeConversation { .. }));
|
||||
ctx.subscribe_to_model(&response_stream, move |me, event, ctx| {
|
||||
me.handle_response_stream_event(
|
||||
input_contains_user_query,
|
||||
input_is_summarization,
|
||||
event,
|
||||
&response_stream_clone,
|
||||
ctx,
|
||||
@@ -2212,6 +2219,7 @@ impl BlocklistAIController {
|
||||
fn handle_response_stream_event(
|
||||
&mut self,
|
||||
did_input_contain_user_query: bool,
|
||||
is_summarization_request: bool,
|
||||
event: &ResponseStreamEvent,
|
||||
response_stream: &ModelHandle<ResponseStream>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
@@ -2319,11 +2327,88 @@ impl BlocklistAIController {
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
history_model.update(ctx, |history_model, _| {
|
||||
if let Some(conversation) = history_model.conversation_mut(&conversation_id) {
|
||||
*conversation.bedrock_message_history_mut() = new_history;
|
||||
log::info!(
|
||||
"[bedrock] Updated conversation bedrock history: {} messages",
|
||||
conversation.bedrock_message_history().len()
|
||||
);
|
||||
// If this was a summarization request, compact the
|
||||
// history to just the summary instead of keeping
|
||||
// the full message list. This is what actually
|
||||
// frees up context window space.
|
||||
let is_summarization = is_summarization_request;
|
||||
|
||||
if is_summarization {
|
||||
// Extract the assistant's summary from the last
|
||||
// message in the history (the response).
|
||||
let summary_text = new_history
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|msg| {
|
||||
use crate::ai::bedrock::convert::{
|
||||
MessageContent, MessageRole,
|
||||
};
|
||||
if msg.role == MessageRole::Assistant {
|
||||
if let MessageContent::Text(text) =
|
||||
&msg.content
|
||||
{
|
||||
Some(text.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(summary) = summary_text {
|
||||
use crate::ai::bedrock::convert::{
|
||||
ConversationMessage, MessageContent,
|
||||
MessageRole,
|
||||
};
|
||||
let assistant_reply = "Understood. I have the context from our previous conversation. How can I help you next?";
|
||||
let user_msg = format!(
|
||||
"Here is a summary of our conversation so far:\n\n{summary}"
|
||||
);
|
||||
let compacted = vec![
|
||||
ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(user_msg.clone()),
|
||||
},
|
||||
ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::Text(
|
||||
assistant_reply.to_string()
|
||||
),
|
||||
},
|
||||
];
|
||||
log::info!(
|
||||
"[bedrock] Compacted conversation history from {} messages to {} (summary)",
|
||||
new_history.len(),
|
||||
compacted.len()
|
||||
);
|
||||
*conversation.bedrock_message_history_mut() =
|
||||
compacted;
|
||||
|
||||
// Estimate new context size from the compacted content.
|
||||
// ~4 chars per token is a reasonable approximation.
|
||||
let estimated_tokens = ((user_msg.len() + assistant_reply.len()) / 4) as u32;
|
||||
let max_context = crate::ai::bedrock::response_translator::context_window_for_model("claude-opus-4-6-20250514[1m]");
|
||||
let new_usage = estimated_tokens as f32 / max_context as f32;
|
||||
conversation.set_context_window_usage(new_usage);
|
||||
conversation.set_current_context_tokens(estimated_tokens);
|
||||
log::info!(
|
||||
"[bedrock] Post-compact context estimate: ~{} tokens ({:.1}% of context window)",
|
||||
estimated_tokens,
|
||||
new_usage * 100.0
|
||||
);
|
||||
} else {
|
||||
*conversation.bedrock_message_history_mut() =
|
||||
new_history;
|
||||
}
|
||||
} else {
|
||||
*conversation.bedrock_message_history_mut() =
|
||||
new_history;
|
||||
log::info!(
|
||||
"[bedrock] Updated conversation bedrock history: {} messages",
|
||||
conversation.bedrock_message_history().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2800,6 +2885,42 @@ impl BlocklistAIController {
|
||||
});
|
||||
ctx.emit(BlocklistAIControllerEvent::FreeTierLimitCheckTriggered);
|
||||
}
|
||||
|
||||
// Auto-compact: trigger summarization when context window usage >= 85%.
|
||||
let should_auto_compact = {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
history_model
|
||||
.conversation(&conversation_id)
|
||||
.is_some_and(|conversation| {
|
||||
let is_summarization_request = conversation
|
||||
.latest_exchange()
|
||||
.is_some_and(|exchange| {
|
||||
exchange
|
||||
.input
|
||||
.iter()
|
||||
.any(|i| matches!(i, AIAgentInput::SummarizeConversation { .. }))
|
||||
});
|
||||
conversation.context_window_usage() >= 0.85
|
||||
&& !conversation.has_pending_auto_compact()
|
||||
&& !is_summarization_request
|
||||
})
|
||||
};
|
||||
|
||||
if should_auto_compact {
|
||||
log::info!(
|
||||
"[auto-compact] Context window usage >= 85% for conversation {:?}, triggering summarization",
|
||||
conversation_id
|
||||
);
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
|
||||
if let Some(conversation) = history_model.conversation_mut(&conversation_id) {
|
||||
conversation.set_has_pending_auto_compact(true);
|
||||
}
|
||||
});
|
||||
self.send_slash_command_request(
|
||||
SlashCommandRequest::Summarize { prompt: None },
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use ai::agent::{
|
||||
action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType},
|
||||
action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult},
|
||||
};
|
||||
use galaxy_core::ui::theme::{color::internal_colors, WarpTheme};
|
||||
use galaxy_core::ui::theme::{color::internal_colors, GalaxyTheme};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
new_scrollable::SingleAxisConfig, Border, ChildView, Clipped, ClippedScrollStateHandle,
|
||||
@@ -1333,7 +1333,7 @@ impl AskUserQuestionView {
|
||||
fn render_question_text(
|
||||
question_text: &str,
|
||||
appearance: &Appearance,
|
||||
theme: &WarpTheme,
|
||||
theme: &GalaxyTheme,
|
||||
) -> Box<dyn Element> {
|
||||
let text_color = theme.foreground().into();
|
||||
Container::new(render_text_with_markdown_support(
|
||||
@@ -1357,7 +1357,7 @@ impl AskUserQuestionView {
|
||||
&self,
|
||||
question_text: &str,
|
||||
appearance: &Appearance,
|
||||
theme: &WarpTheme,
|
||||
theme: &GalaxyTheme,
|
||||
) -> Box<dyn Element> {
|
||||
let body = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
@@ -1385,7 +1385,7 @@ impl AskUserQuestionView {
|
||||
fn render_nav_footer(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
theme: &WarpTheme,
|
||||
theme: &GalaxyTheme,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let counter = format!(
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) mod requested_command_attribution;
|
||||
pub(crate) mod requested_script;
|
||||
pub(super) mod search_codebase;
|
||||
pub(crate) mod search_results_common;
|
||||
pub(super) mod summarization;
|
||||
pub(crate) mod suggested_unit_tests;
|
||||
pub(super) mod web_fetch;
|
||||
pub(super) mod web_search;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::elements::shimmering_text::{ShimmerConfig, ShimmeringTextElement, ShimmeringTextStateHandle};
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex,
|
||||
MainAxisAlignment, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
|
||||
use galaxyui::{AppContext, Entity, SingletonEntity, View, ViewContext};
|
||||
use instant::Instant;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::inline_action_header::{
|
||||
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
|
||||
};
|
||||
use super::inline_action_icons::icon_size;
|
||||
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
pub enum SummarizationViewEvent {}
|
||||
|
||||
pub struct SummarizationView {
|
||||
pub is_finished: bool,
|
||||
shimmering_text_handle: ShimmeringTextStateHandle,
|
||||
start_time: Instant,
|
||||
timer_handle: Option<SpawnedFutureHandle>,
|
||||
}
|
||||
|
||||
impl SummarizationView {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let mut view = Self {
|
||||
is_finished: false,
|
||||
shimmering_text_handle: ShimmeringTextStateHandle::default(),
|
||||
start_time: Instant::now(),
|
||||
timer_handle: None,
|
||||
};
|
||||
view.start_timer(ctx);
|
||||
view
|
||||
}
|
||||
|
||||
pub fn mark_finished(&mut self) {
|
||||
self.is_finished = true;
|
||||
if let Some(handle) = self.timer_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn start_timer(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.timer_handle.is_some() {
|
||||
return;
|
||||
}
|
||||
let handle = ctx.spawn(
|
||||
async move {
|
||||
Timer::after(Duration::from_secs(1)).await;
|
||||
},
|
||||
|me, _unit, ctx| {
|
||||
me.timer_handle = None;
|
||||
if !me.is_finished {
|
||||
ctx.notify();
|
||||
me.start_timer(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
self.timer_handle = Some(handle);
|
||||
}
|
||||
|
||||
fn render_in_progress(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let header_background = theme.surface_2();
|
||||
|
||||
let mut header_row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::Start)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
// Clock loader icon (magenta, matches InProgress convention)
|
||||
let icon_element = galaxyui::elements::Icon::new(
|
||||
Icon::ClockLoader.into(),
|
||||
theme.ansi_fg_magenta(),
|
||||
)
|
||||
.finish();
|
||||
let icon_box = ConstrainedBox::new(icon_element)
|
||||
.with_width(icon_size(app))
|
||||
.with_height(icon_size(app))
|
||||
.finish();
|
||||
header_row.add_child(
|
||||
Container::new(icon_box)
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Shimmering "Summarizing conversation..." text
|
||||
let base_color = theme.disabled_text_color(header_background).into_solid();
|
||||
let shimmer_color = theme.main_text_color(header_background).into_solid();
|
||||
let shimmer_element = ShimmeringTextElement::new(
|
||||
"Summarizing conversation...".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
base_color,
|
||||
shimmer_color,
|
||||
ShimmerConfig::default(),
|
||||
self.shimmering_text_handle.clone(),
|
||||
)
|
||||
.finish();
|
||||
header_row.add_child(Shrinkable::new(1.0, shimmer_element).finish());
|
||||
|
||||
// Elapsed time suffix
|
||||
let elapsed = self.start_time.elapsed();
|
||||
let elapsed_text = format_elapsed(elapsed);
|
||||
let suffix = Text::new_inline(
|
||||
format!(" \u{2022} {elapsed_text}"),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(theme.disabled_text_color(header_background).into())
|
||||
.finish();
|
||||
header_row.add_child(suffix);
|
||||
|
||||
Container::new(header_row.finish())
|
||||
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
|
||||
.with_background(header_background)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_finished(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let header_background = theme.surface_2();
|
||||
|
||||
let mut header_row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::Start)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
// Checkmark-style icon for completed
|
||||
let icon_element = galaxyui::elements::Icon::new(
|
||||
Icon::Check.into(),
|
||||
theme.ansi_fg_green(),
|
||||
)
|
||||
.finish();
|
||||
let icon_box = ConstrainedBox::new(icon_element)
|
||||
.with_width(icon_size(app))
|
||||
.with_height(icon_size(app))
|
||||
.finish();
|
||||
header_row.add_child(
|
||||
Container::new(icon_box)
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let elapsed = self.start_time.elapsed();
|
||||
let elapsed_text = format_elapsed(elapsed);
|
||||
let title = Text::new_inline(
|
||||
format!("Conversation summarized \u{2022} {elapsed_text}"),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(theme.main_text_color(header_background).into())
|
||||
.finish();
|
||||
header_row.add_child(Shrinkable::new(1.0, title).finish());
|
||||
|
||||
Container::new(header_row.finish())
|
||||
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
|
||||
.with_background(header_background)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SummarizationView {
|
||||
type Event = SummarizationViewEvent;
|
||||
}
|
||||
|
||||
impl View for SummarizationView {
|
||||
fn ui_name() -> &'static str {
|
||||
"SummarizationView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let element = if self.is_finished {
|
||||
self.render_finished(app)
|
||||
} else {
|
||||
self.render_in_progress(app)
|
||||
};
|
||||
element.with_agent_output_item_spacing(app).finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_elapsed(duration: Duration) -> String {
|
||||
let secs = duration.as_secs();
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
} else {
|
||||
format!("{}m {}s", secs / 60, secs % 60)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ use warp_multi_agent_api as api;
|
||||
|
||||
const MAX_RETRY_ATTEMPTS: i32 = 3;
|
||||
const MAX_PENDING_LIFECYCLE_EVENTS_PER_TARGET: usize = 200;
|
||||
pub const MAX_SUBAGENT_RETRIES: u8 = 3;
|
||||
const MAX_SUBAGENT_QUESTION_DEPTH: u8 = 3;
|
||||
|
||||
/// Stage associated with a lifecycle error detail.
|
||||
/// This keeps persisted/runtime metadata consistent across API payloads and DB rows.
|
||||
@@ -64,6 +66,23 @@ pub enum PendingEventDetail {
|
||||
Lifecycle {
|
||||
event: api::AgentEvent,
|
||||
},
|
||||
/// A subagent is asking its parent a question (routed from AskUserQuestion).
|
||||
SubagentQuestion {
|
||||
source_conversation_id: AIConversationId,
|
||||
question_text: String,
|
||||
options: Vec<String>,
|
||||
depth: u8,
|
||||
},
|
||||
/// The parent's answer to a subagent's question.
|
||||
SubagentAnswer {
|
||||
target_conversation_id: AIConversationId,
|
||||
answer_text: String,
|
||||
},
|
||||
/// A subagent reporting its completion summary to the parent.
|
||||
SubagentCompletionSummary {
|
||||
source_conversation_id: AIConversationId,
|
||||
summary_text: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A queued event consumed by the controller.
|
||||
@@ -930,29 +949,49 @@ impl OrchestrationEventService {
|
||||
|
||||
let mut messages = Vec::new();
|
||||
let mut lifecycle_events = Vec::new();
|
||||
for event in &deliverable {
|
||||
let mut server_bound_events = Vec::new();
|
||||
for event in deliverable {
|
||||
match &event.detail {
|
||||
PendingEventDetail::Message {
|
||||
message_id,
|
||||
addresses,
|
||||
subject,
|
||||
message_body,
|
||||
} => messages.push(ReceivedMessageInput {
|
||||
message_id: message_id.clone(),
|
||||
sender_agent_id: event.source_agent_id.clone(),
|
||||
addresses: addresses.clone(),
|
||||
subject: subject.clone(),
|
||||
message_body: message_body.clone(),
|
||||
}),
|
||||
PendingEventDetail::Lifecycle { event } => lifecycle_events.push(event.clone()),
|
||||
} => {
|
||||
messages.push(ReceivedMessageInput {
|
||||
message_id: message_id.clone(),
|
||||
sender_agent_id: event.source_agent_id.clone(),
|
||||
addresses: addresses.clone(),
|
||||
subject: subject.clone(),
|
||||
message_body: message_body.clone(),
|
||||
});
|
||||
server_bound_events.push(event);
|
||||
}
|
||||
PendingEventDetail::Lifecycle { event: _ } => {
|
||||
lifecycle_events.push(
|
||||
if let PendingEventDetail::Lifecycle { event: e } = &event.detail {
|
||||
e.clone()
|
||||
} else {
|
||||
unreachable!()
|
||||
},
|
||||
);
|
||||
server_bound_events.push(event);
|
||||
}
|
||||
// Local-only subagent events are consumed directly by the controller,
|
||||
// not converted to AIAgentInput or awaited for server echo.
|
||||
PendingEventDetail::SubagentQuestion { .. }
|
||||
| PendingEventDetail::SubagentAnswer { .. }
|
||||
| PendingEventDetail::SubagentCompletionSummary { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Move to awaiting echo for delivery confirmation.
|
||||
self.awaiting_server_echo_events
|
||||
.entry(conversation_id)
|
||||
.or_default()
|
||||
.extend(deliverable);
|
||||
// Only server-bound events need echo confirmation.
|
||||
if !server_bound_events.is_empty() {
|
||||
self.awaiting_server_echo_events
|
||||
.entry(conversation_id)
|
||||
.or_default()
|
||||
.extend(server_bound_events);
|
||||
}
|
||||
|
||||
let mut inputs = Vec::new();
|
||||
if !messages.is_empty() {
|
||||
@@ -966,6 +1005,34 @@ impl OrchestrationEventService {
|
||||
inputs
|
||||
}
|
||||
|
||||
/// Drain only the local subagent events (Question/Answer/Summary) for a conversation.
|
||||
/// These are not sent to the server and are consumed directly by the controller.
|
||||
pub fn drain_subagent_events(
|
||||
&mut self,
|
||||
conversation_id: &AIConversationId,
|
||||
) -> Vec<PendingEvent> {
|
||||
let Some(pending) = self.pending_events.get_mut(conversation_id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut subagent_events = Vec::new();
|
||||
pending.retain(|event| match &event.detail {
|
||||
PendingEventDetail::SubagentQuestion { .. }
|
||||
| PendingEventDetail::SubagentAnswer { .. }
|
||||
| PendingEventDetail::SubagentCompletionSummary { .. } => {
|
||||
subagent_events.push(event.clone());
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
|
||||
if pending.is_empty() {
|
||||
self.pending_events.remove(conversation_id);
|
||||
}
|
||||
|
||||
subagent_events
|
||||
}
|
||||
|
||||
/// Moves all awaiting events back to pending for retry after a failed
|
||||
/// send attempt. Increments attempt counts and drops events that have
|
||||
/// exhausted their retry limit.
|
||||
@@ -1109,6 +1176,101 @@ impl OrchestrationEventService {
|
||||
self.awaiting_server_echo_events.remove(&conversation_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Route a subagent's AskUserQuestion to the parent conversation for silent auto-answer.
|
||||
pub fn route_subagent_question_to_parent(
|
||||
&mut self,
|
||||
child_conversation_id: AIConversationId,
|
||||
parent_conversation_id: AIConversationId,
|
||||
question_text: String,
|
||||
options: Vec<String>,
|
||||
depth: u8,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if depth >= MAX_SUBAGENT_QUESTION_DEPTH {
|
||||
log::warn!(
|
||||
"Subagent question depth limit reached for conversation {:?}",
|
||||
child_conversation_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let event = PendingEvent {
|
||||
event_id: Uuid::new_v4().to_string(),
|
||||
source_agent_id: child_conversation_id.to_string(),
|
||||
attempt_count: 0,
|
||||
detail: PendingEventDetail::SubagentQuestion {
|
||||
source_conversation_id: child_conversation_id,
|
||||
question_text,
|
||||
options,
|
||||
depth,
|
||||
},
|
||||
};
|
||||
|
||||
self.pending_events
|
||||
.entry(parent_conversation_id)
|
||||
.or_default()
|
||||
.push(event);
|
||||
|
||||
ctx.emit(OrchestrationEventServiceEvent::EventsReady {
|
||||
conversation_id: parent_conversation_id,
|
||||
});
|
||||
}
|
||||
|
||||
/// Route the parent's answer back to the child subagent.
|
||||
pub fn route_answer_to_subagent(
|
||||
&mut self,
|
||||
child_conversation_id: AIConversationId,
|
||||
answer_text: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let event = PendingEvent {
|
||||
event_id: Uuid::new_v4().to_string(),
|
||||
source_agent_id: "parent".to_string(),
|
||||
attempt_count: 0,
|
||||
detail: PendingEventDetail::SubagentAnswer {
|
||||
target_conversation_id: child_conversation_id,
|
||||
answer_text,
|
||||
},
|
||||
};
|
||||
|
||||
self.pending_events
|
||||
.entry(child_conversation_id)
|
||||
.or_default()
|
||||
.push(event);
|
||||
|
||||
ctx.emit(OrchestrationEventServiceEvent::EventsReady {
|
||||
conversation_id: child_conversation_id,
|
||||
});
|
||||
}
|
||||
|
||||
/// Route a subagent's completion summary to the parent conversation.
|
||||
pub fn route_subagent_completion_summary(
|
||||
&mut self,
|
||||
child_conversation_id: AIConversationId,
|
||||
parent_conversation_id: AIConversationId,
|
||||
summary_text: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let event = PendingEvent {
|
||||
event_id: Uuid::new_v4().to_string(),
|
||||
source_agent_id: child_conversation_id.to_string(),
|
||||
attempt_count: 0,
|
||||
detail: PendingEventDetail::SubagentCompletionSummary {
|
||||
source_conversation_id: child_conversation_id,
|
||||
summary_text,
|
||||
},
|
||||
};
|
||||
|
||||
self.pending_events
|
||||
.entry(parent_conversation_id)
|
||||
.or_default()
|
||||
.push(event);
|
||||
|
||||
ctx.emit(OrchestrationEventServiceEvent::EventsReady {
|
||||
conversation_id: parent_conversation_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `None` means \"subscribe to all lifecycle types\" (input omitted).
|
||||
@@ -1135,6 +1297,10 @@ fn did_event_round_trip_through_server(
|
||||
PendingEventDetail::Lifecycle { event } => {
|
||||
echoed_lifecycle_event_ids.contains(event.event_id.as_str())
|
||||
}
|
||||
// Local-only events never round-trip through the server.
|
||||
PendingEventDetail::SubagentQuestion { .. }
|
||||
| PendingEventDetail::SubagentAnswer { .. }
|
||||
| PendingEventDetail::SubagentCompletionSummary { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,11 +33,14 @@ pub struct ConversationUsageInfo {
|
||||
pub lines_added: i32,
|
||||
pub lines_removed: i32,
|
||||
pub commands_executed: i32,
|
||||
pub total_input_tokens: u32,
|
||||
pub total_output_tokens: u32,
|
||||
pub total_cache_read_tokens: u32,
|
||||
pub total_cache_write_tokens: u32,
|
||||
/// Live context window token count (from most recent Bedrock response).
|
||||
pub current_context_tokens: u32,
|
||||
/// Cumulative cost across all requests.
|
||||
pub estimated_cost_cents: f32,
|
||||
/// Cumulative cache read tokens (session total).
|
||||
pub total_cache_read_tokens: u32,
|
||||
/// Cumulative cache write tokens (session total).
|
||||
pub total_cache_write_tokens: u32,
|
||||
}
|
||||
|
||||
/// Timing information for the last set of agent responses
|
||||
@@ -246,32 +249,21 @@ impl ConversationUsageView {
|
||||
);
|
||||
}
|
||||
|
||||
// Token usage section
|
||||
let total_tokens = self.usage_info.total_input_tokens
|
||||
+ self.usage_info.total_output_tokens
|
||||
+ self.usage_info.total_cache_read_tokens
|
||||
// Context tokens (live state — current context window size)
|
||||
if self.usage_info.current_context_tokens > 0 {
|
||||
labels.push(render_label_text("Context tokens", appearance));
|
||||
values.push(render_value_text(
|
||||
format_token_count(self.usage_info.current_context_tokens),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
// Cache usage (cumulative session totals)
|
||||
let total_cache = self.usage_info.total_cache_read_tokens
|
||||
+ self.usage_info.total_cache_write_tokens;
|
||||
if total_tokens > 0 {
|
||||
labels.push(render_label_text("Total tokens", appearance));
|
||||
values.push(render_value_text(
|
||||
format_token_count(total_tokens),
|
||||
appearance,
|
||||
));
|
||||
|
||||
labels.push(render_label_text(" Input", appearance));
|
||||
values.push(render_value_text(
|
||||
format_token_count(self.usage_info.total_input_tokens),
|
||||
appearance,
|
||||
));
|
||||
|
||||
labels.push(render_label_text(" Output", appearance));
|
||||
values.push(render_value_text(
|
||||
format_token_count(self.usage_info.total_output_tokens),
|
||||
appearance,
|
||||
));
|
||||
|
||||
if total_cache > 0 {
|
||||
if self.usage_info.total_cache_read_tokens > 0 {
|
||||
labels.push(render_label_text(" Cache read", appearance));
|
||||
labels.push(render_label_text("Cache read", appearance));
|
||||
values.push(render_value_text(
|
||||
format_token_count(self.usage_info.total_cache_read_tokens),
|
||||
appearance,
|
||||
@@ -279,12 +271,25 @@ impl ConversationUsageView {
|
||||
}
|
||||
|
||||
if self.usage_info.total_cache_write_tokens > 0 {
|
||||
labels.push(render_label_text(" Cache write", appearance));
|
||||
labels.push(render_label_text("Cache write", appearance));
|
||||
values.push(render_value_text(
|
||||
format_token_count(self.usage_info.total_cache_write_tokens),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
// Cache hit rate
|
||||
let cache_miss = self.usage_info.current_context_tokens
|
||||
.saturating_sub(self.usage_info.total_cache_read_tokens);
|
||||
let total_input = self.usage_info.total_cache_read_tokens + cache_miss;
|
||||
if total_input > 0 {
|
||||
let hit_rate = (self.usage_info.total_cache_read_tokens as f32 / total_input as f32) * 100.0;
|
||||
labels.push(render_label_text("Cache hit rate", appearance));
|
||||
values.push(render_value_text(
|
||||
format!("{:.0}%", hit_rate),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
labels.push(render_label_text("Context window used", appearance));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use galaxy_core::ui::theme::{Fill, WarpTheme};
|
||||
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxyui::Element;
|
||||
|
||||
@@ -33,7 +33,7 @@ pub fn icon_for_context_window_usage(context_window_usage: f32) -> Icon {
|
||||
|
||||
pub fn render_context_window_usage_icon(
|
||||
context_window_usage: f32,
|
||||
theme: &WarpTheme,
|
||||
theme: &GalaxyTheme,
|
||||
color_override: Option<Fill>,
|
||||
) -> Box<dyn Element> {
|
||||
let icon = icon_for_context_window_usage(context_window_usage);
|
||||
|
||||
@@ -19,7 +19,7 @@ use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::{
|
||||
themes::theme::{AnsiColorIdentifier, Fill, WarpTheme},
|
||||
themes::theme::{AnsiColorIdentifier, Fill, GalaxyTheme},
|
||||
ui_components::icons::Icon,
|
||||
};
|
||||
|
||||
@@ -52,7 +52,7 @@ pub const CLAUDE_ORANGE: ColorU = ColorU {
|
||||
|
||||
/// Returns the color to be used for various AI signifiers
|
||||
/// input with AI mode).
|
||||
pub fn ai_brand_color(theme: &WarpTheme) -> ColorU {
|
||||
pub fn ai_brand_color(theme: &GalaxyTheme) -> ColorU {
|
||||
AnsiColorIdentifier::Magenta
|
||||
.to_ansi_color(&theme.terminal_colors().normal)
|
||||
.into()
|
||||
@@ -60,7 +60,7 @@ pub fn ai_brand_color(theme: &WarpTheme) -> ColorU {
|
||||
|
||||
/// Returns the color to be used for error UI throughout Agent Mode (like the "request limit
|
||||
/// exceeded" chip).
|
||||
pub fn error_color(theme: &WarpTheme) -> ColorU {
|
||||
pub fn error_color(theme: &GalaxyTheme) -> ColorU {
|
||||
AnsiColorIdentifier::Red
|
||||
.to_ansi_color(&theme.terminal_colors().normal)
|
||||
.into()
|
||||
|
||||
Reference in New Issue
Block a user