From ced40c56a3e6298da9447f1ec5b4c2ab9a04c481 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sat, 16 May 2026 11:34:27 +0800 Subject: [PATCH 01/76] feat(cli): add mode-aware skill management UI --- src/apps/cli/src/commands.rs | 4 +- src/apps/cli/src/modes/chat.rs | 203 ++++++++++++++++++++++-- src/apps/cli/src/ui/chat/mouse.rs | 9 +- src/apps/cli/src/ui/chat/popups.rs | 16 +- src/apps/cli/src/ui/chat/state.rs | 5 +- src/apps/cli/src/ui/skill_selector.rs | 216 +++++++++++++++++++++----- src/apps/cli/src/ui/startup.rs | 161 +++++++++++++++++-- 7 files changed, 533 insertions(+), 81 deletions(-) diff --git a/src/apps/cli/src/commands.rs b/src/apps/cli/src/commands.rs index 9ff936d7c8..b80ceb0a74 100644 --- a/src/apps/cli/src/commands.rs +++ b/src/apps/cli/src/commands.rs @@ -42,7 +42,7 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ }, CommandSpec { name: "/skills", - description: "Browse and execute skills", + description: "List and configure skills", }, CommandSpec { name: "/subagents", @@ -94,7 +94,7 @@ pub const STARTUP_COMMAND_SPECS: &[CommandSpec] = &[ }, CommandSpec { name: "/skills", - description: "Browse and execute skills", + description: "List and configure skills", }, CommandSpec { name: "/subagents", diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 65ec3b1b22..ec90e32532 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -30,7 +30,7 @@ use crate::ui::permission::PermissionAction; use crate::ui::provider_selector::ProviderSelection; use crate::ui::question::QuestionAction; use crate::ui::session_selector::{SessionAction, SessionItem}; -use crate::ui::skill_selector::SkillItem; +use crate::ui::skill_selector::{SkillItem, SkillSelectorAction}; use crate::ui::subagent_selector::SubagentItem; use crate::ui::theme::{ builtin_theme_ids, builtin_theme_json, resolve_appearance, resolve_effective_color_scheme, @@ -39,7 +39,14 @@ use crate::ui::theme::{ use crate::ui::theme_selector::ThemeItem; use crate::ui::{init_terminal, restore_terminal}; use bitfun_core::agentic::agents::{get_agent_registry, AgentInfo}; -use bitfun_core::agentic::tools::implementations::skills::registry::SkillRegistry; +use bitfun_core::agentic::tools::implementations::skills::{ + mode_overrides::{ + load_project_mode_skills_document_local, save_project_mode_skills_document_local, + set_mode_skill_disabled_in_document, set_user_mode_skill_state, + }, + registry::SkillRegistry, + ModeSkillInfo, SkillInfo, +}; use bitfun_core::service::config::GlobalConfigManager; fn event_subagent_parent_info( @@ -1068,10 +1075,11 @@ impl ChatMode { match key.code { KeyCode::Up => chat_view.skill_selector_up(), KeyCode::Down => chat_view.skill_selector_down(), - KeyCode::Enter => { - if let Some(selected) = chat_view.skill_selector_confirm() { - chat_view.hide_skill_selector(); - self.apply_skill_selection(&selected, chat_view); + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(action) = chat_view.skill_selector_confirm() { + self.handle_skill_selector_action( + action, chat_view, chat_state, rt_handle, + ); } } // Note: Esc is handled globally for navigation back @@ -1482,7 +1490,13 @@ impl ChatMode { if let Some(selection) = chat_view.provider_selector_handle_mouse(&mouse) { this.handle_provider_selection(selection, chat_view); } - } else if !chat_view.handle_mouse_event(&mouse) { + } else if chat_view.handle_mouse_event(&mouse) { + if let Some(action) = chat_view.take_pending_skill_action() { + this.handle_skill_selector_action( + action, chat_view, chat_state, rt_handle, + ); + } + } else { match mouse.kind { MouseEventKind::ScrollUp => { let total = chat_view.count_message_lines(chat_state); @@ -2793,33 +2807,77 @@ impl ChatMode { Ok(()) } - /// Show skill selector popup with all available skills + /// Show skill list/configuration menu. fn show_skill_selector( + &self, + chat_view: &mut ChatView, + _chat_state: &mut ChatState, + _rt_handle: &tokio::runtime::Handle, + ) { + chat_view.show_skill_menu(); + } + + fn show_available_skill_list( &self, chat_view: &mut ChatView, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { let skills = tokio::task::block_in_place(|| { + let workspace = self.agent.workspace_path_buf(); + let agent_type = self.agent_type.clone(); rt_handle.block_on(async { let registry = SkillRegistry::global(); - registry.refresh().await; - registry.get_all_skills().await + registry + .get_resolved_skills_for_workspace( + Some(workspace.as_path()), + Some(&agent_type), + ) + .await }) }); if skills.is_empty() { - chat_state.add_system_message("No skills found. Add skills in .bitfun/skills/, .cursor/skills/, or ~/.cursor/skills/".to_string()); + chat_state.add_system_message(format!( + "No enabled skills found for agent mode '{}'. Add skills in .bitfun/skills/, .cursor/skills/, or ~/.cursor/skills/, or enable built-in skills for this mode.", + self.agent_type + )); return; } let skill_items: Vec = skills .into_iter() - .map(|s| SkillItem { - name: s.name, - description: s.description, - level: s.level.as_str().to_string(), + .map(Self::skill_item_from_info) + .collect(); + + if skill_items.is_empty() { + chat_state.add_system_message("No skills found.".to_string()); + return; + } + + chat_view.show_skill_list(skill_items); + } + + fn show_skill_config_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let skills = tokio::task::block_in_place(|| { + let workspace = self.agent.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + rt_handle.block_on(async { + let registry = SkillRegistry::global(); + registry + .get_mode_skill_infos_for_workspace(Some(workspace.as_path()), &agent_type) + .await }) + }); + + let skill_items: Vec = skills + .into_iter() + .map(Self::skill_item_from_mode_info) .collect(); if skill_items.is_empty() { @@ -2827,7 +2885,32 @@ impl ChatMode { return; } - chat_view.show_skill_selector(skill_items); + chat_view.show_skill_config(skill_items); + } + + fn handle_skill_selector_action( + &self, + action: SkillSelectorAction, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + match action { + SkillSelectorAction::ListSkills => { + self.show_available_skill_list(chat_view, chat_state, rt_handle); + } + SkillSelectorAction::ConfigureSkills => { + self.show_skill_config_selector(chat_view, chat_state, rt_handle); + } + SkillSelectorAction::Execute(selected) => { + chat_view.hide_skill_selector(); + self.apply_skill_selection(&selected, chat_view); + } + SkillSelectorAction::Toggle(selected) => { + self.set_skill_enabled(&selected, !selected.enabled, chat_state, rt_handle); + self.show_skill_config_selector(chat_view, chat_state, rt_handle); + } + } } /// Apply skill selection: fill input box with execution command @@ -2835,6 +2918,94 @@ impl ChatMode { chat_view.set_input(&format!("Execute the {} skill.", selected.name)); } + fn set_skill_enabled( + &self, + selected: &SkillItem, + enabled: bool, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let workspace = self.agent.workspace_path_buf(); + let mode_id = self.agent_type.clone(); + let skill = selected.clone(); + + let result: Result<(), String> = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + match skill.level.as_str() { + "user" => { + set_user_mode_skill_state( + &mode_id, + &skill.key, + enabled, + skill.default_enabled, + ) + .await + .map_err(|error| error.to_string())?; + } + "project" => { + let mut document = load_project_mode_skills_document_local(&workspace) + .await + .map_err(|error| error.to_string())?; + set_mode_skill_disabled_in_document( + &mut document, + &mode_id, + &skill.key, + !enabled, + ) + .map_err(|error| error.to_string())?; + save_project_mode_skills_document_local(&workspace, &document) + .await + .map_err(|error| error.to_string())?; + } + other => { + return Err(format!("Unsupported skill level '{}'", other)); + } + } + + Ok(()) + }) + }); + + match result { + Ok(()) => chat_state.add_system_message(format!( + "Skill '{}' {} for mode '{}'.", + selected.name, + if enabled { "enabled" } else { "disabled" }, + self.agent_type + )), + Err(error) => chat_state.add_system_message(format!( + "Failed to update skill '{}': {}", + selected.name, error + )), + } + } + + fn skill_item_from_info(info: SkillInfo) -> SkillItem { + SkillItem { + key: info.key, + name: info.name, + description: info.description, + level: info.level.as_str().to_string(), + enabled: true, + selected_for_runtime: true, + default_enabled: true, + is_shadowed: info.is_shadowed, + } + } + + fn skill_item_from_mode_info(info: ModeSkillInfo) -> SkillItem { + SkillItem { + key: info.skill.key, + name: info.skill.name, + description: info.skill.description, + level: info.skill.level.as_str().to_string(), + enabled: info.effective_enabled, + selected_for_runtime: info.selected_for_runtime, + default_enabled: info.default_enabled, + is_shadowed: info.skill.is_shadowed, + } + } + /// Show subagent selector popup with all available subagents fn show_subagent_selector( &self, diff --git a/src/apps/cli/src/ui/chat/mouse.rs b/src/apps/cli/src/ui/chat/mouse.rs index 0f2163c8cf..ac30d3e829 100644 --- a/src/apps/cli/src/ui/chat/mouse.rs +++ b/src/apps/cli/src/ui/chat/mouse.rs @@ -15,6 +15,10 @@ impl ChatView { self.pending_theme_preview.take() } + pub fn take_pending_skill_action(&mut self) -> Option { + self.pending_skill_action.take() + } + pub fn handle_mouse_event(&mut self, mouse: &crossterm::event::MouseEvent) -> bool { // Popups take priority when visible if self.model_selector.captures_mouse(mouse) { @@ -35,9 +39,8 @@ impl ChatView { return true; } if self.skill_selector.captures_mouse(mouse) { - if let Some(selected) = self.skill_selector.handle_mouse_event(mouse) { - self.skill_selector.hide(); - self.set_input(&format!("Execute the {} skill.", selected.name)); + if let Some(action) = self.skill_selector.handle_mouse_event(mouse) { + self.pending_skill_action = Some(action); } return true; } diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index ec885ebd7e..5251e72ee9 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -170,8 +170,18 @@ impl ChatView { // ============ Skill selector methods ============ - pub fn show_skill_selector(&mut self, skills: Vec) { - self.skill_selector.show(skills); + pub fn show_skill_menu(&mut self) { + self.skill_selector.show_menu(); + self.popup_stack.push(PopupType::SkillSelector); + } + + pub fn show_skill_list(&mut self, skills: Vec) { + self.skill_selector.show_list(skills); + self.popup_stack.push(PopupType::SkillSelector); + } + + pub fn show_skill_config(&mut self, skills: Vec) { + self.skill_selector.show_config(skills); self.popup_stack.push(PopupType::SkillSelector); } @@ -195,7 +205,7 @@ impl ChatView { self.skill_selector.move_down(); } - pub fn skill_selector_confirm(&self) -> Option { + pub fn skill_selector_confirm(&self) -> Option { self.skill_selector.confirm_selection() } diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index 869ba459e3..8c02120bba 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -21,7 +21,7 @@ use super::permission::render_permission_overlay; use super::provider_selector::{ProviderSelection, ProviderSelectorState}; use super::question::render_question_overlay; use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; -use super::skill_selector::{SkillItem, SkillSelectorState}; +use super::skill_selector::{SkillItem, SkillSelectorAction, SkillSelectorState}; use super::subagent_selector::{SubagentItem, SubagentSelectorState}; use super::theme::{Theme, StyleKind}; use super::theme_selector::{ThemeItem, ThemeSelectorState}; @@ -205,6 +205,8 @@ pub struct ChatView { /// Pending MCP toggle from mouse click (consumed by caller) pending_mcp_toggle: Option, + /// Pending skill selector action from mouse click (consumed by caller) + pending_skill_action: Option, /// Info popup message (rendered as overlay, dismissed by any key) info_popup: Option, @@ -280,6 +282,7 @@ impl ChatView { theme_selector: ThemeSelectorState::new(), pending_command: None, pending_mcp_toggle: None, + pending_skill_action: None, pending_theme_preview: None, theme_preview_original: None, info_popup: None, diff --git a/src/apps/cli/src/ui/skill_selector.rs b/src/apps/cli/src/ui/skill_selector.rs index f25b507b1f..a82756dd2e 100644 --- a/src/apps/cli/src/ui/skill_selector.rs +++ b/src/apps/cli/src/ui/skill_selector.rs @@ -13,12 +13,32 @@ use ratatui::{ use crate::ui::theme::{StyleKind, Theme}; -/// A skill item for display in the selector +/// A skill item for display in the selector. #[derive(Debug, Clone)] pub struct SkillItem { + pub key: String, pub name: String, pub description: String, pub level: String, // "project" or "user" + pub enabled: bool, + pub selected_for_runtime: bool, + pub default_enabled: bool, + pub is_shadowed: bool, +} + +#[derive(Debug, Clone)] +pub enum SkillSelectorAction { + ListSkills, + ConfigureSkills, + Execute(SkillItem), + Toggle(SkillItem), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SkillSelectorScreen { + Menu, + List, + Configure, } /// Skill selector popup state @@ -27,6 +47,7 @@ pub struct SkillSelectorState { list_state: ListState, visible: bool, last_area: Option, + screen: SkillSelectorScreen, } impl SkillSelectorState { @@ -36,20 +57,54 @@ impl SkillSelectorState { list_state: ListState::default(), visible: false, last_area: None, + screen: SkillSelectorScreen::Menu, } } - /// Show the skill selector with given skill list - pub fn show(&mut self, skills: Vec) { + pub fn show_menu(&mut self) { + self.items.clear(); + self.screen = SkillSelectorScreen::Menu; + self.list_state.select(Some(0)); + self.visible = true; + } + + /// Show the current mode's runtime-visible skills. + pub fn show_list(&mut self, skills: Vec) { if skills.is_empty() { return; } self.items = skills; + self.screen = SkillSelectorScreen::List; self.list_state.select(Some(0)); self.visible = true; } + /// Show all discovered skills with mode-specific enablement checkboxes. + pub fn show_config(&mut self, skills: Vec) { + if skills.is_empty() { + return; + } + + let selected_key = if self.screen == SkillSelectorScreen::Configure { + self.list_state + .selected() + .and_then(|index| self.items.get(index)) + .map(|item| item.key.clone()) + } else { + None + }; + let selected_index = self.list_state.selected().unwrap_or(0); + let next_index = selected_key + .and_then(|key| skills.iter().position(|item| item.key == key)) + .unwrap_or_else(|| selected_index.min(skills.len().saturating_sub(1))); + + self.items = skills; + self.screen = SkillSelectorScreen::Configure; + self.list_state.select(Some(next_index)); + self.visible = true; + } + pub fn hide(&mut self) { self.visible = false; // Note: we don't clear items here to support back navigation @@ -58,7 +113,7 @@ impl SkillSelectorState { /// Reshow the skill selector (for back navigation) pub fn reshow(&mut self) { - if !self.items.is_empty() { + if self.screen == SkillSelectorScreen::Menu || !self.items.is_empty() { self.visible = true; } } @@ -68,7 +123,7 @@ impl SkillSelectorState { } pub fn move_up(&mut self) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { return; } let selected = self.list_state.selected().unwrap_or(0); @@ -77,32 +132,55 @@ impl SkillSelectorState { } pub fn move_down(&mut self) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { return; } let selected = self.list_state.selected().unwrap_or(0); - let next = (selected + 1).min(self.items.len().saturating_sub(1)); + let next = (selected + 1).min(self.len().saturating_sub(1)); self.list_state.select(Some(next)); } - /// Get the selected skill item - pub fn confirm_selection(&self) -> Option { + /// Get the selected action. + pub fn confirm_selection(&self) -> Option { if !self.visible { return None; } let idx = self.list_state.selected()?; - self.items.get(idx).cloned() + match self.screen { + SkillSelectorScreen::Menu => match idx { + 0 => Some(SkillSelectorAction::ListSkills), + 1 => Some(SkillSelectorAction::ConfigureSkills), + _ => None, + }, + SkillSelectorScreen::List => self + .items + .get(idx) + .cloned() + .map(SkillSelectorAction::Execute), + SkillSelectorScreen::Configure => self + .items + .get(idx) + .cloned() + .map(SkillSelectorAction::Toggle), + } + } + + fn len(&self) -> usize { + match self.screen { + SkillSelectorScreen::Menu => 2, + SkillSelectorScreen::List | SkillSelectorScreen::Configure => self.items.len(), + } } /// Render the skill selector popup as an overlay pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { self.last_area = None; return; } - let popup_width = area.width.saturating_sub(4).min(70); - let popup_height = (self.items.len() as u16 + 4).min(area.height.saturating_sub(2)); + let popup_width = area.width.saturating_sub(4).min(92); + let popup_height = (self.len() as u16 + 4).min(area.height.saturating_sub(2)); if popup_height < 5 || popup_width < 20 { self.last_area = None; return; @@ -119,38 +197,18 @@ impl SkillSelectorState { }; self.last_area = Some(popup_area); - let list_items: Vec = self - .items - .iter() - .map(|skill| { - let level_marker = match skill.level.as_str() { - "project" => "P", - "user" => "U", - _ => "?", - }; - let level_style = match skill.level.as_str() { - "project" => theme.style(StyleKind::Info), - _ => theme.style(StyleKind::Muted), - }; - - let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); - let desc_style = theme.style(StyleKind::Muted); - - let line = Line::from(vec![ - Span::styled(format!("[{}] ", level_marker), level_style), - Span::styled(&skill.name, name_style), - Span::raw(" "), - Span::styled(&skill.description, desc_style), - ]); - ListItem::new(line) - }) - .collect(); + let list_items = self.render_items(theme); + let title = match self.screen { + SkillSelectorScreen::Menu => " Skills ", + SkillSelectorScreen::List => " List Skills (current mode) ", + SkillSelectorScreen::Configure => " Enable/Disable Skills (Space/Enter Toggle) ", + }; let block = Block::default() .borders(Borders::ALL) .border_style(theme.style(StyleKind::Primary)) .style(Style::default().bg(theme.background)) - .title(" Select Skill (↑↓ Navigate, Enter Select, Esc Cancel) "); + .title(title); let list = List::new(list_items) .block(block) @@ -167,7 +225,7 @@ impl SkillSelectorState { } /// Handle mouse events - pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { if !self.visible { return None; } @@ -229,10 +287,84 @@ impl SkillSelectorState { let offset = self.list_state.offset(); let index = (row - inner_y) as usize + offset; - if index >= self.items.len() { + if index >= self.len() { return None; } Some(index) } + + fn render_items(&self, theme: &Theme) -> Vec> { + match self.screen { + SkillSelectorScreen::Menu => vec![ + ListItem::new(Line::from(vec![ + Span::styled("List skills", theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled("Show skills available to the current mode", theme.style(StyleKind::Muted)), + ])), + ListItem::new(Line::from(vec![ + Span::styled("Enable/disable skills", theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled("Toggle all discovered skills for this mode", theme.style(StyleKind::Muted)), + ])), + ], + SkillSelectorScreen::List => self + .items + .iter() + .map(|skill| self.render_skill_line(skill, theme, false)) + .collect(), + SkillSelectorScreen::Configure => self + .items + .iter() + .map(|skill| self.render_skill_line(skill, theme, true)) + .collect(), + } + } + + fn render_skill_line( + &self, + skill: &SkillItem, + theme: &Theme, + include_checkbox: bool, + ) -> ListItem<'static> { + let level_marker = match skill.level.as_str() { + "project" => "P", + "user" => "U", + _ => "?", + }; + let level_style = match skill.level.as_str() { + "project" => theme.style(StyleKind::Info), + _ => theme.style(StyleKind::Muted), + }; + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let desc_style = theme.style(StyleKind::Muted); + let status = if skill.is_shadowed { + " shadowed" + } else if include_checkbox && skill.enabled && !skill.selected_for_runtime { + " enabled" + } else { + "" + }; + + let mut spans = Vec::new(); + if include_checkbox { + spans.push(Span::styled( + if skill.enabled { "[x] " } else { "[ ] " }, + if skill.enabled { + theme.style(StyleKind::Success) + } else { + theme.style(StyleKind::Muted) + }, + )); + } + spans.push(Span::styled(format!("[{}] ", level_marker), level_style)); + spans.push(Span::styled(skill.name.clone(), name_style)); + if !status.is_empty() { + spans.push(Span::styled(status.to_string(), theme.style(StyleKind::Muted))); + } + spans.push(Span::raw(" ")); + spans.push(Span::styled(skill.description.clone(), desc_style)); + + ListItem::new(Line::from(spans)) + } } diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index d92f75f3a4..3c5307b7a2 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -26,7 +26,7 @@ use super::model_selector::{ModelItem, ModelSelectorState}; use super::provider_selector::{ProviderSelection, ProviderSelectorState}; use super::agent_selector::{AgentItem, AgentSelectorState}; use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; -use super::skill_selector::{SkillItem, SkillSelectorState}; +use super::skill_selector::{SkillItem, SkillSelectorAction, SkillSelectorState}; use super::subagent_selector::{SubagentItem, SubagentSelectorState}; use super::theme::{ builtin_theme_json, resolve_appearance, resolve_effective_color_scheme, EffectiveColorScheme, @@ -37,7 +37,14 @@ use crate::config::CliConfig; use bitfun_core::agentic::coordination::ConversationCoordinator; use bitfun_core::agentic::agents::{get_agent_registry, AgentInfo}; -use bitfun_core::agentic::tools::implementations::skills::registry::SkillRegistry; +use bitfun_core::agentic::tools::implementations::skills::{ + mode_overrides::{ + load_project_mode_skills_document_local, save_project_mode_skills_document_local, + set_mode_skill_disabled_in_document, set_user_mode_skill_state, + }, + registry::SkillRegistry, + ModeSkillInfo, SkillInfo, +}; use bitfun_core::service::config::GlobalConfigManager; /// Types of popups that can be shown on the startup page @@ -776,10 +783,9 @@ impl StartupPage { match key.code { KeyCode::Up => self.skill_selector.move_up(), KeyCode::Down => self.skill_selector.move_down(), - KeyCode::Enter => { - if let Some(selected) = self.skill_selector.confirm_selection() { - self.skill_selector.hide(); - self.set_input(&format!("Execute the {} skill.", selected.name)); + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(action) = self.skill_selector.confirm_selection() { + self.handle_skill_selector_action(action); } } KeyCode::Esc => self.navigate_back(), @@ -1538,27 +1544,60 @@ impl StartupPage { fn show_skill_selector(&mut self) { self.push_current_popup_to_stack(); + self.skill_selector.show_menu(); + } + fn show_available_skill_list(&mut self) { let skills = tokio::task::block_in_place(|| { + let workspace = self.workspace_path_buf(); + let agent_type = self.agent_type.clone(); tokio::runtime::Handle::current().block_on(async { let registry = SkillRegistry::global(); - registry.refresh().await; - registry.get_all_skills().await + registry + .get_resolved_skills_for_workspace( + Some(workspace.as_path()), + Some(&agent_type), + ) + .await }) }); if skills.is_empty() { - self.status = Some("No skills found.".to_string()); + self.status = Some(format!( + "No enabled skills found for agent mode '{}'.", + self.agent_type + )); return; } let skill_items: Vec = skills .into_iter() - .map(|s| SkillItem { - name: s.name, - description: s.description, - level: s.level.as_str().to_string(), + .map(Self::skill_item_from_info) + .collect(); + + if skill_items.is_empty() { + self.status = Some("No skills found.".to_string()); + return; + } + + self.skill_selector.show_list(skill_items); + } + + fn show_skill_config_selector(&mut self) { + let skills = tokio::task::block_in_place(|| { + let workspace = self.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + tokio::runtime::Handle::current().block_on(async { + let registry = SkillRegistry::global(); + registry + .get_mode_skill_infos_for_workspace(Some(workspace.as_path()), &agent_type) + .await }) + }); + + let skill_items: Vec = skills + .into_iter() + .map(Self::skill_item_from_mode_info) .collect(); if skill_items.is_empty() { @@ -1566,7 +1605,101 @@ impl StartupPage { return; } - self.skill_selector.show(skill_items); + self.skill_selector.show_config(skill_items); + } + + fn handle_skill_selector_action(&mut self, action: SkillSelectorAction) { + match action { + SkillSelectorAction::ListSkills => self.show_available_skill_list(), + SkillSelectorAction::ConfigureSkills => self.show_skill_config_selector(), + SkillSelectorAction::Execute(selected) => { + self.skill_selector.hide(); + self.set_input(&format!("Execute the {} skill.", selected.name)); + } + SkillSelectorAction::Toggle(selected) => { + self.set_skill_enabled(&selected, !selected.enabled); + self.show_skill_config_selector(); + } + } + } + + fn set_skill_enabled(&mut self, selected: &SkillItem, enabled: bool) { + let workspace = self.workspace_path_buf(); + let mode_id = self.agent_type.clone(); + let skill = selected.clone(); + + let result: Result<(), String> = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + match skill.level.as_str() { + "user" => { + set_user_mode_skill_state( + &mode_id, + &skill.key, + enabled, + skill.default_enabled, + ) + .await + .map_err(|error| error.to_string())?; + } + "project" => { + let mut document = load_project_mode_skills_document_local(&workspace) + .await + .map_err(|error| error.to_string())?; + set_mode_skill_disabled_in_document( + &mut document, + &mode_id, + &skill.key, + !enabled, + ) + .map_err(|error| error.to_string())?; + save_project_mode_skills_document_local(&workspace, &document) + .await + .map_err(|error| error.to_string())?; + } + other => { + return Err(format!("Unsupported skill level '{}'", other)); + } + } + + Ok(()) + }) + }); + + self.status = Some(match result { + Ok(()) => format!( + "Skill '{}' {} for mode '{}'.", + selected.name, + if enabled { "enabled" } else { "disabled" }, + self.agent_type + ), + Err(error) => format!("Failed to update skill '{}': {}", selected.name, error), + }); + } + + fn skill_item_from_info(info: SkillInfo) -> SkillItem { + SkillItem { + key: info.key, + name: info.name, + description: info.description, + level: info.level.as_str().to_string(), + enabled: true, + selected_for_runtime: true, + default_enabled: true, + is_shadowed: info.is_shadowed, + } + } + + fn skill_item_from_mode_info(info: ModeSkillInfo) -> SkillItem { + SkillItem { + key: info.skill.key, + name: info.skill.name, + description: info.skill.description, + level: info.skill.level.as_str().to_string(), + enabled: info.effective_enabled, + selected_for_runtime: info.selected_for_runtime, + default_enabled: info.default_enabled, + is_shadowed: info.skill.is_shadowed, + } } fn show_subagent_selector(&mut self) { From 291da6490859cbea923b8de295d0304bf7c9172a Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sat, 16 May 2026 14:37:39 +0800 Subject: [PATCH 02/76] feat(cli): add mode-aware subagent management menu --- src/apps/cli/src/commands.rs | 9 +- src/apps/cli/src/modes/chat.rs | 215 +++++++++++++---- src/apps/cli/src/ui/chat/mouse.rs | 22 +- src/apps/cli/src/ui/chat/popups.rs | 37 ++- src/apps/cli/src/ui/chat/state.rs | 13 +- src/apps/cli/src/ui/command_palette.rs | 51 ++-- src/apps/cli/src/ui/startup.rs | 292 +++++++++++++++-------- src/apps/cli/src/ui/subagent_selector.rs | 218 +++++++++++++---- 8 files changed, 608 insertions(+), 249 deletions(-) diff --git a/src/apps/cli/src/commands.rs b/src/apps/cli/src/commands.rs index b80ceb0a74..7b1c7b3c84 100644 --- a/src/apps/cli/src/commands.rs +++ b/src/apps/cli/src/commands.rs @@ -46,7 +46,7 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ }, CommandSpec { name: "/subagents", - description: "Browse and launch subagents", + description: "List and configure subagents", }, CommandSpec { name: "/mcps", @@ -98,7 +98,7 @@ pub const STARTUP_COMMAND_SPECS: &[CommandSpec] = &[ }, CommandSpec { name: "/subagents", - description: "Browse and launch subagents", + description: "List and configure subagents", }, CommandSpec { name: "/mcps", @@ -118,7 +118,10 @@ pub const STARTUP_COMMAND_SPECS: &[CommandSpec] = &[ }, ]; -pub fn match_prefix_in(prefix: &str, commands: &'static [CommandSpec]) -> Vec<&'static CommandSpec> { +pub fn match_prefix_in( + prefix: &str, + commands: &'static [CommandSpec], +) -> Vec<&'static CommandSpec> { if prefix.is_empty() { return Vec::new(); } diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index ec90e32532..e1068af55c 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -31,14 +31,16 @@ use crate::ui::provider_selector::ProviderSelection; use crate::ui::question::QuestionAction; use crate::ui::session_selector::{SessionAction, SessionItem}; use crate::ui::skill_selector::{SkillItem, SkillSelectorAction}; -use crate::ui::subagent_selector::SubagentItem; +use crate::ui::subagent_selector::{SubagentItem, SubagentSelectorAction}; use crate::ui::theme::{ builtin_theme_ids, builtin_theme_json, resolve_appearance, resolve_effective_color_scheme, Appearance, EffectiveColorScheme, Theme, }; use crate::ui::theme_selector::ThemeItem; use crate::ui::{init_terminal, restore_terminal}; -use bitfun_core::agentic::agents::{get_agent_registry, AgentInfo}; +use bitfun_core::agentic::agents::{ + get_agent_registry, AgentInfo, SubAgentSource, SubagentListScope, SubagentQueryContext, +}; use bitfun_core::agentic::tools::implementations::skills::{ mode_overrides::{ load_project_mode_skills_document_local, save_project_mode_skills_document_local, @@ -1077,9 +1079,7 @@ impl ChatMode { KeyCode::Down => chat_view.skill_selector_down(), KeyCode::Enter | KeyCode::Char(' ') => { if let Some(action) = chat_view.skill_selector_confirm() { - self.handle_skill_selector_action( - action, chat_view, chat_state, rt_handle, - ); + self.handle_skill_selector_action(action, chat_view, chat_state, rt_handle); } } // Note: Esc is handled globally for navigation back @@ -1092,10 +1092,11 @@ impl ChatMode { match key.code { KeyCode::Up => chat_view.subagent_selector_up(), KeyCode::Down => chat_view.subagent_selector_down(), - KeyCode::Enter => { - if let Some(selected) = chat_view.subagent_selector_confirm() { - chat_view.hide_subagent_selector(); - self.apply_subagent_selection(&selected, chat_view); + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(action) = chat_view.subagent_selector_confirm() { + self.handle_subagent_selector_action( + action, chat_view, chat_state, rt_handle, + ); } } // Note: Esc is handled globally for navigation back @@ -1492,7 +1493,10 @@ impl ChatMode { } } else if chat_view.handle_mouse_event(&mouse) { if let Some(action) = chat_view.take_pending_skill_action() { - this.handle_skill_selector_action( + this.handle_skill_selector_action(action, chat_view, chat_state, rt_handle); + } + if let Some(action) = chat_view.take_pending_subagent_action() { + this.handle_subagent_selector_action( action, chat_view, chat_state, rt_handle, ); } @@ -2739,7 +2743,11 @@ impl ChatMode { } // Load historical messages from core. - let messages = agent.coordinator().get_messages(&sid).await.unwrap_or_default(); + let messages = agent + .coordinator() + .get_messages(&sid) + .await + .unwrap_or_default(); let state = ChatState::from_core_messages( sid.clone(), @@ -2829,10 +2837,7 @@ impl ChatMode { rt_handle.block_on(async { let registry = SkillRegistry::global(); registry - .get_resolved_skills_for_workspace( - Some(workspace.as_path()), - Some(&agent_type), - ) + .get_resolved_skills_for_workspace(Some(workspace.as_path()), Some(&agent_type)) .await }) }); @@ -2845,10 +2850,8 @@ impl ChatMode { return; } - let skill_items: Vec = skills - .into_iter() - .map(Self::skill_item_from_info) - .collect(); + let skill_items: Vec = + skills.into_iter().map(Self::skill_item_from_info).collect(); if skill_items.is_empty() { chat_state.add_system_message("No skills found.".to_string()); @@ -3006,8 +3009,17 @@ impl ChatMode { } } - /// Show subagent selector popup with all available subagents + /// Show subagent list/configuration menu. fn show_subagent_selector( + &self, + chat_view: &mut ChatView, + _chat_state: &mut ChatState, + _rt_handle: &tokio::runtime::Handle, + ) { + chat_view.show_subagent_menu(); + } + + fn show_available_subagent_list( &self, chat_view: &mut ChatView, chat_state: &mut ChatState, @@ -3015,43 +3027,91 @@ impl ChatMode { ) { let registry = get_agent_registry(); let subagents = tokio::task::block_in_place(|| { - let workspace = self.workspace.clone().map(PathBuf::from); - rt_handle.block_on(registry.get_subagents_info(workspace.as_deref())) + let workspace = self.agent.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + rt_handle.block_on(registry.get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: Some(&agent_type), + workspace_root: Some(workspace.as_path()), + list_scope: SubagentListScope::TaskVisible, + include_disabled: false, + })) }); if subagents.is_empty() { + chat_state.add_system_message(format!( + "No enabled subagents found for agent mode '{}'.", + self.agent_type + )); + return; + } + + let subagent_items: Vec = subagents + .into_iter() + .map(Self::subagent_item_from_info) + .collect(); + + if subagent_items.is_empty() { chat_state.add_system_message("No subagents found.".to_string()); return; } + chat_view.show_subagent_list(subagent_items); + } + + fn show_subagent_config_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let registry = get_agent_registry(); + let subagents = tokio::task::block_in_place(|| { + let workspace = self.agent.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + rt_handle.block_on(registry.get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: Some(&agent_type), + workspace_root: Some(workspace.as_path()), + list_scope: SubagentListScope::RegistryManagement, + include_disabled: true, + })) + }); + let subagent_items: Vec = subagents .into_iter() - .map(|s| { - let source = match s.subagent_source { - Some(bitfun_core::agentic::agents::SubAgentSource::Builtin) => { - "builtin".to_string() - } - Some(bitfun_core::agentic::agents::SubAgentSource::Project) => { - "project".to_string() - } - Some(bitfun_core::agentic::agents::SubAgentSource::User) => "user".to_string(), - None => "builtin".to_string(), - }; - SubagentItem { - id: s.id, - name: s.name, - description: s.description, - source, - } - }) + .map(Self::subagent_item_from_info) .collect(); if subagent_items.is_empty() { - chat_state.add_system_message("No enabled subagents found.".to_string()); + chat_state.add_system_message("No subagents found.".to_string()); return; } - chat_view.show_subagent_selector(subagent_items); + chat_view.show_subagent_config(subagent_items); + } + + fn handle_subagent_selector_action( + &self, + action: SubagentSelectorAction, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + match action { + SubagentSelectorAction::ListSubagents => { + self.show_available_subagent_list(chat_view, chat_state, rt_handle); + } + SubagentSelectorAction::ConfigureSubagents => { + self.show_subagent_config_selector(chat_view, chat_state, rt_handle); + } + SubagentSelectorAction::Launch(selected) => { + chat_view.hide_subagent_selector(); + self.apply_subagent_selection(&selected, chat_view); + } + SubagentSelectorAction::Toggle(selected) => { + self.set_subagent_enabled(&selected, !selected.enabled, chat_state, rt_handle); + self.show_subagent_config_selector(chat_view, chat_state, rt_handle); + } + } } /// Apply subagent selection: fill input box with launch command @@ -3062,6 +3122,66 @@ impl ChatMode { )); } + fn set_subagent_enabled( + &self, + selected: &SubagentItem, + enabled: bool, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let registry = get_agent_registry(); + let workspace = self.agent.workspace_path_buf(); + let mode_id = self.agent_type.clone(); + let subagent = selected.clone(); + + let result: Result<(), String> = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + registry + .update_subagent_override( + &mode_id, + &subagent.id, + enabled, + Some(workspace.as_path()), + ) + .await + .map_err(|error| error.to_string()) + }) + }); + + match result { + Ok(()) => chat_state.add_system_message(format!( + "Subagent '{}' {} for mode '{}'.", + selected.name, + if enabled { "enabled" } else { "disabled" }, + self.agent_type + )), + Err(error) => chat_state.add_system_message(format!( + "Failed to update subagent '{}': {}", + selected.name, error + )), + } + } + + fn subagent_item_from_info(info: AgentInfo) -> SubagentItem { + let source = match info.subagent_source { + Some(SubAgentSource::Builtin) => "builtin", + Some(SubAgentSource::Project) => "project", + Some(SubAgentSource::User) => "user", + None => "builtin", + } + .to_string(); + + SubagentItem { + key: info.key, + id: info.id, + name: info.name, + description: info.description, + source, + enabled: info.effective_enabled, + default_enabled: info.default_enabled, + } + } + /// Send a message to the agent programmatically (used by slash commands like /init) fn send_message_to_agent( &self, @@ -3165,7 +3285,10 @@ impl ChatMode { let result = tokio::task::block_in_place(|| { rt_handle.block_on(async { let workspace_path = agent.workspace_path_buf(); - agent.coordinator().delete_session(&workspace_path, &sid).await + agent + .coordinator() + .delete_session(&workspace_path, &sid) + .await }) }); @@ -3304,8 +3427,7 @@ impl ChatMode { if success { chat_view.set_status(Some(format!("Model added: {}", result.name))); - chat_state.current_model_name = - format!("{} / {}", result.model_name, result.name); + chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); tracing::info!("Added new AI model: {} ({})", model_id, result.model_name); } else { chat_view.set_status(Some("Failed to add model".to_string())); @@ -3434,8 +3556,7 @@ impl ChatMode { if success { chat_view.set_status(Some(format!("Model updated: {}", result.name))); - chat_state.current_model_name = - format!("{} / {}", result.model_name, result.name); + chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); tracing::info!("Updated AI model: {}", model_id); } else { chat_view.set_status(Some("Failed to update model".to_string())); diff --git a/src/apps/cli/src/ui/chat/mouse.rs b/src/apps/cli/src/ui/chat/mouse.rs index ac30d3e829..fb6d7d51fa 100644 --- a/src/apps/cli/src/ui/chat/mouse.rs +++ b/src/apps/cli/src/ui/chat/mouse.rs @@ -19,6 +19,10 @@ impl ChatView { self.pending_skill_action.take() } + pub fn take_pending_subagent_action(&mut self) -> Option { + self.pending_subagent_action.take() + } + pub fn handle_mouse_event(&mut self, mouse: &crossterm::event::MouseEvent) -> bool { // Popups take priority when visible if self.model_selector.captures_mouse(mouse) { @@ -45,12 +49,8 @@ impl ChatView { return true; } if self.subagent_selector.captures_mouse(mouse) { - if let Some(selected) = self.subagent_selector.handle_mouse_event(mouse) { - self.subagent_selector.hide(); - self.set_input(&format!( - "Launch subagent {} to finish task: ", - selected.name - )); + if let Some(action) = self.subagent_selector.handle_mouse_event(mouse) { + self.pending_subagent_action = Some(action); } return true; } @@ -143,7 +143,9 @@ impl ChatView { fn selection_text(&self) -> Option { let (start, end) = self.selection_bounds()?; - if start.line >= self.visible_plain_lines.len() || end.line >= self.visible_plain_lines.len() { + if start.line >= self.visible_plain_lines.len() + || end.line >= self.visible_plain_lines.len() + { return None; } @@ -239,7 +241,8 @@ impl ChatView { let Some((start, end)) = self.selection_bounds() else { return; }; - let dragging = self.selection_mouse_down.is_some() && (self.selection_dragged || start != end); + let dragging = + self.selection_mouse_down.is_some() && (self.selection_dragged || start != end); if !dragging { return; } @@ -250,7 +253,8 @@ impl ChatView { return; } - let style = ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED); + let style = + ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED); for line_idx in start.line..=end.line { if line_idx < list_offset { diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index 5251e72ee9..4fd4c1dab1 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -38,10 +38,7 @@ impl ChatView { self.command_palette.is_visible() } - pub fn command_palette_handle_key( - &mut self, - key: crossterm::event::KeyEvent, - ) -> PaletteAction { + pub fn command_palette_handle_key(&mut self, key: crossterm::event::KeyEvent) -> PaletteAction { self.command_palette.handle_key_event(key) } @@ -52,10 +49,7 @@ impl ChatView { self.command_palette.handle_mouse_event(mouse) } - pub fn command_palette_captures_mouse( - &self, - mouse: &crossterm::event::MouseEvent, - ) -> bool { + pub fn command_palette_captures_mouse(&self, mouse: &crossterm::event::MouseEvent) -> bool { self.command_palette.captures_mouse(mouse) } @@ -211,8 +205,18 @@ impl ChatView { // ============ Subagent selector methods ============ - pub fn show_subagent_selector(&mut self, subagents: Vec) { - self.subagent_selector.show(subagents); + pub fn show_subagent_menu(&mut self) { + self.subagent_selector.show_menu(); + self.popup_stack.push(PopupType::SubagentSelector); + } + + pub fn show_subagent_list(&mut self, subagents: Vec) { + self.subagent_selector.show_list(subagents); + self.popup_stack.push(PopupType::SubagentSelector); + } + + pub fn show_subagent_config(&mut self, subagents: Vec) { + self.subagent_selector.show_config(subagents); self.popup_stack.push(PopupType::SubagentSelector); } @@ -236,7 +240,7 @@ impl ChatView { self.subagent_selector.move_down(); } - pub fn subagent_selector_confirm(&self) -> Option { + pub fn subagent_selector_confirm(&self) -> Option { self.subagent_selector.confirm_selection() } @@ -307,10 +311,7 @@ impl ChatView { self.mcp_add_dialog.is_visible() } - pub fn mcp_add_dialog_handle_key( - &mut self, - key: crossterm::event::KeyEvent, - ) -> McpAddAction { + pub fn mcp_add_dialog_handle_key(&mut self, key: crossterm::event::KeyEvent) -> McpAddAction { self.mcp_add_dialog.handle_key_event(key) } @@ -393,10 +394,7 @@ impl ChatView { self.provider_selector.handle_mouse_event(mouse) } - pub fn provider_selector_captures_mouse( - &self, - mouse: &crossterm::event::MouseEvent, - ) -> bool { + pub fn provider_selector_captures_mouse(&self, mouse: &crossterm::event::MouseEvent) -> bool { self.provider_selector.captures_mouse(mouse) } @@ -447,4 +445,3 @@ impl ChatView { self.model_config_form.handle_key_event(key) } } - diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index 8c02120bba..2eb1fe25df 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -8,7 +8,6 @@ use ratatui::{ use std::collections::{HashMap, HashSet, VecDeque}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -use super::text_input::TextInput; use super::agent_selector::{AgentItem, AgentSelectorState}; use super::command_menu::CommandMenuState; use super::command_palette::{CommandPaletteState, PaletteAction}; @@ -22,8 +21,9 @@ use super::provider_selector::{ProviderSelection, ProviderSelectorState}; use super::question::render_question_overlay; use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; use super::skill_selector::{SkillItem, SkillSelectorAction, SkillSelectorState}; -use super::subagent_selector::{SubagentItem, SubagentSelectorState}; -use super::theme::{Theme, StyleKind}; +use super::subagent_selector::{SubagentItem, SubagentSelectorAction, SubagentSelectorState}; +use super::text_input::TextInput; +use super::theme::{StyleKind, Theme}; use super::theme_selector::{ThemeItem, ThemeSelectorState}; use super::widgets::Spinner; use crate::chat_state::{ChatMessage, ChatState, FlowItem, MessageRole}; @@ -179,14 +179,12 @@ pub struct ChatView { theme_selector: ThemeSelectorState, // -- Tool card expand/collapse state -- - /// Set of collapsed tool IDs (block tools default to expanded; this tracks manually collapsed ones) pub collapsed_tools: HashSet, /// Currently focused block tool ID (for Ctrl+O toggle) pub focused_block_tool: Option, // -- Thinking expand/collapse state -- - /// Set of assistant message IDs whose thinking blocks are collapsed collapsed_thinking: HashSet, /// Tracks which messages have been auto-collapsed (so user re-expands won't be overridden) @@ -195,7 +193,6 @@ pub struct ChatView { thinking_user_overrides: HashSet, // -- Mouse click tracking -- - /// Pending command from mouse click on command menu (consumed by caller) pending_command: Option, /// Pending theme preview selection (consumed by caller) @@ -207,6 +204,8 @@ pub struct ChatView { pending_mcp_toggle: Option, /// Pending skill selector action from mouse click (consumed by caller) pending_skill_action: Option, + /// Pending subagent selector action from mouse click (consumed by caller) + pending_subagent_action: Option, /// Info popup message (rendered as overlay, dismissed by any key) info_popup: Option, @@ -238,7 +237,6 @@ pub struct ChatView { pub popup_stack: PopupStack, // -- Render cache state (performance optimization) -- - /// Cached total rendered line count (updated each render frame) cached_total_lines: usize, /// Message count when cache was last updated @@ -283,6 +281,7 @@ impl ChatView { pending_command: None, pending_mcp_toggle: None, pending_skill_action: None, + pending_subagent_action: None, pending_theme_preview: None, theme_preview_original: None, info_popup: None, diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 0a3d10a79c..9f9c289fed 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -3,7 +3,6 @@ /// A centered overlay with a search box at the top and grouped command items below. /// Unlike the slash command menu which appears inline, this is a full-screen centered popup. /// Supports viewport scrolling when content exceeds visible area. - use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, @@ -64,7 +63,7 @@ pub fn default_palette_items() -> Vec { PaletteItem { id: "subagents".into(), label: "Subagents".into(), - description: "Browse and launch subagents".into(), + description: "List and configure subagents".into(), group: "Prompt".into(), }, // Models group @@ -558,12 +557,18 @@ impl CommandPaletteState { // Row 0: Search box let search_display = if self.search_input.is_empty() { Line::from(vec![ - Span::styled("> ", theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD)), + Span::styled( + "> ", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), Span::styled("Search commands...", theme.style(StyleKind::Muted)), ]) } else { Line::from(vec![ - Span::styled("> ", theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD)), + Span::styled( + "> ", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), Span::raw(&self.search_input), ]) }; @@ -576,9 +581,11 @@ impl CommandPaletteState { frame.render_widget(Paragraph::new(search_display), search_area); // Set cursor position in search box - let cursor_x = inner.x + 2 + self.search_input[..self.char_to_byte(self.search_cursor)] - .chars() - .count() as u16; + let cursor_x = inner.x + + 2 + + self.search_input[..self.char_to_byte(self.search_cursor)] + .chars() + .count() as u16; frame.set_cursor_position((cursor_x, inner.y)); // Separator line @@ -592,7 +599,10 @@ impl CommandPaletteState { height: 1, }; frame.render_widget( - Paragraph::new(Line::from(Span::styled(sep, theme.style(StyleKind::Border)))), + Paragraph::new(Line::from(Span::styled( + sep, + theme.style(StyleKind::Border), + ))), sep_area, ); } @@ -631,12 +641,10 @@ impl CommandPaletteState { match row { PaletteRow::GroupHeader(name) => { - let header_line = Line::from(vec![ - Span::styled( - format!(" {}", name.to_uppercase()), - theme.style(StyleKind::Muted).add_modifier(Modifier::BOLD), - ), - ]); + let header_line = Line::from(vec![Span::styled( + format!(" {}", name.to_uppercase()), + theme.style(StyleKind::Muted).add_modifier(Modifier::BOLD), + )]); frame.render_widget(Paragraph::new(header_line), row_area); } PaletteRow::Item(sel_idx) => { @@ -699,12 +707,10 @@ impl CommandPaletteState { height: 1, }; - let mut hints = vec![ - Span::styled( - " \u{2191}\u{2193} Navigate Enter Select Esc Cancel", - theme.style(StyleKind::Muted), - ), - ]; + let mut hints = vec![Span::styled( + " \u{2191}\u{2193} Navigate Enter Select Esc Cancel", + theme.style(StyleKind::Muted), + )]; if has_more_above || has_more_below { let indicator = if has_more_above && has_more_below { @@ -714,10 +720,7 @@ impl CommandPaletteState { } else { " \u{2193}" }; - hints.push(Span::styled( - indicator, - theme.style(StyleKind::Warning), - )); + hints.push(Span::styled(indicator, theme.style(StyleKind::Warning))); } let hint_line = Line::from(hints); diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 3c5307b7a2..921e62b37c 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1,3 +1,19 @@ +use super::agent_selector::{AgentItem, AgentSelectorState}; +use super::command_menu::CommandMenuState; +use super::command_palette::{CommandPaletteState, PaletteAction}; +use super::model_config_form::{ModelConfigFormState, ModelFormAction, ModelFormResult}; +use super::model_selector::{ModelItem, ModelSelectorState}; +use super::provider_selector::{ProviderSelection, ProviderSelectorState}; +use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; +use super::skill_selector::{SkillItem, SkillSelectorAction, SkillSelectorState}; +use super::subagent_selector::{SubagentItem, SubagentSelectorAction, SubagentSelectorState}; +use super::text_input::{TextInput, TextInputStyle}; +use super::theme::{ + builtin_theme_json, resolve_appearance, resolve_effective_color_scheme, EffectiveColorScheme, + Theme, +}; +use crate::commands::STARTUP_COMMAND_SPECS; +use crate::config::CliConfig; /// Startup page module /// /// Full-featured startup page with: @@ -5,7 +21,6 @@ /// - Slash command menu with real execution /// - Model/Agent/Session/Skill/Subagent selector popups /// - Random tips - use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ @@ -18,25 +33,11 @@ use ratatui::{ }; use std::sync::Arc; use std::time::Duration; -use super::text_input::{TextInput, TextInputStyle}; -use super::command_menu::CommandMenuState; -use super::command_palette::{CommandPaletteState, PaletteAction}; -use super::model_config_form::{ModelConfigFormState, ModelFormAction, ModelFormResult}; -use super::model_selector::{ModelItem, ModelSelectorState}; -use super::provider_selector::{ProviderSelection, ProviderSelectorState}; -use super::agent_selector::{AgentItem, AgentSelectorState}; -use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; -use super::skill_selector::{SkillItem, SkillSelectorAction, SkillSelectorState}; -use super::subagent_selector::{SubagentItem, SubagentSelectorState}; -use super::theme::{ - builtin_theme_json, resolve_appearance, resolve_effective_color_scheme, EffectiveColorScheme, - Theme, -}; -use crate::commands::STARTUP_COMMAND_SPECS; -use crate::config::CliConfig; +use bitfun_core::agentic::agents::{ + get_agent_registry, AgentInfo, SubAgentSource, SubagentListScope, SubagentQueryContext, +}; use bitfun_core::agentic::coordination::ConversationCoordinator; -use bitfun_core::agentic::agents::{get_agent_registry, AgentInfo}; use bitfun_core::agentic::tools::implementations::skills::{ mode_overrides::{ load_project_mode_skills_document_local, save_project_mode_skills_document_local, @@ -344,7 +345,10 @@ impl StartupPage { } else { for ev in events { match ev { - Event::Key(key) if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat => { + Event::Key(key) + if key.kind == KeyEventKind::Press + || key.kind == KeyEventKind::Repeat => + { if let Some(result) = self.handle_key(key) { return Ok(result); } @@ -443,19 +447,21 @@ impl StartupPage { // Dynamic input height: content lines (1..6) + 2 (padding top + agent label row) + 1 (gap) let input_content_width = max_width.saturating_sub(2 + 4); // left bar(2) + inner padding(4) - let visual_lines = self.text_input.visual_line_count_with_prefix(input_content_width, 0) as u16; + let visual_lines = + self.text_input + .visual_line_count_with_prefix(input_content_width, 0) as u16; let content_lines = visual_lines.max(1).min(6); let input_box_height = content_lines + 3; // +1 top padding, +1 gap, +1 agent label let v_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Percentage(20), // top space - Constraint::Length(12), // logo - Constraint::Length(1), // gap + Constraint::Percentage(20), // top space + Constraint::Length(12), // logo + Constraint::Length(1), // gap Constraint::Length(input_box_height), // input box - Constraint::Length(2), // gap + tip/status - Constraint::Min(1), // bottom space + Constraint::Length(2), // gap + tip/status + Constraint::Min(1), // bottom space ]) .split(area); @@ -492,18 +498,13 @@ impl StartupPage { .direction(Direction::Horizontal) .constraints([ Constraint::Length(2), // left bar - Constraint::Min(1), // content + Constraint::Min(1), // content ]) .split(area); // Left bar: full-height ┃ let bar_lines: Vec = (0..area.height) - .map(|_| { - Line::from(Span::styled( - " ┃", - Style::default().fg(highlight_color), - )) - }) + .map(|_| Line::from(Span::styled(" ┃", Style::default().fg(highlight_color)))) .collect(); let bar = Paragraph::new(bar_lines); frame.render_widget(bar, h_chunks[0]); @@ -512,10 +513,8 @@ impl StartupPage { let content_area = h_chunks[1]; // Fill background - let bg = Paragraph::new( - vec![Line::from(""); content_area.height as usize], - ) - .style(Style::default().bg(self.theme.background_element)); + let bg = Paragraph::new(vec![Line::from(""); content_area.height as usize]) + .style(Style::default().bg(self.theme.background_element)); frame.render_widget(bg, content_area); // Inner content with padding @@ -553,10 +552,7 @@ impl StartupPage { Style::default().fg(highlight_color), )]; if !self.model_display_name.is_empty() { - spans.push(Span::styled( - " | ", - Style::default().fg(self.theme.muted), - )); + spans.push(Span::styled(" | ", Style::default().fg(self.theme.muted))); spans.push(Span::styled( &self.model_display_name, Style::default().fg(self.theme.muted), @@ -612,7 +608,10 @@ impl StartupPage { // Right: MCP status | version let right = Paragraph::new(Line::from(vec![ Span::styled(&mcp_status, Style::default().fg(mcp_color)), - Span::styled(format!(" | {} ", version), Style::default().fg(self.theme.muted)), + Span::styled( + format!(" | {} ", version), + Style::default().fg(self.theme.muted), + ), ])) .alignment(Alignment::Right); frame.render_widget(right, area); @@ -798,10 +797,9 @@ impl StartupPage { match key.code { KeyCode::Up => self.subagent_selector.move_up(), KeyCode::Down => self.subagent_selector.move_down(), - KeyCode::Enter => { - if let Some(selected) = self.subagent_selector.confirm_selection() { - self.subagent_selector.hide(); - self.set_input(&format!("Launch subagent {} to finish task: ", selected.name)); + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(action) = self.subagent_selector.confirm_selection() { + self.handle_subagent_selector_action(action); } } KeyCode::Esc => self.navigate_back(), @@ -872,7 +870,8 @@ impl StartupPage { } KeyCode::Esc => { self.text_input.clear(); - self.command_menu.update_with_commands("", 0, STARTUP_COMMAND_SPECS); + self.command_menu + .update_with_commands("", 0, STARTUP_COMMAND_SPECS); return None; } _ => { @@ -1071,20 +1070,21 @@ impl StartupPage { prompt: Some("/acp".to_string()), }); } - "/init" => { - match crate::prompts::get_cli_prompt("init") { - Some(prompt) => { - return Some(StartupResult::NewSession { - prompt: Some(prompt.to_string()), - }); - } - None => { - self.status = Some("Init prompt not found".to_string()); - } + "/init" => match crate::prompts::get_cli_prompt("init") { + Some(prompt) => { + return Some(StartupResult::NewSession { + prompt: Some(prompt.to_string()), + }); } - } + None => { + self.status = Some("Init prompt not found".to_string()); + } + }, _ => { - self.status = Some(format!("Unknown command: {}. Type /help for available commands.", cmd)); + self.status = Some(format!( + "Unknown command: {}. Type /help for available commands.", + cmd + )); } } @@ -1173,9 +1173,8 @@ impl StartupPage { let result = tokio::task::block_in_place(|| { let workspace_path = self.workspace_path_buf(); - tokio::runtime::Handle::current().block_on(async { - coordinator.delete_session(&workspace_path, &sid).await - }) + tokio::runtime::Handle::current() + .block_on(async { coordinator.delete_session(&workspace_path, &sid).await }) }); match result { @@ -1326,7 +1325,9 @@ impl StartupPage { enable_thinking_process: result.enable_thinking || result.support_preserved_thinking, skip_ssl_verify: result.skip_ssl_verify, custom_headers, - custom_headers_mode: if result.custom_headers_mode.is_empty() || result.custom_headers_mode == "merge" { + custom_headers_mode: if result.custom_headers_mode.is_empty() + || result.custom_headers_mode == "merge" + { None } else { Some(result.custom_headers_mode.clone()) @@ -1336,8 +1337,7 @@ impl StartupPage { }; let result_name = result.name.clone(); - let result_model_display = - format!("{} / {}", result.model_name, result.name); + let result_model_display = format!("{} / {}", result.model_name, result.name); let success = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { @@ -1424,10 +1424,13 @@ impl StartupPage { enable_thinking: model.enable_thinking_process, support_preserved_thinking: model.inline_think_in_text, skip_ssl_verify: model.skip_ssl_verify, - custom_headers: model.custom_headers + custom_headers: model + .custom_headers .map(|h| serde_json::to_string(&h).unwrap_or_default()) .unwrap_or_default(), - custom_headers_mode: model.custom_headers_mode.unwrap_or_else(|| "merge".to_string()), + custom_headers_mode: model + .custom_headers_mode + .unwrap_or_else(|| "merge".to_string()), custom_request_body: model.custom_request_body.unwrap_or_default(), }; self.model_config_form.show_for_edit(&model.id, &form_data); @@ -1471,7 +1474,9 @@ impl StartupPage { enable_thinking_process: result.enable_thinking || result.support_preserved_thinking, skip_ssl_verify: result.skip_ssl_verify, custom_headers, - custom_headers_mode: if result.custom_headers_mode.is_empty() || result.custom_headers_mode == "merge" { + custom_headers_mode: if result.custom_headers_mode.is_empty() + || result.custom_headers_mode == "merge" + { None } else { Some(result.custom_headers_mode.clone()) @@ -1481,8 +1486,7 @@ impl StartupPage { }; let result_name = result.name.clone(); - let result_model_display = - format!("{} / {}", result.model_name, result.name); + let result_model_display = format!("{} / {}", result.model_name, result.name); let success = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { @@ -1494,7 +1498,10 @@ impl StartupPage { } }; - if let Err(e) = config_service.update_ai_model(&model_id, model_config).await { + if let Err(e) = config_service + .update_ai_model(&model_id, model_config) + .await + { tracing::error!("Failed to update AI model: {}", e); return false; } @@ -1530,7 +1537,8 @@ impl StartupPage { }) .collect(); - self.agent_selector.show(agent_items, Some(self.agent_type.clone())); + self.agent_selector + .show(agent_items, Some(self.agent_type.clone())); } fn apply_agent_selection(&mut self, selected: &AgentItem) { @@ -1554,10 +1562,7 @@ impl StartupPage { tokio::runtime::Handle::current().block_on(async { let registry = SkillRegistry::global(); registry - .get_resolved_skills_for_workspace( - Some(workspace.as_path()), - Some(&agent_type), - ) + .get_resolved_skills_for_workspace(Some(workspace.as_path()), Some(&agent_type)) .await }) }); @@ -1570,10 +1575,8 @@ impl StartupPage { return; } - let skill_items: Vec = skills - .into_iter() - .map(Self::skill_item_from_info) - .collect(); + let skill_items: Vec = + skills.into_iter().map(Self::skill_item_from_info).collect(); if skill_items.is_empty() { self.status = Some("No skills found.".to_string()); @@ -1704,35 +1707,63 @@ impl StartupPage { fn show_subagent_selector(&mut self) { self.push_current_popup_to_stack(); + self.subagent_selector.show_menu(); + } + fn show_available_subagent_list(&mut self) { let registry = get_agent_registry(); let subagents = tokio::task::block_in_place(|| { let workspace = self.workspace_path_buf(); - tokio::runtime::Handle::current() - .block_on(registry.get_subagents_info(Some(workspace.as_path()))) + let agent_type = self.agent_type.clone(); + tokio::runtime::Handle::current().block_on(registry.get_subagents_for_query( + &SubagentQueryContext { + parent_agent_type: Some(&agent_type), + workspace_root: Some(workspace.as_path()), + list_scope: SubagentListScope::TaskVisible, + include_disabled: false, + }, + )) }); if subagents.is_empty() { + self.status = Some(format!( + "No enabled subagents found for agent mode '{}'.", + self.agent_type + )); + return; + } + + let subagent_items: Vec = subagents + .into_iter() + .map(Self::subagent_item_from_info) + .collect(); + + if subagent_items.is_empty() { self.status = Some("No subagents found.".to_string()); return; } + self.subagent_selector.show_list(subagent_items); + } + + fn show_subagent_config_selector(&mut self) { + let registry = get_agent_registry(); + let subagents = tokio::task::block_in_place(|| { + let workspace = self.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + tokio::runtime::Handle::current().block_on(registry.get_subagents_for_query( + &SubagentQueryContext { + parent_agent_type: Some(&agent_type), + workspace_root: Some(workspace.as_path()), + list_scope: SubagentListScope::RegistryManagement, + include_disabled: true, + }, + )) + }); + let subagent_items: Vec = subagents .into_iter() - .map(|s| { - let source = match s.subagent_source { - Some(bitfun_core::agentic::agents::SubAgentSource::Builtin) => "builtin".to_string(), - Some(bitfun_core::agentic::agents::SubAgentSource::Project) => "project".to_string(), - Some(bitfun_core::agentic::agents::SubAgentSource::User) => "user".to_string(), - None => "builtin".to_string(), - }; - SubagentItem { - id: s.id, - name: s.name, - description: s.description, - source, - } - }) + .map(Self::subagent_item_from_info) .collect(); if subagent_items.is_empty() { @@ -1740,7 +1771,76 @@ impl StartupPage { return; } - self.subagent_selector.show(subagent_items); + self.subagent_selector.show_config(subagent_items); + } + + fn handle_subagent_selector_action(&mut self, action: SubagentSelectorAction) { + match action { + SubagentSelectorAction::ListSubagents => self.show_available_subagent_list(), + SubagentSelectorAction::ConfigureSubagents => self.show_subagent_config_selector(), + SubagentSelectorAction::Launch(selected) => { + self.subagent_selector.hide(); + self.set_input(&format!( + "Launch subagent {} to finish task: ", + selected.name + )); + } + SubagentSelectorAction::Toggle(selected) => { + self.set_subagent_enabled(&selected, !selected.enabled); + self.show_subagent_config_selector(); + } + } + } + + fn set_subagent_enabled(&mut self, selected: &SubagentItem, enabled: bool) { + let registry = get_agent_registry(); + let workspace = self.workspace_path_buf(); + let mode_id = self.agent_type.clone(); + let subagent = selected.clone(); + + let result: Result<(), String> = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + registry + .update_subagent_override( + &mode_id, + &subagent.id, + enabled, + Some(workspace.as_path()), + ) + .await + .map_err(|error| error.to_string()) + }) + }); + + self.status = Some(match result { + Ok(()) => format!( + "Subagent '{}' {} for mode '{}'.", + selected.name, + if enabled { "enabled" } else { "disabled" }, + self.agent_type + ), + Err(error) => format!("Failed to update subagent '{}': {}", selected.name, error), + }); + } + + fn subagent_item_from_info(info: AgentInfo) -> SubagentItem { + let source = match info.subagent_source { + Some(SubAgentSource::Builtin) => "builtin", + Some(SubAgentSource::Project) => "project", + Some(SubAgentSource::User) => "user", + None => "builtin", + } + .to_string(); + + SubagentItem { + key: info.key, + id: info.id, + name: info.name, + description: info.description, + source, + enabled: info.effective_enabled, + default_enabled: info.default_enabled, + } } // ======================== Helpers ======================== diff --git a/src/apps/cli/src/ui/subagent_selector.rs b/src/apps/cli/src/ui/subagent_selector.rs index f984658229..f74fd8b571 100644 --- a/src/apps/cli/src/ui/subagent_selector.rs +++ b/src/apps/cli/src/ui/subagent_selector.rs @@ -17,10 +17,28 @@ use crate::ui::theme::{StyleKind, Theme}; #[derive(Debug, Clone)] #[allow(dead_code)] pub struct SubagentItem { + pub key: String, pub id: String, pub name: String, pub description: String, pub source: String, // "builtin", "project", or "user" + pub enabled: bool, + pub default_enabled: bool, +} + +#[derive(Debug, Clone)] +pub enum SubagentSelectorAction { + ListSubagents, + ConfigureSubagents, + Launch(SubagentItem), + Toggle(SubagentItem), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SubagentSelectorScreen { + Menu, + List, + Configure, } /// Subagent selector popup state @@ -29,6 +47,7 @@ pub struct SubagentSelectorState { list_state: ListState, visible: bool, last_area: Option, + screen: SubagentSelectorScreen, } impl SubagentSelectorState { @@ -38,20 +57,54 @@ impl SubagentSelectorState { list_state: ListState::default(), visible: false, last_area: None, + screen: SubagentSelectorScreen::Menu, } } - /// Show the subagent selector with given subagent list - pub fn show(&mut self, subagents: Vec) { + pub fn show_menu(&mut self) { + self.items.clear(); + self.screen = SubagentSelectorScreen::Menu; + self.list_state.select(Some(0)); + self.visible = true; + } + + /// Show subagents available to the current parent mode. + pub fn show_list(&mut self, subagents: Vec) { if subagents.is_empty() { return; } self.items = subagents; + self.screen = SubagentSelectorScreen::List; self.list_state.select(Some(0)); self.visible = true; } + /// Show all discovered subagents with mode-specific enablement checkboxes. + pub fn show_config(&mut self, subagents: Vec) { + if subagents.is_empty() { + return; + } + + let selected_key = if self.screen == SubagentSelectorScreen::Configure { + self.list_state + .selected() + .and_then(|index| self.items.get(index)) + .map(|item| item.key.clone()) + } else { + None + }; + let selected_index = self.list_state.selected().unwrap_or(0); + let next_index = selected_key + .and_then(|key| subagents.iter().position(|item| item.key == key)) + .unwrap_or_else(|| selected_index.min(subagents.len().saturating_sub(1))); + + self.items = subagents; + self.screen = SubagentSelectorScreen::Configure; + self.list_state.select(Some(next_index)); + self.visible = true; + } + pub fn hide(&mut self) { self.visible = false; // Note: we don't clear items here to support back navigation @@ -60,7 +113,7 @@ impl SubagentSelectorState { /// Reshow the subagent selector (for back navigation) pub fn reshow(&mut self) { - if !self.items.is_empty() { + if self.screen == SubagentSelectorScreen::Menu || !self.items.is_empty() { self.visible = true; } } @@ -70,7 +123,7 @@ impl SubagentSelectorState { } pub fn move_up(&mut self) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { return; } let selected = self.list_state.selected().unwrap_or(0); @@ -79,32 +132,55 @@ impl SubagentSelectorState { } pub fn move_down(&mut self) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { return; } let selected = self.list_state.selected().unwrap_or(0); - let next = (selected + 1).min(self.items.len().saturating_sub(1)); + let next = (selected + 1).min(self.len().saturating_sub(1)); self.list_state.select(Some(next)); } - /// Get the selected subagent item - pub fn confirm_selection(&self) -> Option { + /// Get the selected action. + pub fn confirm_selection(&self) -> Option { if !self.visible { return None; } let idx = self.list_state.selected()?; - self.items.get(idx).cloned() + match self.screen { + SubagentSelectorScreen::Menu => match idx { + 0 => Some(SubagentSelectorAction::ListSubagents), + 1 => Some(SubagentSelectorAction::ConfigureSubagents), + _ => None, + }, + SubagentSelectorScreen::List => self + .items + .get(idx) + .cloned() + .map(SubagentSelectorAction::Launch), + SubagentSelectorScreen::Configure => self + .items + .get(idx) + .cloned() + .map(SubagentSelectorAction::Toggle), + } + } + + fn len(&self) -> usize { + match self.screen { + SubagentSelectorScreen::Menu => 2, + SubagentSelectorScreen::List | SubagentSelectorScreen::Configure => self.items.len(), + } } /// Render the subagent selector popup as an overlay pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { self.last_area = None; return; } - let popup_width = area.width.saturating_sub(4).min(70); - let popup_height = (self.items.len() as u16 + 4).min(area.height.saturating_sub(2)); + let popup_width = area.width.saturating_sub(4).min(92); + let popup_height = (self.len() as u16 + 4).min(area.height.saturating_sub(2)); if popup_height < 5 || popup_width < 20 { self.last_area = None; return; @@ -121,40 +197,18 @@ impl SubagentSelectorState { }; self.last_area = Some(popup_area); - let list_items: Vec = self - .items - .iter() - .map(|subagent| { - let source_marker = match subagent.source.as_str() { - "builtin" => "B", - "project" => "P", - "user" => "U", - _ => "?", - }; - let source_style = match subagent.source.as_str() { - "builtin" => theme.style(StyleKind::Success), - "project" => theme.style(StyleKind::Info), - _ => theme.style(StyleKind::Muted), - }; - - let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); - let desc_style = theme.style(StyleKind::Muted); - - let line = Line::from(vec![ - Span::styled(format!("[{}] ", source_marker), source_style), - Span::styled(&subagent.name, name_style), - Span::raw(" "), - Span::styled(&subagent.description, desc_style), - ]); - ListItem::new(line) - }) - .collect(); + let list_items = self.render_items(theme); + let title = match self.screen { + SubagentSelectorScreen::Menu => " Subagents ", + SubagentSelectorScreen::List => " List Subagents (current mode) ", + SubagentSelectorScreen::Configure => " Enable/Disable Subagents (Space/Enter Toggle) ", + }; let block = Block::default() .borders(Borders::ALL) .border_style(theme.style(StyleKind::Primary)) .style(Style::default().bg(theme.background)) - .title(" Select Subagent (↑↓ Navigate, Enter Select, Esc Cancel) "); + .title(title); let list = List::new(list_items) .block(block) @@ -171,7 +225,7 @@ impl SubagentSelectorState { } /// Handle mouse events - pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { if !self.visible { return None; } @@ -233,10 +287,88 @@ impl SubagentSelectorState { let offset = self.list_state.offset(); let index = (row - inner_y) as usize + offset; - if index >= self.items.len() { + if index >= self.len() { return None; } Some(index) } + + fn render_items(&self, theme: &Theme) -> Vec> { + match self.screen { + SubagentSelectorScreen::Menu => vec![ + ListItem::new(Line::from(vec![ + Span::styled( + "List subagents", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + "Show subagents available to the current mode", + theme.style(StyleKind::Muted), + ), + ])), + ListItem::new(Line::from(vec![ + Span::styled( + "Enable/disable subagents", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + "Toggle all discovered subagents for this mode", + theme.style(StyleKind::Muted), + ), + ])), + ], + SubagentSelectorScreen::List => self + .items + .iter() + .map(|subagent| self.render_subagent_line(subagent, theme, false)) + .collect(), + SubagentSelectorScreen::Configure => self + .items + .iter() + .map(|subagent| self.render_subagent_line(subagent, theme, true)) + .collect(), + } + } + + fn render_subagent_line( + &self, + subagent: &SubagentItem, + theme: &Theme, + include_checkbox: bool, + ) -> ListItem<'static> { + let source_marker = match subagent.source.as_str() { + "builtin" => "B", + "project" => "P", + "user" => "U", + _ => "?", + }; + let source_style = match subagent.source.as_str() { + "builtin" => theme.style(StyleKind::Success), + "project" => theme.style(StyleKind::Info), + _ => theme.style(StyleKind::Muted), + }; + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let desc_style = theme.style(StyleKind::Muted); + + let mut spans = Vec::new(); + if include_checkbox { + spans.push(Span::styled( + if subagent.enabled { "[x] " } else { "[ ] " }, + if subagent.enabled { + theme.style(StyleKind::Success) + } else { + theme.style(StyleKind::Muted) + }, + )); + } + spans.push(Span::styled(format!("[{}] ", source_marker), source_style)); + spans.push(Span::styled(subagent.name.clone(), name_style)); + spans.push(Span::raw(" ")); + spans.push(Span::styled(subagent.description.clone(), desc_style)); + + ListItem::new(Line::from(spans)) + } } From 1e7f1db11686b2bc7d797a2470334627dd726a6e Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sat, 16 May 2026 17:25:04 +0800 Subject: [PATCH 03/76] feat(cli): redesign built-in themes and startup theme switching --- src/apps/cli/src/commands.rs | 4 + src/apps/cli/src/config.rs | 2 +- src/apps/cli/src/main.rs | 3 +- src/apps/cli/src/ui/agent_selector.rs | 4 +- src/apps/cli/src/ui/chat/render.rs | 8 +- src/apps/cli/src/ui/command_menu.rs | 4 +- src/apps/cli/src/ui/command_palette.rs | 21 +- src/apps/cli/src/ui/mcp_add_dialog.rs | 10 +- src/apps/cli/src/ui/mcp_selector.rs | 4 +- src/apps/cli/src/ui/model_config_form.rs | 8 +- src/apps/cli/src/ui/model_selector.rs | 4 +- src/apps/cli/src/ui/provider_selector.rs | 8 +- src/apps/cli/src/ui/question.rs | 6 +- src/apps/cli/src/ui/session_selector.rs | 2 +- src/apps/cli/src/ui/skill_selector.rs | 4 +- src/apps/cli/src/ui/startup.rs | 229 ++++++++++++++-- src/apps/cli/src/ui/subagent_selector.rs | 4 +- src/apps/cli/src/ui/theme.rs | 146 +++++++++- src/apps/cli/src/ui/theme_selector.rs | 4 +- src/apps/cli/themes/presets/bitfun-cyber.json | 56 ++++ src/apps/cli/themes/presets/bitfun-dark.json | 56 ++++ .../cli/themes/presets/bitfun-ink-night.json | 56 ++++ src/apps/cli/themes/presets/bitfun-light.json | 56 ++++ .../cli/themes/presets/bitfun-midnight.json | 56 ++++ .../themes/presets/bitfun-tokyo-night.json | 56 ++++ src/apps/cli/themes/presets/cursor.json | 249 ------------------ src/apps/cli/themes/presets/everforest.json | 241 ----------------- src/apps/cli/themes/presets/github.json | 233 ---------------- src/apps/cli/themes/presets/one-dark.json | 84 ------ src/apps/cli/themes/presets/opencode.json | 245 ----------------- src/apps/cli/themes/presets/tokyonight.json | 243 ----------------- 31 files changed, 742 insertions(+), 1364 deletions(-) create mode 100644 src/apps/cli/themes/presets/bitfun-cyber.json create mode 100644 src/apps/cli/themes/presets/bitfun-dark.json create mode 100644 src/apps/cli/themes/presets/bitfun-ink-night.json create mode 100644 src/apps/cli/themes/presets/bitfun-light.json create mode 100644 src/apps/cli/themes/presets/bitfun-midnight.json create mode 100644 src/apps/cli/themes/presets/bitfun-tokyo-night.json delete mode 100644 src/apps/cli/themes/presets/cursor.json delete mode 100644 src/apps/cli/themes/presets/everforest.json delete mode 100644 src/apps/cli/themes/presets/github.json delete mode 100644 src/apps/cli/themes/presets/one-dark.json delete mode 100644 src/apps/cli/themes/presets/opencode.json delete mode 100644 src/apps/cli/themes/presets/tokyonight.json diff --git a/src/apps/cli/src/commands.rs b/src/apps/cli/src/commands.rs index 7b1c7b3c84..71b8f952fd 100644 --- a/src/apps/cli/src/commands.rs +++ b/src/apps/cli/src/commands.rs @@ -84,6 +84,10 @@ pub const STARTUP_COMMAND_SPECS: &[CommandSpec] = &[ name: "/models", description: "Select model for all modes", }, + CommandSpec { + name: "/theme", + description: "Switch UI theme", + }, CommandSpec { name: "/connect", description: "Add a new AI model configuration", diff --git a/src/apps/cli/src/config.rs b/src/apps/cli/src/config.rs index 6fc4f51e81..dad45048ba 100644 --- a/src/apps/cli/src/config.rs +++ b/src/apps/cli/src/config.rs @@ -73,7 +73,7 @@ impl Default for UiConfig { fn default() -> Self { Self { theme: "dark".to_string(), - theme_id: "cursor".to_string(), + theme_id: "bitfun-dark".to_string(), show_tips: true, animation: true, color_scheme: "default".to_string(), diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index b28b5e92b8..6a41f383a9 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -366,7 +366,7 @@ async fn shutdown_mcp_servers() { /// Run the full interactive TUI flow: loading screen → startup page → chat async fn run_interactive( - config: CliConfig, + _config: CliConfig, default_agent: String, _workspace_str: String, ) -> Result<()> { @@ -412,6 +412,7 @@ async fn run_interactive( let agent_type = startup_page.agent_type().to_string(); // Use the current project workspace selected at process start. let workspace = startup_page.workspace(); + let config = startup_page.config().clone(); let mut chat_mode = ChatMode::new(config, agent_type, workspace, &agentic_system); if let Some(session_id) = restore_session_id { chat_mode = chat_mode.with_restore_session(session_id); diff --git a/src/apps/cli/src/ui/agent_selector.rs b/src/apps/cli/src/ui/agent_selector.rs index b38d8b4444..b66a529469 100644 --- a/src/apps/cli/src/ui/agent_selector.rs +++ b/src/apps/cli/src/ui/agent_selector.rs @@ -6,7 +6,7 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, @@ -169,7 +169,7 @@ impl AgentSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs index 7c1df10121..309b9af4b6 100644 --- a/src/apps/cli/src/ui/chat/render.rs +++ b/src/apps/cli/src/ui/chat/render.rs @@ -2,6 +2,10 @@ impl ChatView { /// Render interface pub fn render(&mut self, frame: &mut Frame, chat_state: &ChatState) { let size = frame.area(); + frame.render_widget( + Block::default().style(Style::default().bg(self.theme.background)), + size, + ); // Dynamic input area height: 2 (borders) + visible content lines, capped at 8+2=10 let max_input_content_lines: u16 = 8; @@ -91,7 +95,7 @@ impl ChatView { .style(Style::default().bg(self.theme.background)); let title_style = Style::default() - .fg(ratatui::style::Color::Rgb(147, 51, 234)) + .fg(self.theme.primary) .add_modifier(Modifier::BOLD); let text = vec![Line::from(vec![ @@ -119,6 +123,7 @@ impl ChatView { let block = Block::default() .borders(Borders::ALL) .border_style(self.theme.style(StyleKind::Border)) + .style(Style::default().bg(self.theme.background)) .title(title); let inner = block.inner(area); @@ -152,6 +157,7 @@ impl ChatView { let paragraph = Paragraph::new(welcome) .alignment(Alignment::Center) + .style(Style::default().bg(self.theme.background)) .wrap(Wrap { trim: true }); frame.render_widget(paragraph, inner); diff --git a/src/apps/cli/src/ui/command_menu.rs b/src/apps/cli/src/ui/command_menu.rs index cb052715fd..0872f623b3 100644 --- a/src/apps/cli/src/ui/command_menu.rs +++ b/src/apps/cli/src/ui/command_menu.rs @@ -3,7 +3,7 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, @@ -149,7 +149,7 @@ impl CommandMenuState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 9f9c289fed..61a100e31b 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -6,7 +6,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, Frame, @@ -79,6 +79,13 @@ pub fn default_palette_items() -> Vec { description: "Add a new AI model configuration".into(), group: "Models".into(), }, + // Appearance group + PaletteItem { + id: "theme".into(), + label: "Theme".into(), + description: "Switch UI theme".into(), + group: "Appearance".into(), + }, // Agent group PaletteItem { id: "switch_agent".into(), @@ -124,6 +131,12 @@ fn build_suggested_items() -> Vec { description: "Switch agent mode".into(), group: "Suggested".into(), }, + PaletteItem { + id: "theme".into(), + label: "Theme".into(), + description: "Switch UI theme".into(), + group: "Suggested".into(), + }, PaletteItem { id: "new_session".into(), label: "New session".into(), @@ -655,14 +668,16 @@ impl CommandPaletteState { let label_style = if is_selected { Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD) } else { theme.style(StyleKind::Primary) }; let desc_style = if is_selected { - Style::default().bg(theme.primary).fg(Color::White) + Style::default() + .bg(theme.primary) + .fg(theme.selection_foreground()) } else { theme.style(StyleKind::Muted) }; diff --git a/src/apps/cli/src/ui/mcp_add_dialog.rs b/src/apps/cli/src/ui/mcp_add_dialog.rs index 4183d1c79e..e79c513047 100644 --- a/src/apps/cli/src/ui/mcp_add_dialog.rs +++ b/src/apps/cli/src/ui/mcp_add_dialog.rs @@ -448,12 +448,18 @@ impl McpAddDialogState { // Selector let type_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; let local_style = if self.server_type == ServerType::Local { - Style::default().fg(Color::White).bg(theme.primary).add_modifier(Modifier::BOLD) + Style::default() + .fg(theme.selection_foreground()) + .bg(theme.primary) + .add_modifier(Modifier::BOLD) } else { theme.style(StyleKind::Muted) }; let remote_style = if self.server_type == ServerType::Remote { - Style::default().fg(Color::White).bg(theme.primary).add_modifier(Modifier::BOLD) + Style::default() + .fg(theme.selection_foreground()) + .bg(theme.primary) + .add_modifier(Modifier::BOLD) } else { theme.style(StyleKind::Muted) }; diff --git a/src/apps/cli/src/ui/mcp_selector.rs b/src/apps/cli/src/ui/mcp_selector.rs index dadefbe69d..9e2037d6d9 100644 --- a/src/apps/cli/src/ui/mcp_selector.rs +++ b/src/apps/cli/src/ui/mcp_selector.rs @@ -12,7 +12,7 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, @@ -290,7 +290,7 @@ impl McpSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/src/ui/model_config_form.rs b/src/apps/cli/src/ui/model_config_form.rs index 4e84e48ce6..401ae302c9 100644 --- a/src/apps/cli/src/ui/model_config_form.rs +++ b/src/apps/cli/src/ui/model_config_form.rs @@ -850,7 +850,7 @@ impl ModelConfigFormState { let style = if selected && is_active { Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD) } else if selected { theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) @@ -881,7 +881,7 @@ impl ModelConfigFormState { let style = if selected && is_active { Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD) } else if selected { theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) @@ -919,7 +919,7 @@ impl ModelConfigFormState { if is_active { Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD) } else { Style::default() @@ -933,7 +933,7 @@ impl ModelConfigFormState { if is_active { Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD) } else { theme.style(StyleKind::Muted) diff --git a/src/apps/cli/src/ui/model_selector.rs b/src/apps/cli/src/ui/model_selector.rs index 23f2464d21..8d6b6b4867 100644 --- a/src/apps/cli/src/ui/model_selector.rs +++ b/src/apps/cli/src/ui/model_selector.rs @@ -6,7 +6,7 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::{Alignment, Rect}, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, Frame, @@ -178,7 +178,7 @@ impl ModelSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/src/ui/provider_selector.rs b/src/apps/cli/src/ui/provider_selector.rs index d4e1920e8c..a5a04fdc1a 100644 --- a/src/apps/cli/src/ui/provider_selector.rs +++ b/src/apps/cli/src/ui/provider_selector.rs @@ -8,7 +8,7 @@ use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, Frame, @@ -490,14 +490,16 @@ impl ProviderSelectorState { let label_style = if is_selected { Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD) } else { theme.style(StyleKind::Primary) }; let desc_style = if is_selected { - Style::default().bg(theme.primary).fg(Color::White) + Style::default() + .bg(theme.primary) + .fg(theme.selection_foreground()) } else { theme.style(StyleKind::Muted) }; diff --git a/src/apps/cli/src/ui/question.rs b/src/apps/cli/src/ui/question.rs index e37d2b2330..dd9ed34c0d 100644 --- a/src/apps/cli/src/ui/question.rs +++ b/src/apps/cli/src/ui/question.rs @@ -494,7 +494,7 @@ fn render_question_page( tab_spans.push(Span::styled( format!(" {} ", qd.header), Style::default() - .fg(theme.background) + .fg(theme.selection_foreground()) .bg(theme.primary) .add_modifier(Modifier::BOLD), )); @@ -514,7 +514,7 @@ fn render_question_page( tab_spans.push(Span::styled( " Confirm ", Style::default() - .fg(theme.background) + .fg(theme.selection_foreground()) .bg(theme.primary) .add_modifier(Modifier::BOLD), )); @@ -692,7 +692,7 @@ fn render_confirm_page( tab_spans.push(Span::styled( " Confirm ", Style::default() - .fg(theme.background) + .fg(theme.selection_foreground()) .bg(theme.primary) .add_modifier(Modifier::BOLD), )); diff --git a/src/apps/cli/src/ui/session_selector.rs b/src/apps/cli/src/ui/session_selector.rs index 79d1cf1639..18be67b6f4 100644 --- a/src/apps/cli/src/ui/session_selector.rs +++ b/src/apps/cli/src/ui/session_selector.rs @@ -326,7 +326,7 @@ impl SessionSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/src/ui/skill_selector.rs b/src/apps/cli/src/ui/skill_selector.rs index a82756dd2e..5d1bcba992 100644 --- a/src/apps/cli/src/ui/skill_selector.rs +++ b/src/apps/cli/src/ui/skill_selector.rs @@ -5,7 +5,7 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, @@ -216,7 +216,7 @@ impl SkillSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 921e62b37c..7aecf1b881 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -9,9 +9,10 @@ use super::skill_selector::{SkillItem, SkillSelectorAction, SkillSelectorState}; use super::subagent_selector::{SubagentItem, SubagentSelectorAction, SubagentSelectorState}; use super::text_input::{TextInput, TextInputStyle}; use super::theme::{ - builtin_theme_json, resolve_appearance, resolve_effective_color_scheme, EffectiveColorScheme, - Theme, + builtin_theme_ids, builtin_theme_json, resolve_appearance, resolve_effective_color_scheme, + Appearance, EffectiveColorScheme, Theme, }; +use super::theme_selector::{ThemeItem, ThemeSelectorState}; use crate::commands::STARTUP_COMMAND_SPECS; use crate::config::CliConfig; /// Startup page module @@ -26,9 +27,9 @@ use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifier use ratatui::{ backend::Backend, layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, - widgets::Paragraph, + widgets::{Block, Paragraph}, Frame, Terminal, }; use std::sync::Arc; @@ -57,6 +58,7 @@ pub enum PopupType { SessionSelector, SkillSelector, SubagentSelector, + ThemeSelector, ProviderSelector, ModelConfigForm, } @@ -133,6 +135,7 @@ const TIPS: &[&str] = &[ "Use /sessions to list and continue previous conversations", "Press Ctrl+O to expand/collapse tool output", "Use /skills to browse and execute available skills", + "Use /theme to switch the CLI theme", "Use /acp to copy editor setup commands for ACP hosts", "Press Up/Down to cycle through input history", "Use /new to start a fresh conversation session", @@ -144,6 +147,8 @@ pub struct StartupPage { text_input: TextInput, /// Theme theme: Theme, + /// CLI config, including persisted theme preference. + config: CliConfig, /// Current tip text tip: &'static str, @@ -159,8 +164,10 @@ pub struct StartupPage { session_selector: SessionSelectorState, skill_selector: SkillSelectorState, subagent_selector: SubagentSelectorState, + theme_selector: ThemeSelectorState, provider_selector: ProviderSelectorState, model_config_form: ModelConfigFormState, + theme_preview_original: Option, // ── System context ── coordinator: Arc, @@ -222,6 +229,7 @@ impl StartupPage { let mut page = Self { text_input: TextInput::new(), theme, + config, tip: TIPS[tip_index], command_menu: CommandMenuState::new(), command_palette: CommandPaletteState::new(), @@ -230,8 +238,10 @@ impl StartupPage { session_selector: SessionSelectorState::new(), skill_selector: SkillSelectorState::new(), subagent_selector: SubagentSelectorState::new(), + theme_selector: ThemeSelectorState::new(), provider_selector: ProviderSelectorState::new(), model_config_form: ModelConfigFormState::new(), + theme_preview_original: None, coordinator, agent_type: default_agent, model_display_name: String::new(), @@ -266,6 +276,11 @@ impl StartupPage { } } + /// Get the current CLI config after startup-page edits. + pub fn config(&self) -> &CliConfig { + &self.config + } + fn workspace_path_buf(&self) -> std::path::PathBuf { self.workspace() .map(std::path::PathBuf::from) @@ -281,6 +296,7 @@ impl StartupPage { || self.session_selector.is_visible() || self.skill_selector.is_visible() || self.subagent_selector.is_visible() + || self.theme_selector.is_visible() || self.provider_selector.is_visible() || self.model_config_form.is_visible() } @@ -376,6 +392,11 @@ impl StartupPage { if let PaletteAction::Execute(id) = action { let _ = self.handle_palette_action(&id); } + } else if self.theme_selector.captures_mouse(&mouse) { + self.theme_selector.handle_mouse_event(&mouse); + if let Some(selected) = self.theme_selector.selected_item().cloned() { + self.preview_theme_selection(&selected); + } } else if self.provider_selector.captures_mouse(&mouse) { if let Some(selection) = self.provider_selector.handle_mouse_event(&mouse) { self.handle_provider_selection(selection); @@ -399,6 +420,10 @@ impl StartupPage { fn render(&mut self, frame: &mut Frame) { let size = frame.area(); + frame.render_widget( + Block::default().style(Style::default().bg(self.theme.background)), + size, + ); let chunks = Layout::default() .direction(Direction::Vertical) @@ -429,6 +454,7 @@ impl StartupPage { self.session_selector.render(frame, size, &self.theme); self.skill_selector.render(frame, size, &self.theme); self.subagent_selector.render(frame, size, &self.theme); + self.theme_selector.render(frame, size, &self.theme); self.provider_selector.render(frame, size, &self.theme); self.model_config_form.render_mut(frame, size, &self.theme); @@ -492,6 +518,7 @@ impl StartupPage { fn render_input(&mut self, frame: &mut Frame, area: Rect) { let highlight_color = self.theme.primary; + let input_bg = self.input_background(); // Split: 2 cols for left bar, rest for content let h_chunks = Layout::default() @@ -504,9 +531,14 @@ impl StartupPage { // Left bar: full-height ┃ let bar_lines: Vec = (0..area.height) - .map(|_| Line::from(Span::styled(" ┃", Style::default().fg(highlight_color)))) + .map(|_| { + Line::from(Span::styled( + " ┃", + Style::default().fg(highlight_color).bg(input_bg), + )) + }) .collect(); - let bar = Paragraph::new(bar_lines); + let bar = Paragraph::new(bar_lines).style(Style::default().bg(input_bg)); frame.render_widget(bar, h_chunks[0]); // Content area with background @@ -514,7 +546,7 @@ impl StartupPage { // Fill background let bg = Paragraph::new(vec![Line::from(""); content_area.height as usize]) - .style(Style::default().bg(self.theme.background_element)); + .style(Style::default().bg(input_bg)); frame.render_widget(bg, content_area); // Inner content with padding @@ -540,8 +572,8 @@ impl StartupPage { first_line_prefix: "", continuation_prefix: "", placeholder: "Ask anything... or type / for commands".to_string(), - text_style: Style::default().fg(Color::White), - placeholder_style: Style::default().fg(self.theme.muted), + text_style: Style::default().fg(self.theme.command_text).bg(input_bg), + placeholder_style: Style::default().fg(self.theme.muted).bg(input_bg), }; self.text_input.render(frame, text_area, &style, true); @@ -569,6 +601,10 @@ impl StartupPage { } } + fn input_background(&self) -> ratatui::style::Color { + self.theme.input_background + } + fn render_tip_or_status(&self, frame: &mut Frame, area: Rect) { let line = if let Some(ref status) = self.status { Line::from(vec![ @@ -633,12 +669,12 @@ impl StartupPage { ]; let colors = [ - Color::Rgb(255, 0, 100), - Color::Rgb(255, 100, 0), - Color::Rgb(255, 200, 0), - Color::Rgb(100, 255, 0), - Color::Rgb(0, 255, 200), - Color::Rgb(100, 100, 255), + self.theme.primary, + self.theme.info, + self.theme.success, + self.theme.warning, + self.theme.error, + self.theme.muted, ]; for (i, line) in logo.iter().enumerate() { @@ -659,11 +695,11 @@ impl StartupPage { ]; let colors = [ - Color::Cyan, - Color::Blue, - Color::Magenta, - Color::Red, - Color::Yellow, + self.theme.primary, + self.theme.info, + self.theme.success, + self.theme.warning, + self.theme.error, ]; for (i, line) in logo.iter().enumerate() { @@ -680,14 +716,14 @@ impl StartupPage { lines.push(Line::from(Span::styled( "AI agent-driven command-line programming assistant", Style::default() - .fg(Color::Gray) + .fg(self.theme.muted) .add_modifier(Modifier::ITALIC), ))); let version = format!("v{}", env!("CARGO_PKG_VERSION")); lines.push(Line::from(Span::styled( version, - Style::default().fg(Color::DarkGray), + Style::default().fg(self.theme.muted), ))); let paragraph = Paragraph::new(lines).alignment(Alignment::Center); @@ -723,6 +759,32 @@ impl StartupPage { // ── Selector popups intercept all keys when active ── + if self.theme_selector.is_visible() { + match key.code { + KeyCode::Up => { + self.theme_selector.move_up(); + if let Some(selected) = self.theme_selector.selected_item().cloned() { + self.preview_theme_selection(&selected); + } + } + KeyCode::Down => { + self.theme_selector.move_down(); + if let Some(selected) = self.theme_selector.selected_item().cloned() { + self.preview_theme_selection(&selected); + } + } + KeyCode::Enter => { + if let Some(selected) = self.theme_selector.confirm_selection() { + self.theme_selector.hide(); + self.apply_theme_selection(&selected); + } + } + KeyCode::Esc => self.navigate_back(), + _ => {} + } + return None; + } + if self.model_selector.is_visible() { match key.code { KeyCode::Up => self.model_selector.move_up(), @@ -1001,6 +1063,10 @@ impl StartupPage { self.push_current_popup_to_stack(); self.provider_selector.show(); } + // Appearance group + "theme" => { + self.show_theme_selector(); + } // Agent group "switch_agent" => { self.show_agent_selector(); @@ -1046,6 +1112,9 @@ impl StartupPage { "/models" => { self.show_model_selector(); } + "/theme" => { + self.show_theme_selector(); + } "/connect" => { self.push_current_popup_to_stack(); self.provider_selector.show(); @@ -1113,6 +1182,9 @@ impl StartupPage { } else if self.subagent_selector.is_visible() { self.popup_stack.push(PopupType::SubagentSelector); self.subagent_selector.hide(); + } else if self.theme_selector.is_visible() { + self.popup_stack.push(PopupType::ThemeSelector); + self.theme_selector.hide(); } else if self.provider_selector.is_visible() { self.popup_stack.push(PopupType::ProviderSelector); self.provider_selector.hide(); @@ -1550,6 +1622,113 @@ impl StartupPage { } } + fn show_theme_selector(&mut self) { + let themes = self.list_available_themes(); + if themes.is_empty() { + self.status = Some("No themes available.".to_string()); + return; + } + + self.push_current_popup_to_stack(); + self.begin_theme_preview(); + self.theme_selector + .show(themes, Some(self.config.ui.theme_id.clone())); + if let Some(selected) = self.theme_selector.selected_item().cloned() { + self.preview_theme_selection(&selected); + } + } + + fn list_available_themes(&self) -> Vec { + let mut themes: Vec = builtin_theme_ids() + .into_iter() + .map(|id| ThemeItem { id }) + .collect(); + + themes.sort_by(|a, b| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase())); + themes.dedup_by(|a, b| a.id == b.id); + themes + } + + fn current_base_theme(&self) -> (Theme, Appearance, EffectiveColorScheme) { + let appearance = resolve_appearance(&self.config.ui.theme); + let scheme = resolve_effective_color_scheme(&self.config.ui.color_scheme); + let base_is_light = appearance.is_light(); + let base = match (base_is_light, scheme) { + (_, EffectiveColorScheme::Monochrome) => Theme::monochrome(), + (true, EffectiveColorScheme::Ansi16) => Theme::light_ansi16(), + (true, EffectiveColorScheme::Truecolor) => Theme::light(), + (false, EffectiveColorScheme::Ansi16) => Theme::dark_ansi16(), + (false, EffectiveColorScheme::Truecolor) => Theme::dark(), + }; + + (base, appearance, scheme) + } + + fn resolve_theme_by_id( + &self, + base: Theme, + appearance: Appearance, + scheme: EffectiveColorScheme, + id: &str, + ) -> Theme { + if scheme == EffectiveColorScheme::Monochrome { + return Theme::monochrome(); + } + + let id = id.trim(); + if id.is_empty() { + return base; + } + + if let Some(json) = builtin_theme_json(id) { + return base + .apply_opencode_theme_json(json, appearance) + .unwrap_or(base) + .with_effective_scheme(scheme); + } + + base + } + + fn begin_theme_preview(&mut self) { + if self.theme_preview_original.is_none() { + self.theme_preview_original = Some(self.theme.clone()); + } + } + + fn cancel_theme_preview(&mut self) { + if let Some(original) = self.theme_preview_original.take() { + self.theme = original; + } + } + + fn preview_theme_selection(&mut self, theme: &ThemeItem) { + self.begin_theme_preview(); + let (base, appearance, scheme) = self.current_base_theme(); + self.theme = self.resolve_theme_by_id(base, appearance, scheme, &theme.id); + self.status = Some(format!( + "Preview theme: {} (Enter apply, Esc cancel)", + theme.id + )); + } + + fn apply_theme_selection(&mut self, theme: &ThemeItem) { + let (base, appearance, scheme) = self.current_base_theme(); + self.config.ui.theme_id = theme.id.clone(); + + match self.config.save() { + Ok(()) => { + self.status = Some(format!("Theme set to: {}", theme.id)); + } + Err(e) => { + self.status = Some(format!("Failed to save config: {}", e)); + } + } + + self.theme = self.resolve_theme_by_id(base, appearance, scheme, &theme.id); + self.theme_preview_original = None; + } + fn show_skill_selector(&mut self) { self.push_current_popup_to_stack(); self.skill_selector.show_menu(); @@ -1860,6 +2039,9 @@ impl StartupPage { self.skill_selector.hide(); } else if self.subagent_selector.is_visible() { self.subagent_selector.hide(); + } else if self.theme_selector.is_visible() { + self.theme_selector.hide(); + self.cancel_theme_preview(); } else if self.provider_selector.is_visible() { self.provider_selector.hide(); } else if self.model_config_form.is_visible() { @@ -1875,6 +2057,7 @@ impl StartupPage { PopupType::SessionSelector => self.session_selector.reshow(), PopupType::SkillSelector => self.skill_selector.reshow(), PopupType::SubagentSelector => self.subagent_selector.reshow(), + PopupType::ThemeSelector => self.theme_selector.reshow(), PopupType::ProviderSelector => self.provider_selector.reshow(), PopupType::ModelConfigForm => self.model_config_form.reshow(), } @@ -1889,6 +2072,8 @@ impl StartupPage { self.session_selector.hide(); self.skill_selector.hide(); self.subagent_selector.hide(); + self.theme_selector.hide(); + self.cancel_theme_preview(); self.provider_selector.hide(); self.model_config_form.hide(); self.popup_stack.clear(); diff --git a/src/apps/cli/src/ui/subagent_selector.rs b/src/apps/cli/src/ui/subagent_selector.rs index f74fd8b571..55720b6861 100644 --- a/src/apps/cli/src/ui/subagent_selector.rs +++ b/src/apps/cli/src/ui/subagent_selector.rs @@ -5,7 +5,7 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, @@ -216,7 +216,7 @@ impl SubagentSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/src/ui/theme.rs b/src/apps/cli/src/ui/theme.rs index e0750acc25..a15645ca90 100644 --- a/src/apps/cli/src/ui/theme.rs +++ b/src/apps/cli/src/ui/theme.rs @@ -1,11 +1,15 @@ /// Theme and style definitions -use ratatui::style::{Color, Modifier, Style}; -use std::io::{IsTerminal, Read}; use std::collections::{HashMap, HashSet}; +use std::io::IsTerminal; use std::path::Path; use std::time::{Duration, Instant}; + use once_cell::sync::Lazy; +use ratatui::style::{Color, Modifier, Style}; + +#[cfg(unix)] +use std::io::Read; #[derive(Debug, Clone)] pub struct Theme { @@ -21,6 +25,7 @@ pub struct Theme { // Panel backgrounds (inspired by opencode theme) pub background_panel: Color, pub background_element: Color, + pub input_background: Color, // Diff colors pub diff_added_fg: Color, @@ -261,6 +266,7 @@ impl Theme { background_panel: Color::Rgb(30, 38, 55), background_element: Color::Rgb(40, 50, 70), + input_background: Color::Rgb(40, 50, 70), diff_added_fg: Color::Rgb(34, 197, 94), diff_removed_fg: Color::Rgb(239, 68, 68), @@ -293,6 +299,7 @@ impl Theme { background_panel: Color::Reset, background_element: Color::Reset, + input_background: Color::Reset, diff_added_fg: Color::Green, diff_removed_fg: Color::Red, @@ -325,6 +332,7 @@ impl Theme { background_panel: Color::Rgb(243, 244, 246), background_element: Color::Rgb(229, 231, 235), + input_background: Color::Rgb(229, 231, 235), diff_added_fg: Color::Rgb(22, 163, 74), diff_removed_fg: Color::Rgb(220, 38, 38), @@ -357,6 +365,7 @@ impl Theme { background_panel: Color::Reset, background_element: Color::Reset, + input_background: Color::Reset, diff_added_fg: Color::Green, diff_removed_fg: Color::Red, @@ -389,6 +398,7 @@ impl Theme { background_panel: Color::Reset, background_element: Color::Reset, + input_background: Color::Reset, diff_added_fg: Color::Reset, diff_removed_fg: Color::Reset, @@ -423,6 +433,8 @@ impl Theme { self.border = to_ansi16(self.border); self.background_panel = to_ansi16(self.background_panel); self.background_element = to_ansi16(self.background_element); + // Keep the startup input panel as the preset-defined RGB color. + // Otherwise subtle dark theme variants collapse to the same ANSI black/blue. self.diff_added_fg = to_ansi16(self.diff_added_fg); self.diff_removed_fg = to_ansi16(self.diff_removed_fg); self.diff_added_bg = to_ansi16(self.diff_added_bg); @@ -457,6 +469,11 @@ impl Theme { border: resolved.border.unwrap_or(fallback.border), background_panel: resolved.background_panel.unwrap_or(fallback.background_panel), background_element: resolved.background_element.unwrap_or(fallback.background_element), + input_background: resolved.input_background.unwrap_or( + resolved + .background_element + .unwrap_or(fallback.input_background), + ), diff_added_fg: resolved.diff_added_fg.unwrap_or(fallback.diff_added_fg), diff_removed_fg: resolved.diff_removed_fg.unwrap_or(fallback.diff_removed_fg), diff_added_bg: resolved.diff_added_bg.unwrap_or(fallback.diff_added_bg), @@ -496,6 +513,47 @@ impl Theme { StyleKind::DiffLineNumber => Style::default().fg(self.diff_line_number), } } + + pub fn selection_foreground(&self) -> Color { + readable_foreground_for(self.primary) + } +} + +fn readable_foreground_for(background: Color) -> Color { + match background { + Color::Reset => Color::Reset, + Color::Black | Color::Red | Color::Green | Color::Blue | Color::Magenta | Color::DarkGray => { + Color::White + } + Color::Yellow + | Color::Gray + | Color::LightRed + | Color::LightGreen + | Color::LightYellow + | Color::LightBlue + | Color::LightMagenta + | Color::LightCyan + | Color::White + | Color::Cyan => Color::Black, + Color::Indexed(idx) => readable_foreground_for(idx_to_ansi16(idx)), + Color::Rgb(r, g, b) => { + let lum = relative_luminance(r, g, b); + if lum > 0.36 { Color::Black } else { Color::White } + } + } +} + +fn relative_luminance(r: u8, g: u8, b: u8) -> f64 { + fn channel(v: u8) -> f64 { + let v = v as f64 / 255.0; + if v <= 0.03928 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + } + + 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) } fn to_ansi16(c: Color) -> Color { @@ -679,45 +737,45 @@ static BUILTIN_OPENCODE_THEMES: Lazy> = HashMap::from([ parse( - "cursor", + "bitfun-cyber", include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/themes/presets/cursor.json" + "/themes/presets/bitfun-cyber.json" )), ), parse( - "everforest", + "bitfun-dark", include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/themes/presets/everforest.json" + "/themes/presets/bitfun-dark.json" )), ), parse( - "github", + "bitfun-ink-night", include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/themes/presets/github.json" + "/themes/presets/bitfun-ink-night.json" )), ), parse( - "one-dark", + "bitfun-light", include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/themes/presets/one-dark.json" + "/themes/presets/bitfun-light.json" )), ), parse( - "opencode", + "bitfun-midnight", include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/themes/presets/opencode.json" + "/themes/presets/bitfun-midnight.json" )), ), parse( - "tokyonight", + "bitfun-tokyo-night", include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/themes/presets/tokyonight.json" + "/themes/presets/bitfun-tokyo-night.json" )), ), ]) @@ -745,6 +803,7 @@ struct ResolvedTokens { border: Option, background_panel: Option, background_element: Option, + input_background: Option, diff_added_fg: Option, diff_removed_fg: Option, diff_added_bg: Option, @@ -774,6 +833,7 @@ fn resolve_opencode_theme(json: &OpencodeThemeJson, appearance: Appearance) -> a tokens.border = resolve_key(json, &defs, "border", mode)?; tokens.background_panel = resolve_key(json, &defs, "backgroundPanel", mode)?; tokens.background_element = resolve_key(json, &defs, "backgroundElement", mode)?; + tokens.input_background = resolve_key(json, &defs, "inputBackground", mode)?; tokens.diff_added_fg = resolve_key(json, &defs, "diffAdded", mode)?; tokens.diff_removed_fg = resolve_key(json, &defs, "diffRemoved", mode)?; @@ -844,6 +904,17 @@ fn resolve_color_string( let g = u8::from_str_radix(&hex[2..4], 16)?; let b = u8::from_str_radix(&hex[4..6], 16)?; return Ok(Color::Rgb(r, g, b)); + } else if hex.len() == 8 { + let r = u8::from_str_radix(&hex[0..2], 16)?; + let g = u8::from_str_radix(&hex[2..4], 16)?; + let b = u8::from_str_radix(&hex[4..6], 16)?; + let a = u8::from_str_radix(&hex[6..8], 16)?; + let base = if mode == "light" { 255 } else { 0 }; + return Ok(Color::Rgb( + blend_alpha_channel(r, a, base), + blend_alpha_channel(g, a, base), + blend_alpha_channel(b, a, base), + )); } } @@ -861,3 +932,50 @@ fn resolve_color_string( anyhow::bail!("Theme color reference \"{}\" not found", t) } + +fn blend_alpha_channel(fg: u8, alpha: u8, bg: u8) -> u8 { + let fg = fg as u16; + let alpha = alpha as u16; + let bg = bg as u16; + (((fg * alpha) + (bg * (255 - alpha)) + 127) / 255) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_themes_resolve_for_dark_and_light() { + for id in builtin_theme_ids() { + let json = builtin_theme_json(&id).expect("built-in theme id should resolve"); + Theme::dark() + .apply_opencode_theme_json(json, Appearance::Dark) + .unwrap_or_else(|err| panic!("failed to resolve dark theme {id}: {err}")); + Theme::light() + .apply_opencode_theme_json(json, Appearance::Light) + .unwrap_or_else(|err| panic!("failed to resolve light theme {id}: {err}")); + } + } + + #[test] + fn eight_digit_hex_colors_are_supported() { + let json = serde_json::from_str::( + r##"{ + "theme": { + "primary": { "dark": "#ffffff80", "light": "#00000080" } + } + }"##, + ) + .unwrap(); + + let dark = Theme::dark() + .apply_opencode_theme_json(&json, Appearance::Dark) + .unwrap(); + let light = Theme::light() + .apply_opencode_theme_json(&json, Appearance::Light) + .unwrap(); + + assert_eq!(dark.primary, Color::Rgb(128, 128, 128)); + assert_eq!(light.primary, Color::Rgb(127, 127, 127)); + } +} diff --git a/src/apps/cli/src/ui/theme_selector.rs b/src/apps/cli/src/ui/theme_selector.rs index 5feeaf38ba..17ec32f1e6 100644 --- a/src/apps/cli/src/ui/theme_selector.rs +++ b/src/apps/cli/src/ui/theme_selector.rs @@ -5,7 +5,7 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, @@ -161,7 +161,7 @@ impl ThemeSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/themes/presets/bitfun-cyber.json b/src/apps/cli/themes/presets/bitfun-cyber.json new file mode 100644 index 0000000000..ee7169442b --- /dev/null +++ b/src/apps/cli/themes/presets/bitfun-cyber.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#00e6ff", + "secondary": "#8a2be2", + "accent": "#8a2be2", + "error": "#ff0055", + "warning": "#ffcc00", + "success": "#00ff9f", + "info": "#00e6ff", + "text": "#e0f2ff", + "textMuted": "#7fadcc", + "background": "#101010", + "backgroundPanel": "#151515", + "backgroundElement": "#082a30", + "inputBackground": "#003f46", + "border": "#16404a", + "borderActive": "#00e6ff", + "borderSubtle": "#102b32", + "diffAdded": "#00ff9f", + "diffRemoved": "#ff0055", + "diffContext": "#7fadcc", + "diffHunkHeader": "#00e6ff", + "diffHighlightAdded": "#52ffc0", + "diffHighlightRemoved": "#ff4d88", + "diffAddedBg": "#08372a", + "diffRemovedBg": "#3d1325", + "diffContextBg": "#151515", + "diffLineNumber": "#5e879b", + "diffAddedLineNumberBg": "#0b4231", + "diffRemovedLineNumberBg": "#4a162b", + "markdownText": "#e0f2ff", + "markdownHeading": "#00e6ff", + "markdownLink": "#00e6ff", + "markdownLinkText": "#8a2be2", + "markdownCode": "#00ff9f", + "markdownBlockQuote": "#7fadcc", + "markdownEmph": "#ffcc00", + "markdownStrong": "#ffdd44", + "markdownHorizontalRule": "#16404a", + "markdownListItem": "#00e6ff", + "markdownListEnumeration": "#8a2be2", + "markdownImage": "#00e6ff", + "markdownImageText": "#8a2be2", + "markdownCodeBlock": "#e0f2ff", + "syntaxComment": "#7fadcc", + "syntaxKeyword": "#8a2be2", + "syntaxFunction": "#00e6ff", + "syntaxVariable": "#e0f2ff", + "syntaxString": "#00ff9f", + "syntaxNumber": "#ffcc00", + "syntaxType": "#ffdd44", + "syntaxOperator": "#ff0055", + "syntaxPunctuation": "#e0f2ff" + } +} diff --git a/src/apps/cli/themes/presets/bitfun-dark.json b/src/apps/cli/themes/presets/bitfun-dark.json new file mode 100644 index 0000000000..ec86dd9d0e --- /dev/null +++ b/src/apps/cli/themes/presets/bitfun-dark.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#60a5fa", + "secondary": "#8b5cf6", + "accent": "#8b5cf6", + "error": "#ef4444", + "warning": "#f59e0b", + "success": "#34d399", + "info": "#a1a1aa", + "text": "#e8e8e8", + "textMuted": "#858585", + "background": "#0e0e10", + "backgroundPanel": "#1c1c1f", + "backgroundElement": "#252528", + "inputBackground": "#252528", + "border": "#3b3b40", + "borderActive": "#60a5fa", + "borderSubtle": "#2b2b30", + "diffAdded": "#34d399", + "diffRemoved": "#ef4444", + "diffContext": "#858585", + "diffHunkHeader": "#60a5fa", + "diffHighlightAdded": "#6ee7b7", + "diffHighlightRemoved": "#f87171", + "diffAddedBg": "#12352d", + "diffRemovedBg": "#3a1f24", + "diffContextBg": "#1c1c1f", + "diffLineNumber": "#66666d", + "diffAddedLineNumberBg": "#163c32", + "diffRemovedLineNumberBg": "#432329", + "markdownText": "#e8e8e8", + "markdownHeading": "#60a5fa", + "markdownLink": "#60a5fa", + "markdownLinkText": "#8b5cf6", + "markdownCode": "#34d399", + "markdownBlockQuote": "#858585", + "markdownEmph": "#f59e0b", + "markdownStrong": "#e8e8e8", + "markdownHorizontalRule": "#3b3b40", + "markdownListItem": "#60a5fa", + "markdownListEnumeration": "#8b5cf6", + "markdownImage": "#60a5fa", + "markdownImageText": "#8b5cf6", + "markdownCodeBlock": "#e8e8e8", + "syntaxComment": "#6a737d", + "syntaxKeyword": "#c792ea", + "syntaxFunction": "#7dcfff", + "syntaxVariable": "#80d4ff", + "syntaxString": "#a5e844", + "syntaxNumber": "#f78c6c", + "syntaxType": "#ffc777", + "syntaxOperator": "#c792ea", + "syntaxPunctuation": "#e0e6f0" + } +} diff --git a/src/apps/cli/themes/presets/bitfun-ink-night.json b/src/apps/cli/themes/presets/bitfun-ink-night.json new file mode 100644 index 0000000000..607e76f922 --- /dev/null +++ b/src/apps/cli/themes/presets/bitfun-ink-night.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#d7b56d", + "secondary": "#79a88f", + "accent": "#b35d4b", + "error": "#d45b4f", + "warning": "#e0a34f", + "success": "#79a88f", + "info": "#8aa8b8", + "text": "#efe7d2", + "textMuted": "#a79b83", + "background": "#17110c", + "backgroundPanel": "#241b12", + "backgroundElement": "#3a2817", + "inputBackground": "#4a2d15", + "border": "#5a472b", + "borderActive": "#d7b56d", + "borderSubtle": "#44341f", + "diffAdded": "#6bc072", + "diffRemoved": "#e85555", + "diffContext": "#928f89", + "diffHunkHeader": "#d7b56d", + "diffHighlightAdded": "#93bfa9", + "diffHighlightRemoved": "#e07368", + "diffAddedBg": "#24362a", + "diffRemovedBg": "#3e221c", + "diffContextBg": "#241b12", + "diffLineNumber": "#8e8065", + "diffAddedLineNumberBg": "#2b4031", + "diffRemovedLineNumberBg": "#4a281f", + "markdownText": "#efe7d2", + "markdownHeading": "#d7b56d", + "markdownLink": "#d7b56d", + "markdownLinkText": "#79a88f", + "markdownCode": "#79a88f", + "markdownBlockQuote": "#a79b83", + "markdownEmph": "#e0a34f", + "markdownStrong": "#d7b56d", + "markdownHorizontalRule": "#5a472b", + "markdownListItem": "#d7b56d", + "markdownListEnumeration": "#79a88f", + "markdownImage": "#d7b56d", + "markdownImageText": "#79a88f", + "markdownCodeBlock": "#efe7d2", + "syntaxComment": "#a79b83", + "syntaxKeyword": "#b35d4b", + "syntaxFunction": "#d7b56d", + "syntaxVariable": "#efe7d2", + "syntaxString": "#79a88f", + "syntaxNumber": "#e0a34f", + "syntaxType": "#8aa8b8", + "syntaxOperator": "#b35d4b", + "syntaxPunctuation": "#efe7d2" + } +} diff --git a/src/apps/cli/themes/presets/bitfun-light.json b/src/apps/cli/themes/presets/bitfun-light.json new file mode 100644 index 0000000000..47887859f3 --- /dev/null +++ b/src/apps/cli/themes/presets/bitfun-light.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#475569", + "secondary": "#7c6b99", + "accent": "#64748b", + "error": "#c26565", + "warning": "#c08c42", + "success": "#5b9a6f", + "info": "#64748b", + "text": "#1e293b", + "textMuted": "#64748b", + "background": "#f3f3f5", + "backgroundPanel": "#ffffff", + "backgroundElement": "#e7ecf3", + "inputBackground": "#e7ecf3", + "border": "#cbd5e1", + "borderActive": "#475569", + "borderSubtle": "#d9dee7", + "diffAdded": "#4f8f64", + "diffRemoved": "#b85454", + "diffContext": "#64748b", + "diffHunkHeader": "#475569", + "diffHighlightAdded": "#3f7d54", + "diffHighlightRemoved": "#9f4444", + "diffAddedBg": "#e4f0e7", + "diffRemovedBg": "#f3dfdf", + "diffContextBg": "#ffffff", + "diffLineNumber": "#94a3b8", + "diffAddedLineNumberBg": "#d8eadc", + "diffRemovedLineNumberBg": "#ebd2d2", + "markdownText": "#1e293b", + "markdownHeading": "#475569", + "markdownLink": "#475569", + "markdownLinkText": "#7c6b99", + "markdownCode": "#5b9a6f", + "markdownBlockQuote": "#64748b", + "markdownEmph": "#c08c42", + "markdownStrong": "#1e293b", + "markdownHorizontalRule": "#cbd5e1", + "markdownListItem": "#475569", + "markdownListEnumeration": "#7c6b99", + "markdownImage": "#475569", + "markdownImageText": "#7c6b99", + "markdownCodeBlock": "#1e293b", + "syntaxComment": "#64748b", + "syntaxKeyword": "#7c6b99", + "syntaxFunction": "#475569", + "syntaxVariable": "#1e293b", + "syntaxString": "#5b9a6f", + "syntaxNumber": "#c08c42", + "syntaxType": "#64748b", + "syntaxOperator": "#7c6b99", + "syntaxPunctuation": "#1e293b" + } +} diff --git a/src/apps/cli/themes/presets/bitfun-midnight.json b/src/apps/cli/themes/presets/bitfun-midnight.json new file mode 100644 index 0000000000..0934fe9d03 --- /dev/null +++ b/src/apps/cli/themes/presets/bitfun-midnight.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#8fbc9f", + "secondary": "#d6a657", + "accent": "#9bb3bf", + "error": "#d9827f", + "warning": "#d6a657", + "success": "#8fbc9f", + "info": "#9bb3bf", + "text": "#d7ded9", + "textMuted": "#77827d", + "background": "#0b0d0e", + "backgroundPanel": "#141819", + "backgroundElement": "#1b3328", + "inputBackground": "#143d2b", + "border": "#32403b", + "borderActive": "#8fbc9f", + "borderSubtle": "#24302d", + "diffAdded": "#8fbc9f", + "diffRemoved": "#d9827f", + "diffContext": "#77827d", + "diffHunkHeader": "#9bb3bf", + "diffHighlightAdded": "#b2d5bd", + "diffHighlightRemoved": "#e7a09d", + "diffAddedBg": "#17291f", + "diffRemovedBg": "#2f1d1d", + "diffContextBg": "#141819", + "diffLineNumber": "#61706a", + "diffAddedLineNumberBg": "#1c3326", + "diffRemovedLineNumberBg": "#3a2222", + "markdownText": "#d7ded9", + "markdownHeading": "#8fbc9f", + "markdownLink": "#9bb3bf", + "markdownLinkText": "#d6a657", + "markdownCode": "#8fbc9f", + "markdownBlockQuote": "#77827d", + "markdownEmph": "#d6a657", + "markdownStrong": "#edf3ee", + "markdownHorizontalRule": "#32403b", + "markdownListItem": "#8fbc9f", + "markdownListEnumeration": "#d6a657", + "markdownImage": "#9bb3bf", + "markdownImageText": "#d6a657", + "markdownCodeBlock": "#d7ded9", + "syntaxComment": "#77827d", + "syntaxKeyword": "#d6a657", + "syntaxFunction": "#8fbc9f", + "syntaxVariable": "#d7ded9", + "syntaxString": "#a8c98f", + "syntaxNumber": "#d6a657", + "syntaxType": "#9bb3bf", + "syntaxOperator": "#d9827f", + "syntaxPunctuation": "#d7ded9" + } +} diff --git a/src/apps/cli/themes/presets/bitfun-tokyo-night.json b/src/apps/cli/themes/presets/bitfun-tokyo-night.json new file mode 100644 index 0000000000..2c107f6b53 --- /dev/null +++ b/src/apps/cli/themes/presets/bitfun-tokyo-night.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#8ab4ff", + "secondary": "#ff79c6", + "accent": "#7dcfff", + "error": "#f7768e", + "warning": "#e0af68", + "success": "#9ece6a", + "info": "#7dcfff", + "text": "#d5ddff", + "textMuted": "#8b92bd", + "background": "#121329", + "backgroundPanel": "#171933", + "backgroundElement": "#202b55", + "inputBackground": "#202b55", + "border": "#3a4070", + "borderActive": "#8ab4ff", + "borderSubtle": "#2a2f55", + "diffAdded": "#9ece6a", + "diffRemoved": "#f7768e", + "diffContext": "#787c99", + "diffHunkHeader": "#8ab4ff", + "diffHighlightAdded": "#b3dc82", + "diffHighlightRemoved": "#ff8fa3", + "diffAddedBg": "#2a3826", + "diffRemovedBg": "#3f2632", + "diffContextBg": "#171933", + "diffLineNumber": "#747ca8", + "diffAddedLineNumberBg": "#304128", + "diffRemovedLineNumberBg": "#482a38", + "markdownText": "#d5ddff", + "markdownHeading": "#8ab4ff", + "markdownLink": "#7dcfff", + "markdownLinkText": "#ff79c6", + "markdownCode": "#9ece6a", + "markdownBlockQuote": "#787c99", + "markdownEmph": "#e0af68", + "markdownStrong": "#d5ddff", + "markdownHorizontalRule": "#3a4070", + "markdownListItem": "#8ab4ff", + "markdownListEnumeration": "#ff79c6", + "markdownImage": "#7dcfff", + "markdownImageText": "#ff79c6", + "markdownCodeBlock": "#d5ddff", + "syntaxComment": "#8b92bd", + "syntaxKeyword": "#ff79c6", + "syntaxFunction": "#7dcfff", + "syntaxVariable": "#d5ddff", + "syntaxString": "#9ece6a", + "syntaxNumber": "#e0af68", + "syntaxType": "#8ab4ff", + "syntaxOperator": "#f7768e", + "syntaxPunctuation": "#d5ddff" + } +} diff --git a/src/apps/cli/themes/presets/cursor.json b/src/apps/cli/themes/presets/cursor.json deleted file mode 100644 index ab518dbe7e..0000000000 --- a/src/apps/cli/themes/presets/cursor.json +++ /dev/null @@ -1,249 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#181818", - "darkPanel": "#141414", - "darkElement": "#262626", - "darkFg": "#e4e4e4", - "darkMuted": "#e4e4e45e", - "darkBorder": "#e4e4e413", - "darkBorderActive": "#e4e4e426", - "darkCyan": "#88c0d0", - "darkBlue": "#81a1c1", - "darkGreen": "#3fa266", - "darkGreenBright": "#70b489", - "darkRed": "#e34671", - "darkRedBright": "#fc6b83", - "darkYellow": "#f1b467", - "darkOrange": "#d2943e", - "darkPink": "#E394DC", - "darkPurple": "#AAA0FA", - "darkTeal": "#82D2CE", - "darkSyntaxYellow": "#F8C762", - "darkSyntaxOrange": "#EFB080", - "darkSyntaxGreen": "#A8CC7C", - "darkSyntaxBlue": "#87C3FF", - "lightBg": "#fcfcfc", - "lightPanel": "#f3f3f3", - "lightElement": "#ededed", - "lightFg": "#141414", - "lightMuted": "#141414ad", - "lightBorder": "#14141413", - "lightBorderActive": "#14141426", - "lightTeal": "#6f9ba6", - "lightBlue": "#3c7cab", - "lightBlueDark": "#206595", - "lightGreen": "#1f8a65", - "lightGreenBright": "#55a583", - "lightRed": "#cf2d56", - "lightRedBright": "#e75e78", - "lightOrange": "#db704b", - "lightYellow": "#c08532", - "lightPurple": "#9e94d5", - "lightPurpleDark": "#6049b3", - "lightPink": "#b8448b", - "lightMagenta": "#b3003f" - }, - "theme": { - "primary": { - "dark": "darkCyan", - "light": "lightTeal" - }, - "secondary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "accent": { - "dark": "darkCyan", - "light": "lightTeal" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "text": { - "dark": "darkFg", - "light": "lightFg" - }, - "textMuted": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "background": { - "dark": "darkBg", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "darkPanel", - "light": "lightPanel" - }, - "backgroundElement": { - "dark": "darkElement", - "light": "lightElement" - }, - "border": { - "dark": "darkBorder", - "light": "lightBorder" - }, - "borderActive": { - "dark": "darkCyan", - "light": "lightTeal" - }, - "borderSubtle": { - "dark": "#0f0f0f", - "light": "#e0e0e0" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "diffHunkHeader": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "diffHighlightAdded": { - "dark": "darkGreenBright", - "light": "lightGreenBright" - }, - "diffHighlightRemoved": { - "dark": "darkRedBright", - "light": "lightRedBright" - }, - "diffAddedBg": { - "dark": "#3fa26633", - "light": "#1f8a651f" - }, - "diffRemovedBg": { - "dark": "#b8004933", - "light": "#cf2d5614" - }, - "diffContextBg": { - "dark": "darkPanel", - "light": "lightPanel" - }, - "diffLineNumber": { - "dark": "#e4e4e442", - "light": "#1414147a" - }, - "diffAddedLineNumberBg": { - "dark": "#3fa26633", - "light": "#1f8a651f" - }, - "diffRemovedLineNumberBg": { - "dark": "#b8004933", - "light": "#cf2d5614" - }, - "markdownText": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownHeading": { - "dark": "darkPurple", - "light": "lightBlueDark" - }, - "markdownLink": { - "dark": "darkTeal", - "light": "lightBlueDark" - }, - "markdownLinkText": { - "dark": "darkBlue", - "light": "lightMuted" - }, - "markdownCode": { - "dark": "darkPink", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "markdownEmph": { - "dark": "darkTeal", - "light": "lightFg" - }, - "markdownStrong": { - "dark": "darkSyntaxYellow", - "light": "lightFg" - }, - "markdownHorizontalRule": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "markdownListItem": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightMuted" - }, - "markdownImage": { - "dark": "darkCyan", - "light": "lightBlueDark" - }, - "markdownImageText": { - "dark": "darkBlue", - "light": "lightMuted" - }, - "markdownCodeBlock": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxComment": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "syntaxKeyword": { - "dark": "darkTeal", - "light": "lightMagenta" - }, - "syntaxFunction": { - "dark": "darkSyntaxOrange", - "light": "lightOrange" - }, - "syntaxVariable": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxString": { - "dark": "darkPink", - "light": "lightPurple" - }, - "syntaxNumber": { - "dark": "darkSyntaxYellow", - "light": "lightPink" - }, - "syntaxType": { - "dark": "darkSyntaxOrange", - "light": "lightBlueDark" - }, - "syntaxOperator": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxPunctuation": { - "dark": "darkFg", - "light": "lightFg" - } - } -} diff --git a/src/apps/cli/themes/presets/everforest.json b/src/apps/cli/themes/presets/everforest.json deleted file mode 100644 index 62dfb31ba8..0000000000 --- a/src/apps/cli/themes/presets/everforest.json +++ /dev/null @@ -1,241 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#2d353b", - "darkStep2": "#333c43", - "darkStep3": "#343f44", - "darkStep4": "#3d484d", - "darkStep5": "#475258", - "darkStep6": "#7a8478", - "darkStep7": "#859289", - "darkStep8": "#9da9a0", - "darkStep9": "#a7c080", - "darkStep10": "#83c092", - "darkStep11": "#7a8478", - "darkStep12": "#d3c6aa", - "darkRed": "#e67e80", - "darkOrange": "#e69875", - "darkGreen": "#a7c080", - "darkCyan": "#83c092", - "darkYellow": "#dbbc7f", - "lightStep1": "#fdf6e3", - "lightStep2": "#efebd4", - "lightStep3": "#f4f0d9", - "lightStep4": "#efebd4", - "lightStep5": "#e6e2cc", - "lightStep6": "#a6b0a0", - "lightStep7": "#939f91", - "lightStep8": "#829181", - "lightStep9": "#8da101", - "lightStep10": "#35a77c", - "lightStep11": "#a6b0a0", - "lightStep12": "#5c6a72", - "lightRed": "#f85552", - "lightOrange": "#f57d26", - "lightGreen": "#8da101", - "lightCyan": "#35a77c", - "lightYellow": "#dfa000" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "#7fbbb3", - "light": "#3a94c5" - }, - "accent": { - "dark": "#d699b6", - "light": "#df69ba" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "darkStep7", - "light": "lightStep7" - }, - "borderActive": { - "dark": "darkStep8", - "light": "lightStep8" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#4fd6be", - "light": "#1e725c" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#b8db87", - "light": "#4db380" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#20303b", - "light": "#d5e5d5" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#1b2b34", - "light": "#c5d5c5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "#d699b6", - "light": "#df69ba" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "#d699b6", - "light": "#df69ba" - }, - "syntaxFunction": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } -} diff --git a/src/apps/cli/themes/presets/github.json b/src/apps/cli/themes/presets/github.json deleted file mode 100644 index 99a80879e1..0000000000 --- a/src/apps/cli/themes/presets/github.json +++ /dev/null @@ -1,233 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#0d1117", - "darkBgAlt": "#010409", - "darkBgPanel": "#161b22", - "darkFg": "#c9d1d9", - "darkFgMuted": "#8b949e", - "darkBlue": "#58a6ff", - "darkGreen": "#3fb950", - "darkRed": "#f85149", - "darkOrange": "#d29922", - "darkPurple": "#bc8cff", - "darkPink": "#ff7b72", - "darkYellow": "#e3b341", - "darkCyan": "#39c5cf", - "lightBg": "#ffffff", - "lightBgAlt": "#f6f8fa", - "lightBgPanel": "#f0f3f6", - "lightFg": "#24292f", - "lightFgMuted": "#57606a", - "lightBlue": "#0969da", - "lightGreen": "#1a7f37", - "lightRed": "#cf222e", - "lightOrange": "#bc4c00", - "lightPurple": "#8250df", - "lightPink": "#bf3989", - "lightYellow": "#9a6700", - "lightCyan": "#1b7c83" - }, - "theme": { - "primary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "secondary": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "accent": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "text": { - "dark": "darkFg", - "light": "lightFg" - }, - "textMuted": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "background": { - "dark": "darkBg", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "backgroundElement": { - "dark": "darkBgPanel", - "light": "lightBgPanel" - }, - "border": { - "dark": "#30363d", - "light": "#d0d7de" - }, - "borderActive": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "borderSubtle": { - "dark": "#21262d", - "light": "#d8dee4" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "diffHunkHeader": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "diffHighlightAdded": { - "dark": "#3fb950", - "light": "#1a7f37" - }, - "diffHighlightRemoved": { - "dark": "#f85149", - "light": "#cf222e" - }, - "diffAddedBg": { - "dark": "#033a16", - "light": "#dafbe1" - }, - "diffRemovedBg": { - "dark": "#67060c", - "light": "#ffebe9" - }, - "diffContextBg": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "diffLineNumber": { - "dark": "#484f58", - "light": "#afb8c1" - }, - "diffAddedLineNumberBg": { - "dark": "#033a16", - "light": "#dafbe1" - }, - "diffRemovedLineNumberBg": { - "dark": "#67060c", - "light": "#ffebe9" - }, - "markdownText": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownHeading": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLink": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkPink", - "light": "lightPink" - }, - "markdownBlockQuote": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "#30363d", - "light": "#d0d7de" - }, - "markdownListItem": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxComment": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "syntaxKeyword": { - "dark": "darkPink", - "light": "lightRed" - }, - "syntaxFunction": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "syntaxVariable": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxString": { - "dark": "darkCyan", - "light": "lightBlue" - }, - "syntaxNumber": { - "dark": "darkBlue", - "light": "lightCyan" - }, - "syntaxType": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxOperator": { - "dark": "darkPink", - "light": "lightRed" - }, - "syntaxPunctuation": { - "dark": "darkFg", - "light": "lightFg" - } - } -} diff --git a/src/apps/cli/themes/presets/one-dark.json b/src/apps/cli/themes/presets/one-dark.json deleted file mode 100644 index 73b24e9292..0000000000 --- a/src/apps/cli/themes/presets/one-dark.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#282c34", - "darkBgAlt": "#21252b", - "darkBgPanel": "#353b45", - "darkFg": "#abb2bf", - "darkFgMuted": "#5c6370", - "darkPurple": "#c678dd", - "darkBlue": "#61afef", - "darkRed": "#e06c75", - "darkGreen": "#98c379", - "darkYellow": "#e5c07b", - "darkOrange": "#d19a66", - "darkCyan": "#56b6c2", - "lightBg": "#fafafa", - "lightBgAlt": "#f0f0f1", - "lightBgPanel": "#eaeaeb", - "lightFg": "#383a42", - "lightFgMuted": "#a0a1a7", - "lightPurple": "#a626a4", - "lightBlue": "#4078f2", - "lightRed": "#e45649", - "lightGreen": "#50a14f", - "lightYellow": "#c18401", - "lightOrange": "#986801", - "lightCyan": "#0184bc" - }, - "theme": { - "primary": { "dark": "darkBlue", "light": "lightBlue" }, - "secondary": { "dark": "darkPurple", "light": "lightPurple" }, - "accent": { "dark": "darkCyan", "light": "lightCyan" }, - "error": { "dark": "darkRed", "light": "lightRed" }, - "warning": { "dark": "darkYellow", "light": "lightYellow" }, - "success": { "dark": "darkGreen", "light": "lightGreen" }, - "info": { "dark": "darkOrange", "light": "lightOrange" }, - "text": { "dark": "darkFg", "light": "lightFg" }, - "textMuted": { "dark": "darkFgMuted", "light": "lightFgMuted" }, - "background": { "dark": "darkBg", "light": "lightBg" }, - "backgroundPanel": { "dark": "darkBgAlt", "light": "lightBgAlt" }, - "backgroundElement": { "dark": "darkBgPanel", "light": "lightBgPanel" }, - "border": { "dark": "#393f4a", "light": "#d1d1d2" }, - "borderActive": { "dark": "darkBlue", "light": "lightBlue" }, - "borderSubtle": { "dark": "#2c313a", "light": "#e0e0e1" }, - "diffAdded": { "dark": "darkGreen", "light": "lightGreen" }, - "diffRemoved": { "dark": "darkRed", "light": "lightRed" }, - "diffContext": { "dark": "darkFgMuted", "light": "lightFgMuted" }, - "diffHunkHeader": { "dark": "darkCyan", "light": "lightCyan" }, - "diffHighlightAdded": { "dark": "#aad482", "light": "#489447" }, - "diffHighlightRemoved": { "dark": "#e8828b", "light": "#d65145" }, - "diffAddedBg": { "dark": "#2c382b", "light": "#eafbe9" }, - "diffRemovedBg": { "dark": "#3a2d2f", "light": "#fce9e8" }, - "diffContextBg": { "dark": "darkBgAlt", "light": "lightBgAlt" }, - "diffLineNumber": { "dark": "#495162", "light": "#c9c9ca" }, - "diffAddedLineNumberBg": { "dark": "#283427", "light": "#e1f3df" }, - "diffRemovedLineNumberBg": { "dark": "#36292b", "light": "#f5e2e1" }, - "markdownText": { "dark": "darkFg", "light": "lightFg" }, - "markdownHeading": { "dark": "darkPurple", "light": "lightPurple" }, - "markdownLink": { "dark": "darkBlue", "light": "lightBlue" }, - "markdownLinkText": { "dark": "darkCyan", "light": "lightCyan" }, - "markdownCode": { "dark": "darkGreen", "light": "lightGreen" }, - "markdownBlockQuote": { "dark": "darkFgMuted", "light": "lightFgMuted" }, - "markdownEmph": { "dark": "darkYellow", "light": "lightYellow" }, - "markdownStrong": { "dark": "darkOrange", "light": "lightOrange" }, - "markdownHorizontalRule": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "markdownListItem": { "dark": "darkBlue", "light": "lightBlue" }, - "markdownListEnumeration": { "dark": "darkCyan", "light": "lightCyan" }, - "markdownImage": { "dark": "darkBlue", "light": "lightBlue" }, - "markdownImageText": { "dark": "darkCyan", "light": "lightCyan" }, - "markdownCodeBlock": { "dark": "darkFg", "light": "lightFg" }, - "syntaxComment": { "dark": "darkFgMuted", "light": "lightFgMuted" }, - "syntaxKeyword": { "dark": "darkPurple", "light": "lightPurple" }, - "syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" }, - "syntaxVariable": { "dark": "darkRed", "light": "lightRed" }, - "syntaxString": { "dark": "darkGreen", "light": "lightGreen" }, - "syntaxNumber": { "dark": "darkOrange", "light": "lightOrange" }, - "syntaxType": { "dark": "darkYellow", "light": "lightYellow" }, - "syntaxOperator": { "dark": "darkCyan", "light": "lightCyan" }, - "syntaxPunctuation": { "dark": "darkFg", "light": "lightFg" } - } -} diff --git a/src/apps/cli/themes/presets/opencode.json b/src/apps/cli/themes/presets/opencode.json deleted file mode 100644 index 8f585a4509..0000000000 --- a/src/apps/cli/themes/presets/opencode.json +++ /dev/null @@ -1,245 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#0a0a0a", - "darkStep2": "#141414", - "darkStep3": "#1e1e1e", - "darkStep4": "#282828", - "darkStep5": "#323232", - "darkStep6": "#3c3c3c", - "darkStep7": "#484848", - "darkStep8": "#606060", - "darkStep9": "#fab283", - "darkStep10": "#ffc09f", - "darkStep11": "#808080", - "darkStep12": "#eeeeee", - "darkSecondary": "#5c9cf5", - "darkAccent": "#9d7cd8", - "darkRed": "#e06c75", - "darkOrange": "#f5a742", - "darkGreen": "#7fd88f", - "darkCyan": "#56b6c2", - "darkYellow": "#e5c07b", - "lightStep1": "#ffffff", - "lightStep2": "#fafafa", - "lightStep3": "#f5f5f5", - "lightStep4": "#ebebeb", - "lightStep5": "#e1e1e1", - "lightStep6": "#d4d4d4", - "lightStep7": "#b8b8b8", - "lightStep8": "#a0a0a0", - "lightStep9": "#3b7dd8", - "lightStep10": "#2968c3", - "lightStep11": "#8a8a8a", - "lightStep12": "#1a1a1a", - "lightSecondary": "#7b5bb6", - "lightAccent": "#d68c27", - "lightRed": "#d1383d", - "lightOrange": "#d68c27", - "lightGreen": "#3d9a57", - "lightCyan": "#318795", - "lightYellow": "#b0851f" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "darkSecondary", - "light": "lightSecondary" - }, - "accent": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "darkStep7", - "light": "lightStep7" - }, - "borderActive": { - "dark": "darkStep8", - "light": "lightStep8" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#4fd6be", - "light": "#1e725c" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#b8db87", - "light": "#4db380" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#20303b", - "light": "#d5e5d5" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#1b2b34", - "light": "#c5d5c5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "syntaxFunction": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } -} diff --git a/src/apps/cli/themes/presets/tokyonight.json b/src/apps/cli/themes/presets/tokyonight.json deleted file mode 100644 index 1c9503a420..0000000000 --- a/src/apps/cli/themes/presets/tokyonight.json +++ /dev/null @@ -1,243 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#1a1b26", - "darkStep2": "#1e2030", - "darkStep3": "#222436", - "darkStep4": "#292e42", - "darkStep5": "#3b4261", - "darkStep6": "#545c7e", - "darkStep7": "#737aa2", - "darkStep8": "#9099b2", - "darkStep9": "#82aaff", - "darkStep10": "#89b4fa", - "darkStep11": "#828bb8", - "darkStep12": "#c8d3f5", - "darkRed": "#ff757f", - "darkOrange": "#ff966c", - "darkYellow": "#ffc777", - "darkGreen": "#c3e88d", - "darkCyan": "#86e1fc", - "darkPurple": "#c099ff", - "lightStep1": "#e1e2e7", - "lightStep2": "#d5d6db", - "lightStep3": "#c8c9ce", - "lightStep4": "#b9bac1", - "lightStep5": "#a8aecb", - "lightStep6": "#9699a8", - "lightStep7": "#737a8c", - "lightStep8": "#5a607d", - "lightStep9": "#2e7de9", - "lightStep10": "#1a6ce7", - "lightStep11": "#8990a3", - "lightStep12": "#3760bf", - "lightRed": "#f52a65", - "lightOrange": "#b15c00", - "lightYellow": "#8c6c3e", - "lightGreen": "#587539", - "lightCyan": "#007197", - "lightPurple": "#9854f1" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "accent": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "darkStep7", - "light": "lightStep7" - }, - "borderActive": { - "dark": "darkStep8", - "light": "lightStep8" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#4fd6be", - "light": "#1e725c" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#b8db87", - "light": "#4db380" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#20303b", - "light": "#d5e5d5" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#1b2b34", - "light": "#c5d5c5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "syntaxFunction": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } -} From d67df49fd6c8aa9495f9a02f9b24a846db8309ce Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 16 May 2026 18:03:16 +0800 Subject: [PATCH 04/76] perf(web): improve startup and history session readiness Add startup phase tracing across desktop and web startup, reduce pre-render initialization work, and dedupe concurrent config reads. Track historical session hydrate state explicitly so saved sessions show a loading or retry surface instead of briefly looking like a new empty session. Add focused tests for startup tracing, config read dedupe, historical session state transitions, retry UI, pending hydrate reuse, ACP restore skip, and Modern store sync. --- src/apps/desktop/src/lib.rs | 37 +- src/apps/desktop/src/theme.rs | 10 +- src/web-ui/src/app/App.tsx | 13 + .../modern/HistorySessionPlaceholder.tsx | 48 +++ ...rnFlowChatContainer.history-state.test.tsx | 228 +++++++++++ .../modern/ModernFlowChatContainer.scss | 81 ++++ .../modern/ModernFlowChatContainer.tsx | 20 +- .../flow-chat-manager/SessionModule.test.ts | 139 +++++++ .../flow-chat-manager/SessionModule.ts | 18 +- .../src/flow_chat/services/storeSync.test.ts | 79 ++++ .../src/flow_chat/store/FlowChatStore.test.ts | 187 +++++++++ .../src/flow_chat/store/FlowChatStore.ts | 77 ++++ src/web-ui/src/flow_chat/types/flow-chat.ts | 17 + .../api/service-api/ApiClient.ts | 36 ++ .../config/services/ConfigManager.test.ts | 57 +++ .../config/services/ConfigManager.ts | 51 ++- .../config/services/FrontendLogLevelSync.ts | 50 ++- src/web-ui/src/locales/en-US/flow-chat.json | 7 + src/web-ui/src/locales/zh-CN/flow-chat.json | 7 + src/web-ui/src/locales/zh-TW/flow-chat.json | 7 + src/web-ui/src/main.tsx | 59 ++- .../src/shared/utils/startupTrace.test.ts | 299 ++++++++++++++ src/web-ui/src/shared/utils/startupTrace.ts | 370 ++++++++++++++++++ 23 files changed, 1841 insertions(+), 56 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/HistorySessionPlaceholder.tsx create mode 100644 src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx create mode 100644 src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts create mode 100644 src/web-ui/src/flow_chat/services/storeSync.test.ts create mode 100644 src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts create mode 100644 src/web-ui/src/shared/utils/startupTrace.test.ts create mode 100644 src/web-ui/src/shared/utils/startupTrace.ts diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 7c0d84f0db..a28081286c 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -21,7 +21,7 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }; -use std::time::Instant; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use tauri::Emitter; use tauri::Manager; @@ -194,6 +194,10 @@ async fn webdriver_bridge_result(request: WebdriverBridgeResultRequest) -> Resul #[cfg_attr(mobile, tauri::mobile_entry_point)] pub async fn run() { let startup_started = Instant::now(); + let startup_trace_id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| format!("desktop-{}", duration.as_millis())) + .unwrap_or_else(|_| "desktop-unknown".to_string()); let mut startup_timings = TimingCollector::default(); let in_debug = cfg!(debug_assertions) || std::env::var("DEBUG").unwrap_or_default() == "1"; let log_config = logging::LogConfig::new(in_debug); @@ -303,6 +307,11 @@ pub async fn run() { .manage(terminal_state) .setup(move |app| { let setup_started = Instant::now(); + log::debug!( + "Desktop startup trace event: trace_id={}, phase=tauri_setup_start, since_process_start_ms={}", + startup_trace_id, + elapsed_ms(startup_started) + ); #[cfg(target_os = "macos")] { app.on_menu_event(|app, event| { @@ -414,16 +423,34 @@ pub async fn run() { let app_handle = app.handle().clone(); let window_started = Instant::now(); - theme::create_main_window(&app_handle); + log::debug!( + "Desktop startup trace event: trace_id={}, phase=main_window_create_start", + startup_trace_id + ); + theme::create_main_window(&app_handle, &startup_trace_id); + let window_duration_ms = elapsed_ms(window_started); log::debug!( "Desktop startup step completed: step=create_main_window, duration_ms={}", - elapsed_ms(window_started) + window_duration_ms + ); + log::debug!( + "Desktop startup trace event: trace_id={}, phase=main_window_create_end, duration_ms={}", + startup_trace_id, + window_duration_ms ); bitfun_webdriver::maybe_start(app_handle.clone()); + let setup_duration_ms = elapsed_ms(setup_started); + let since_process_start_ms = elapsed_ms(startup_started); log::debug!( "Desktop startup timing: phase=tauri_setup, duration_ms={}, since_process_start_ms={}", - elapsed_ms(setup_started), - elapsed_ms(startup_started) + setup_duration_ms, + since_process_start_ms + ); + log::debug!( + "Desktop startup trace event: trace_id={}, phase=tauri_setup_end, duration_ms={}, since_process_start_ms={}", + startup_trace_id, + setup_duration_ms, + since_process_start_ms ); #[cfg(target_os = "macos")] diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index 55c34cc503..e5a45af019 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -270,12 +270,15 @@ impl ThemeConfig { theme_id } - pub fn generate_init_script(&self) -> String { + pub fn generate_init_script(&self, startup_trace_id: &str) -> String { let theme_type = if self.is_light { "light" } else { "dark" }; + let startup_trace_id_json = serde_json::to_string(startup_trace_id) + .unwrap_or_else(|_| "\"desktop-unknown\"".to_string()); format!( r#" (function() {{ + window.__BITFUN_STARTUP_TRACE_ID__ = {startup_trace_id_json}; function applyTheme() {{ var root = document.documentElement; if (!root) return false; @@ -318,6 +321,7 @@ impl ThemeConfig { bg_secondary = self.bg_secondary, bg_scene = self.bg_scene, text_primary = self.text_primary, + startup_trace_id_json = startup_trace_id_json, ) } @@ -330,10 +334,10 @@ impl ThemeConfig { } } -pub fn create_main_window(app_handle: &tauri::AppHandle) { +pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) { let theme = ThemeConfig::load_from_config(); let bg_color = theme.to_tauri_color(); - let init_script = theme.generate_init_script(); + let init_script = theme.generate_init_script(startup_trace_id); let main_url = if cfg!(debug_assertions) { match "http://localhost:1422".parse() { diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index a0f96f4c80..143a40ca90 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -10,6 +10,7 @@ import { NotificationContainer, NotificationCenter } from '../shared/notificatio import { AnnouncementProvider } from '../shared/announcement-system'; import { ConfirmDialogRenderer } from '../component-library'; import { createLogger } from '@/shared/utils/logger'; +import { startupTrace } from '@/shared/utils/startupTrace'; import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; import { syncAgentCompanionDesktopWindow } from '@/infrastructure/config/services/AgentCompanionWindowService'; import { isTauriRuntime } from '@/infrastructure/runtime'; @@ -51,6 +52,7 @@ function App() { const [splashExiting, setSplashExiting] = useState(false); const mountTimeRef = useRef(Date.now()); const mainWindowShownRef = useRef(false); + const interactiveShellReadyRef = useRef(false); // Once the workspace finishes loading, wait for the remaining min-display // time and then begin the exit animation. @@ -76,6 +78,7 @@ function App() { const { invoke } = await import('@tauri-apps/api/core'); await invoke('show_main_window'); log.debug('Main window shown', { reason }); + startupTrace.markPhase('main_window_shown', { reason }); } catch (error: any) { log.error('Failed to show main window', error); @@ -85,6 +88,7 @@ function App() { await mainWindow.show(); await mainWindow.setFocus(); log.debug('Main window shown via fallback', { reason }); + startupTrace.markPhase('main_window_shown_fallback', { reason }); } catch (fallbackError) { log.error('Fallback window show failed', fallbackError); mainWindowShownRef.current = false; @@ -96,9 +100,18 @@ function App() { // The splash still covers the UI, so users see immediate feedback instead // of waiting on a hidden window while startup continues in the background. useEffect(() => { + startupTrace.markPhase('app_effect_mounted'); void showMainWindow('startup-overlay'); }, [showMainWindow]); + useEffect(() => { + if (workspaceLoading || interactiveShellReadyRef.current) { + return; + } + interactiveShellReadyRef.current = true; + startupTrace.markPhase('interactive_shell_ready'); + }, [workspaceLoading]); + // If the early reveal path fails, keep the old post-splash show as a retry. useEffect(() => { if (splashVisible) { diff --git a/src/web-ui/src/flow_chat/components/modern/HistorySessionPlaceholder.tsx b/src/web-ui/src/flow_chat/components/modern/HistorySessionPlaceholder.tsx new file mode 100644 index 0000000000..56fa642209 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/HistorySessionPlaceholder.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { AlertCircle, LoaderCircle, RefreshCw } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { SessionHistoryState } from '../../types/flow-chat'; + +interface HistorySessionPlaceholderProps { + state: Extract; + onRetry?: () => void; +} + +export const HistorySessionPlaceholder: React.FC = ({ + state, + onRetry, +}) => { + const { t } = useTranslation('flow-chat'); + const failed = state === 'failed'; + + return ( +
+ +
+

+ {failed ? t('historyState.failedTitle') : t('historyState.loadingTitle')} +

+

+ {failed ? t('historyState.failedDescription') : t('historyState.loadingDescription')} +

+
+ {failed && ( + + )} +
+ ); +}; + +HistorySessionPlaceholder.displayName = 'HistorySessionPlaceholder'; diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx new file mode 100644 index 0000000000..2b9fe555df --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx @@ -0,0 +1,228 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { ModernFlowChatContainer } from './ModernFlowChatContainer'; +import type { Session } from '../../types/flow-chat'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const stateMocks = vi.hoisted(() => ({ + activeSession: null as Session | null, + virtualItems: [] as unknown[], + visibleTurnInfo: null as unknown, +})); + +const switchChatSessionMock = vi.hoisted(() => vi.fn()); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const labels: Record = { + 'historyState.loadingTitle': 'Loading saved session', + 'historyState.loadingDescription': 'Preparing the conversation history.', + 'historyState.failedTitle': 'Session history did not load', + 'historyState.failedDescription': 'Retry loading the saved conversation.', + 'historyState.retry': 'Retry', + }; + return labels[key] ?? key; + }, + }), +})); + +vi.mock('@/infrastructure/hooks/useShortcut', () => ({ + useShortcut: vi.fn(), +})); + +vi.mock('@/flow_chat/services/FlowChatManager', () => ({ + FlowChatManager: { + getInstance: () => ({ + cancelCurrentTask: vi.fn(), + createChatSession: vi.fn(), + switchChatSession: switchChatSessionMock, + }), + }, +})); + +vi.mock('@/app/stores/sessionModeStore', () => ({ + useSessionModeStore: { + getState: () => ({ + setMode: vi.fn(), + }), + }, +})); + +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ + useWorkspaceContext: () => ({ + workspacePath: 'D:/workspace/BitFun', + }), +})); + +vi.mock('../../utils/acpSession', () => ({ + isAcpFlowSession: () => false, +})); + +vi.mock('../../store/modernFlowChatStore', () => ({ + useVirtualItems: () => stateMocks.virtualItems, + useActiveSession: () => stateMocks.activeSession, + useVisibleTurnInfo: () => stateMocks.visibleTurnInfo, +})); + +vi.mock('./VirtualMessageList', () => ({ + VirtualMessageList: React.forwardRef(() =>
), +})); + +vi.mock('./FlowChatHeader', () => ({ + FlowChatHeader: () =>
, +})); + +vi.mock('../WelcomePanel', () => ({ + WelcomePanel: () =>
Welcome panel
, +})); + +vi.mock('./useExploreGroupState', () => ({ + useExploreGroupState: () => ({ + exploreGroupStates: {}, + onExploreGroupToggle: vi.fn(), + onExpandGroup: vi.fn(), + onExpandAllInTurn: vi.fn(), + onCollapseGroup: vi.fn(), + }), +})); + +vi.mock('./useFlowChatFileActions', () => ({ + useFlowChatFileActions: () => ({ + handleFileViewRequest: vi.fn(), + }), +})); + +vi.mock('./useFlowChatNavigation', () => ({ + useFlowChatNavigation: vi.fn(), +})); + +vi.mock('./useFlowChatCopyDialog', () => ({ + useFlowChatCopyDialog: vi.fn(), +})); + +vi.mock('./useFlowChatSync', () => ({ + useFlowChatSync: vi.fn(), +})); + +vi.mock('./useFlowChatToolActions', () => ({ + useFlowChatToolActions: () => ({ + handleToolConfirm: vi.fn(), + handleToolReject: vi.fn(), + }), +})); + +vi.mock('./useFlowChatSearch', () => ({ + useFlowChatSearch: () => ({ + searchQuery: '', + onSearchChange: vi.fn(), + matches: [], + matchIndices: [], + currentMatchIndex: -1, + currentMatchVirtualIndex: -1, + goToNext: vi.fn(), + goToPrev: vi.fn(), + clearSearch: vi.fn(), + }), +})); + +function createSession(overrides: Partial = {}): Session { + return { + sessionId: 'session-1', + title: 'Saved session', + dialogTurns: [], + status: 'idle', + config: { agentType: 'agentic' }, + createdAt: 1, + lastActiveAt: 1, + error: null, + isHistorical: true, + todos: [], + mode: 'agentic', + workspacePath: 'D:/workspace/BitFun', + sessionKind: 'normal', + ...overrides, + }; +} + +describe('ModernFlowChatContainer historical empty state', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + stateMocks.virtualItems = []; + stateMocks.visibleTurnInfo = null; + switchChatSessionMock.mockReset(); + }); + + afterEach(() => { + if (root) { + act(() => { + root.unmount(); + }); + } + container?.remove(); + stateMocks.activeSession = null; + }); + + it('shows a history loading shell for metadata-only sessions instead of the new-session welcome', () => { + stateMocks.activeSession = createSession({ historyState: 'metadata-only' } as Partial); + + act(() => { + root.render(); + }); + + expect(container.textContent).toContain('Loading saved session'); + expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); + }); + + it('keeps the loading shell while historical sessions are hydrating', () => { + stateMocks.activeSession = createSession({ historyState: 'hydrating' } as Partial); + + act(() => { + root.render(); + }); + + expect(container.textContent).toContain('Loading saved session'); + expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); + }); + + it('keeps the new-session welcome for genuinely new empty sessions', () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'new', + } as Partial); + + act(() => { + root.render(); + }); + + expect(container.querySelector('[data-testid="welcome-panel"]')).not.toBeNull(); + }); + + it('shows retry for failed history loads', () => { + stateMocks.activeSession = createSession({ historyState: 'failed' } as Partial); + + act(() => { + root.render(); + }); + + const retryButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('Retry')); + expect(container.textContent).toContain('Session history did not load'); + expect(retryButton).toBeTruthy(); + + act(() => { + retryButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(switchChatSessionMock).toHaveBeenCalledWith('session-1'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss index e26d34f35a..d0b09c1149 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss @@ -43,3 +43,84 @@ border-top: 1px solid var(--color-border, #3a3a3a); } } + +.history-session-placeholder { + width: 100%; + height: 100%; + min-height: 240px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + padding: 32px; + color: var(--color-text-secondary); + text-align: center; +} + +.history-session-placeholder__icon { + width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-accent, #5b8def); + + svg { + animation: history-placeholder-spin 1.1s linear infinite; + } +} + +.history-session-placeholder__icon--failed { + color: var(--color-warning, #f59e0b); + + svg { + animation: none; + } +} + +.history-session-placeholder__text { + display: grid; + gap: 6px; + max-width: min(420px, 100%); +} + +.history-session-placeholder__title { + margin: 0; + color: var(--color-text-primary); + font-size: 16px; + font-weight: 600; + line-height: 1.35; +} + +.history-session-placeholder__description { + margin: 0; + font-size: 13px; + line-height: 1.5; +} + +.history-session-placeholder__retry { + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 12px; + border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12)); + border-radius: 6px; + color: var(--color-text-primary); + background: var(--color-surface-hover, rgba(255, 255, 255, 0.06)); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.history-session-placeholder__retry:hover { + background: var(--color-surface-active, rgba(255, 255, 255, 0.1)); +} + +@keyframes history-placeholder-spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 4f784bdb92..14ddd79c43 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -11,6 +11,7 @@ import { useSessionModeStore } from '@/app/stores/sessionModeStore'; import { VirtualMessageList, VirtualMessageListRef } from './VirtualMessageList'; import { FlowChatHeader, type FlowChatHeaderTurnSummary } from './FlowChatHeader'; import { WelcomePanel } from '../WelcomePanel'; +import { HistorySessionPlaceholder } from './HistorySessionPlaceholder'; import { FlowChatContext, FlowChatContextValue } from './FlowChatContext'; import { useExploreGroupState } from './useExploreGroupState'; import { useFlowChatFileActions } from './useFlowChatFileActions'; @@ -56,6 +57,12 @@ export const ModernFlowChatContainer: React.FC = ( const chatScopeRef = useRef(null); const { workspacePath } = useWorkspaceContext(); const allowUserMessageRollback = !isAcpFlowSession(activeSession); + const historyState = activeSession?.historyState; + const showHistoryPlaceholder = virtualItems.length === 0 && ( + historyState === 'metadata-only' || + historyState === 'hydrating' || + historyState === 'failed' + ); const { exploreGroupStates, onExploreGroupToggle: handleExploreGroupToggle, @@ -266,6 +273,12 @@ export const ModernFlowChatContainer: React.FC = ( handleJumpToTurn(nextTurn.turnId); }, [effectiveVisibleTurnInfo, handleJumpToTurn, turnSummaries]); + const handleRetryHistoryLoad = useCallback(() => { + const sessionId = activeSession?.sessionId; + if (!sessionId) return; + void FlowChatManager.getInstance().switchChatSession(sessionId); + }, [activeSession?.sessionId]); + useShortcut( 'chat.stopGeneration', { key: 'Escape', scope: 'chat', allowInInput: true }, @@ -343,7 +356,12 @@ export const ModernFlowChatContainer: React.FC = ( />
- {virtualItems.length === 0 ? ( + {showHistoryPlaceholder ? ( + + ) : virtualItems.length === 0 ? ( ({ + ensureCoordinatorSession: vi.fn(), + createSession: vi.fn(), +})); + +const persistenceMocks = vi.hoisted(() => ({ + touchSessionActivity: vi.fn(), + cleanupSaveState: vi.fn(), +})); + +vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ + agentAPI: agentApiMocks, +})); + +vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ + sessionAPI: {}, +})); + +vi.mock('../../../shared/notification-system', () => ({ + notificationService: { + error: vi.fn(), + warning: vi.fn(), + }, +})); + +vi.mock('@/infrastructure/i18n', () => ({ + i18nService: { + t: (key: string) => key, + }, +})); + +vi.mock('@/infrastructure/services/business/workspaceManager', () => ({ + workspaceManager: { + getState: () => ({ + currentWorkspace: null, + openedWorkspaces: new Map(), + }), + }, +})); + +vi.mock('./PersistenceModule', () => ({ + touchSessionActivity: persistenceMocks.touchSessionActivity, + cleanupSaveState: persistenceMocks.cleanupSaveState, +})); + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createSession(overrides: Partial = {}): Session { + return { + sessionId: 'history-1', + title: 'Saved session', + dialogTurns: [], + status: 'idle', + config: { agentType: 'agentic' }, + createdAt: 1, + lastActiveAt: 1, + error: null, + isHistorical: true, + historyState: 'metadata-only', + todos: [], + mode: 'agentic', + workspacePath: 'D:/workspace/BitFun', + sessionKind: 'normal', + ...overrides, + }; +} + +function createContext(session: Session) { + let state = { + sessions: new Map([[session.sessionId, session]]), + activeSessionId: null as string | null, + }; + const flowChatStore = { + getState: () => state, + switchSession: vi.fn((sessionId: string) => { + state = { ...state, activeSessionId: sessionId }; + }), + loadSessionHistory: vi.fn(), + setState: vi.fn((updater: any) => { + state = updater(state); + }), + }; + + return { + context: { + flowChatStore, + pendingHistoryLoads: new Map>(), + } as any, + flowChatStore, + }; +} + +describe('SessionModule historical session coordination', () => { + it('switches to a historical session immediately while hydrating in the background', async () => { + const load = createDeferred(); + const { context, flowChatStore } = createContext(createSession()); + flowChatStore.loadSessionHistory.mockReturnValueOnce(load.promise); + persistenceMocks.touchSessionActivity.mockResolvedValueOnce(undefined); + + await switchChatSession(context, 'history-1'); + + expect(flowChatStore.switchSession).toHaveBeenCalledWith('history-1'); + expect(flowChatStore.loadSessionHistory).toHaveBeenCalledTimes(1); + + load.resolve(); + await load.promise; + }); + + it('reuses pending historical hydration before ensuring the backend session', async () => { + const pendingHydrate = createDeferred(); + const { context, flowChatStore } = createContext(createSession()); + context.pendingHistoryLoads.set('history-1', pendingHydrate.promise); + agentApiMocks.ensureCoordinatorSession.mockResolvedValueOnce(undefined); + + const ensure = ensureBackendSession(context, 'history-1'); + await Promise.resolve(); + + expect(flowChatStore.loadSessionHistory).not.toHaveBeenCalled(); + expect(agentApiMocks.ensureCoordinatorSession).not.toHaveBeenCalled(); + + pendingHydrate.resolve(); + await ensure; + + expect(agentApiMocks.ensureCoordinatorSession).toHaveBeenCalledTimes(1); + expect(agentApiMocks.createSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 78d2e1614e..171fbfdc5b 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -7,6 +7,8 @@ import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; import { notificationService } from '../../../shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; +import { isRemoteTraceContext, startupTrace } from '@/shared/utils/startupTrace'; +import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { i18nService } from '@/infrastructure/i18n'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { normalizeRemoteWorkspacePath } from '@/shared/utils/pathUtils'; @@ -105,9 +107,11 @@ async function hydrateHistoricalSession( ): Promise { const existing = context.pendingHistoryLoads.get(sessionId); if (existing) { + startupTrace.markPhase('historical_session_hydrate_reused'); await existing; return; } + const traceStartedAt = nowMs(); const loadPromise = (async () => { const session = context.flowChatStore.getState().sessions.get(sessionId); @@ -116,6 +120,8 @@ async function hydrateHistoricalSession( } const workspacePath = requireSessionWorkspacePath(session.workspacePath, sessionId); + const remote = isRemoteTraceContext(session.remoteConnectionId, session.remoteSshHost); + startupTrace.markPhase('historical_session_hydrate_request', { remote }); // Prefer the current workspace's connection info over the session's // stored values. When the user changes the SSH port the session's @@ -145,7 +151,13 @@ async function hydrateHistoricalSession( try { await loadPromise; + startupTrace.markPhase('historical_session_hydrate_request_end', { + durationMs: elapsedMs(traceStartedAt), + }); } catch (error) { + startupTrace.markPhase('historical_session_hydrate_request_failed', { + durationMs: elapsedMs(traceStartedAt), + }); log.error('Failed to load session history', { sessionId, error }); if (notifyOnError) { notificationService.warning('Failed to load session history, showing empty session', { @@ -465,6 +477,10 @@ export async function switchChatSession( // Switch UI immediately so the user sees the new session without waiting for history load. context.flowChatStore.switchSession(sessionId); + startupTrace.markPhase('historical_session_switch', { + historical: Boolean(session?.isHistorical), + remote: isRemoteTraceContext(session?.remoteConnectionId, session?.remoteSshHost), + }); touchSessionActivity( sessionId, @@ -650,7 +666,7 @@ export async function ensureBackendSession( const newSessions = new Map(prev.sessions); const sess = newSessions.get(sessionId); if (sess) { - newSessions.set(sessionId, { ...sess, isHistorical: false }); + newSessions.set(sessionId, { ...sess, isHistorical: false, historyState: 'ready' }); } return { ...prev, sessions: newSessions }; }); diff --git a/src/web-ui/src/flow_chat/services/storeSync.test.ts b/src/web-ui/src/flow_chat/services/storeSync.test.ts new file mode 100644 index 0000000000..8b93292af0 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/storeSync.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Session } from '../types/flow-chat'; + +const syncMocks = vi.hoisted(() => { + const flowState = { + sessions: new Map(), + activeSessionId: null as string | null, + }; + const modernState = { + activeSession: null as Session | null, + setActiveSession: vi.fn((session: Session | null) => { + modernState.activeSession = session; + }), + clear: vi.fn(() => { + modernState.activeSession = null; + }), + }; + + return { + flowState, + modernState, + }; +}); + +vi.mock('../store/FlowChatStore', () => ({ + flowChatStore: { + getState: () => syncMocks.flowState, + }, +})); + +vi.mock('../store/modernFlowChatStore', () => ({ + useModernFlowChatStore: { + getState: () => syncMocks.modernState, + }, +})); + +import { syncSessionToModernStore } from './storeSync'; + +function createSession(overrides: Partial = {}): Session { + return { + sessionId: 'history-1', + title: 'Saved session', + dialogTurns: [], + status: 'idle', + config: { agentType: 'agentic' }, + createdAt: 1, + lastActiveAt: 1, + error: null, + isHistorical: true, + historyState: 'metadata-only', + todos: [], + mode: 'agentic', + workspacePath: 'D:/workspace/BitFun', + sessionKind: 'normal', + ...overrides, + }; +} + +describe('storeSync history session state', () => { + afterEach(() => { + syncMocks.flowState.sessions = new Map(); + syncMocks.flowState.activeSessionId = null; + syncMocks.modernState.activeSession = null; + syncMocks.modernState.setActiveSession.mockClear(); + syncMocks.modernState.clear.mockClear(); + }); + + it('preserves historyState when syncing historical sessions to the modern store', () => { + const session = createSession(); + syncMocks.flowState.sessions = new Map([[session.sessionId, session]]); + syncMocks.flowState.activeSessionId = session.sessionId; + + syncSessionToModernStore(session.sessionId); + + expect(syncMocks.modernState.setActiveSession).toHaveBeenCalledWith(session); + expect(syncMocks.modernState.activeSession).toBe(session); + expect(syncMocks.modernState.activeSession?.historyState).toBe('metadata-only'); + }); +}); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 466fef2c5c..4f86272abd 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -2,6 +2,45 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { flowChatStore } from './FlowChatStore'; import type { FlowChatState, Session } from '../types/flow-chat'; +const apiMocks = vi.hoisted(() => ({ + listSessions: vi.fn(), + loadSessionTurns: vi.fn(), + saveSessionTurn: vi.fn(), + restoreSession: vi.fn(), +})); + +const configManagerMock = vi.hoisted(() => ({ + getConfig: vi.fn(async (path: string) => { + if (path === 'ai.models') return []; + if (path === 'ai.default_models') return {}; + return undefined; + }), +})); + +const stateMachineManagerMock = vi.hoisted(() => ({ + getOrCreate: vi.fn(), + reset: vi.fn(), +})); + +vi.mock('@/infrastructure/api', () => ({ + sessionAPI: { + listSessions: apiMocks.listSessions, + loadSessionTurns: apiMocks.loadSessionTurns, + saveSessionTurn: apiMocks.saveSessionTurn, + }, + agentAPI: { + restoreSession: apiMocks.restoreSession, + }, +})); + +vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ + configManager: configManagerMock, +})); + +vi.mock('../state-machine', () => ({ + stateMachineManager: stateMachineManagerMock, +})); + const resetStore = () => { flowChatStore.setState((): FlowChatState => ({ sessions: new Map(), @@ -28,6 +67,22 @@ const createSession = (overrides: Partial = {}): Session => ({ ...overrides, }); +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flushAsyncWork(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + describe('FlowChatStore metadata persistence callbacks', () => { afterEach(() => { resetStore(); @@ -166,3 +221,135 @@ describe('FlowChatStore local usage reports', () => { ]); }); }); + +describe('FlowChatStore historical session hydration state', () => { + afterEach(() => { + resetStore(); + vi.clearAllMocks(); + }); + + it('loads persisted metadata as metadata-only historical sessions', async () => { + apiMocks.listSessions.mockResolvedValueOnce([ + { + sessionId: 'history-1', + title: 'Saved session', + agentType: 'agentic', + modelName: 'auto', + createdAt: 10, + lastActiveAt: 20, + }, + ]); + + await flowChatStore.initializeFromDisk('D:/workspace/BitFun'); + + const session = flowChatStore.getState().sessions.get('history-1'); + expect(session).toMatchObject({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + dialogTurns: [], + }); + }); + + it('marks historical sessions hydrating while turns are loading and ready after completion', async () => { + const turns = createDeferred(); + apiMocks.restoreSession.mockResolvedValueOnce(undefined); + apiMocks.loadSessionTurns.mockReturnValueOnce(turns.promise); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + const load = flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + await flushAsyncWork(); + + expect(flowChatStore.getState().sessions.get('history-1')?.historyState).toBe('hydrating'); + + turns.resolve([]); + await load; + + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + dialogTurns: [], + }); + }); + + it('marks historical sessions failed when hydrate fails', async () => { + apiMocks.restoreSession.mockResolvedValueOnce(undefined); + apiMocks.loadSessionTurns.mockRejectedValueOnce(new Error('turn load failed')); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await expect( + flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun') + ).rejects.toThrow('turn load failed'); + + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: true, + historyState: 'failed', + }); + }); + + it('does not change the active session when an older hydrate completes', async () => { + apiMocks.restoreSession.mockResolvedValueOnce(undefined); + apiMocks.loadSessionTurns.mockResolvedValueOnce([]); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ['history-2', createSession({ + sessionId: 'history-2', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-2', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(flowChatStore.getState().activeSessionId).toBe('history-2'); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + }); + }); + + it('does not restore ACP historical sessions through the normal backend path', async () => { + apiMocks.loadSessionTurns.mockResolvedValueOnce([]); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['acp-1', createSession({ + sessionId: 'acp-1', + isHistorical: true, + historyState: 'metadata-only', + mode: 'acp:test', + config: { agentType: 'acp:test' }, + })], + ]), + activeSessionId: 'acp-1', + })); + + await flowChatStore.loadSessionHistory('acp-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index ecfad920ea..a0461d6f15 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -14,8 +14,11 @@ import { ImageAnalysisResult, AnyFlowItem, SessionConfig, + SessionHistoryState, } from '../types/flow-chat'; import { createLogger } from '@/shared/utils/logger'; +import { isRemoteTraceContext, startupTrace } from '@/shared/utils/startupTrace'; +import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { i18nService } from '@/infrastructure/i18n/core/I18nService'; import type { LocalCommandMetadata, SessionKind } from '@/shared/types/session-history'; import { @@ -249,6 +252,7 @@ export class FlowChatStore { lastActiveAt: Date.now(), lastFinishedAt: undefined, error: null, + historyState: 'new', maxContextTokens: maxContextTokens || 128128, mode: mode || 'agentic', workspacePath, @@ -320,6 +324,7 @@ export class FlowChatStore { maxContextTokens: 128128, mode: mode || 'agentic', isHistorical: false, + historyState: 'new', workspacePath, remoteConnectionId, remoteSshHost, @@ -1731,9 +1736,15 @@ export class FlowChatStore { remoteConnectionId?: string, remoteSshHost?: string ): Promise { + const traceStartedAt = nowMs(); + const remote = isRemoteTraceContext(remoteConnectionId, remoteSshHost); + let sessionCount = 0; + startupTrace.markPhase('session_metadata_list_start', { remote }); try { const { sessionAPI } = await import('@/infrastructure/api'); const sessions = await sessionAPI.listSessions(workspacePath, remoteConnectionId, remoteSshHost); + sessionCount = sessions.length; + startupTrace.markPhase('session_metadata_list_loaded', { remote, sessionCount }); const { stateMachineManager } = await import('../state-machine'); sessions.forEach(metadata => { @@ -1811,6 +1822,7 @@ export class FlowChatStore { lastFinishedAt, error: null, isHistorical: true, + historyState: 'metadata-only', todos: (metadata as any).todos || [], maxContextTokens, mode: validatedAgentType, @@ -1839,11 +1851,41 @@ export class FlowChatStore { }; await Promise.all(sessions.map(processSession)); + startupTrace.markPhase('session_metadata_list_end', { + remote, + sessionCount, + durationMs: elapsedMs(traceStartedAt), + }); } catch (error) { + startupTrace.markPhase('session_metadata_list_failed', { + remote, + sessionCount, + durationMs: elapsedMs(traceStartedAt), + }); log.error('Failed to load persisted sessions', error); } } + public setSessionHistoryState(sessionId: string, historyState: SessionHistoryState): void { + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if (!session || session.historyState === historyState) { + return prev; + } + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, { + ...session, + historyState, + }); + + return { + ...prev, + sessions: newSessions, + }; + }); + } + /** * Lazy load session history (convert historical data to FlowChat format) */ @@ -1854,6 +1896,10 @@ export class FlowChatStore { remoteConnectionId?: string, remoteSshHost?: string ): Promise { + const traceStartedAt = nowMs(); + const remote = isRemoteTraceContext(remoteConnectionId, remoteSshHost); + startupTrace.markPhase('historical_session_hydrate_start', { remote }); + this.setSessionHistoryState(sessionId, 'hydrating'); try { const { stateMachineManager } = await import('../state-machine'); stateMachineManager.getOrCreate(sessionId); @@ -1878,6 +1924,10 @@ export class FlowChatStore { remoteConnectionId, remoteSshHost ); + startupTrace.markPhase('historical_session_turns_loaded', { + remote, + turnCount: Array.isArray(turns) ? turns.length : 0, + }); const dialogTurns = this.convertToDialogTurns(turns); @@ -1889,6 +1939,8 @@ export class FlowChatStore { ...session, dialogTurns, isHistorical: false, + historyState: 'ready' as const, + error: null, }; const newSessions = new Map(prev.sessions); @@ -1903,7 +1955,32 @@ export class FlowChatStore { // Reset state machine to IDLE after loading history // This handles the case where restoreSession triggered events that left the state machine in PROCESSING stateMachineManager.reset(sessionId); + startupTrace.markPhase('historical_session_hydrate_end', { + remote, + turnCount: dialogTurns.length, + durationMs: elapsedMs(traceStartedAt), + }); } catch (error) { + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if (!session) return prev; + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, { + ...session, + isHistorical: true, + historyState: 'failed', + }); + + return { + ...prev, + sessions: newSessions, + }; + }); + startupTrace.markPhase('historical_session_hydrate_failed', { + remote, + durationMs: elapsedMs(traceStartedAt), + }); log.error('Failed to load session history', { sessionId, error }); throw error; } diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index ab36ab5550..5732899fbd 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -232,6 +232,13 @@ export interface TodoItem { status: 'pending' | 'in_progress' | 'completed'; } +export type SessionHistoryState = + | 'new' + | 'metadata-only' + | 'hydrating' + | 'ready' + | 'failed'; + // Session state. export interface Session { sessionId: string; @@ -263,6 +270,16 @@ export interface Session { // Historical sessions are persisted and require lazy loading. isHistorical?: boolean; + + /** + * Lazy history lifecycle for persisted sessions: + * - 'new': an empty local session that should show the normal welcome state. + * - 'metadata-only': persisted metadata is visible, but turns have not hydrated yet. + * - 'hydrating': history is currently being restored / loaded. + * - 'ready': turns are available, or the session no longer needs lazy history. + * - 'failed': hydrate failed and the UI should offer retry instead of showing a new session. + */ + historyState?: SessionHistoryState; todos?: TodoItem[]; diff --git a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts index 86aebc76ad..8c2e0fe4c0 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts @@ -15,6 +15,7 @@ import { } from './types'; import { createLogger } from '@/shared/utils/logger'; import { elapsedMs, nowMs } from '@/shared/utils/timing'; +import { estimateJsonBytes, isRemoteTraceRequest, startupTrace } from '@/shared/utils/startupTrace'; import { sanitizeErrorForLog, sanitizeLogValue, sanitizeTextForLog } from '../logSanitizer'; const log = createLogger('ApiClient'); @@ -129,6 +130,19 @@ export class ApiClient implements IApiClient { private async executeRequest(request: ApiRequest): Promise { const startedAt = nowMs(); + const tracePayloadStartedAt = nowMs(); + const tracePayload = request.type === 'tauri' + ? (request.config as TauriCommandConfig).args + : { + params: (request.config as HttpRequestConfig).params, + data: (request.config as HttpRequestConfig).data, + }; + const traceCommand = request.type === 'tauri' + ? (request.config as TauriCommandConfig).command + : `${(request.config as HttpRequestConfig).method} ${(request.config as HttpRequestConfig).url}`; + const requestBytes = estimateJsonBytes(tracePayload); + const remote = isRemoteTraceRequest(tracePayload); + const requestPayloadEstimateDurationMs = elapsedMs(tracePayloadStartedAt); this.updateStats({ totalRequests: this.stats.totalRequests + 1 }); @@ -157,6 +171,19 @@ export class ApiClient implements IApiClient { const durationMs = elapsedMs(startedAt); + const responseEstimateStartedAt = nowMs(); + const responseBytes = estimateJsonBytes(response.data); + const responseEstimateDurationMs = elapsedMs(responseEstimateStartedAt); + startupTrace.recordApiCall({ + type: request.type, + command: traceCommand, + durationMs, + outcome: 'success', + requestBytes, + responseBytes, + payloadEstimateDurationMs: requestPayloadEstimateDurationMs + responseEstimateDurationMs, + remote, + }); this.recordResponseTime(durationMs); this.updateStats({ successfulRequests: this.stats.successfulRequests + 1 }); @@ -176,6 +203,15 @@ export class ApiClient implements IApiClient { } } catch (error) { this.updateStats({ failedRequests: this.stats.failedRequests + 1 }); + startupTrace.recordApiCall({ + type: request.type, + command: traceCommand, + durationMs: elapsedMs(startedAt), + outcome: 'failure', + requestBytes, + payloadEstimateDurationMs: requestPayloadEstimateDurationMs, + remote, + }); if (request.retryCount < (request.config.retries || this.config.retries)) { diff --git a/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts b/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts new file mode 100644 index 0000000000..b486579958 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { configManager } from './ConfigManager'; + +const configApiMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), + setConfig: vi.fn(), + resetConfig: vi.fn(), + exportConfig: vi.fn(), + importConfig: vi.fn(), +})); + +vi.mock('@/infrastructure/api', () => ({ + configAPI: configApiMocks, +})); + +vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ + configAPI: configApiMocks, +})); + +vi.mock('@/infrastructure/i18n', () => ({ + i18nService: { + t: (key: string) => key, + }, +})); + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('ConfigManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + configManager.clearCache(); + }); + + it('deduplicates concurrent reads for the same config path', async () => { + const deferred = createDeferred(); + configApiMocks.getConfig.mockReturnValueOnce(deferred.promise); + + const first = configManager.getConfig('app.logging.level'); + const second = configManager.getConfig('app.logging.level'); + + expect(configApiMocks.getConfig).toHaveBeenCalledTimes(1); + expect(configApiMocks.getConfig).toHaveBeenCalledWith('app.logging.level'); + + deferred.resolve('debug'); + + await expect(Promise.all([first, second])).resolves.toEqual(['debug', 'debug']); + expect(configApiMocks.getConfig).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/services/ConfigManager.ts b/src/web-ui/src/infrastructure/config/services/ConfigManager.ts index 1860011ef3..2d2f4d3773 100644 --- a/src/web-ui/src/infrastructure/config/services/ConfigManager.ts +++ b/src/web-ui/src/infrastructure/config/services/ConfigManager.ts @@ -5,7 +5,7 @@ import { ConfigValidationResult, ConfigExport, } from '../types'; -import { configAPI } from '@/infrastructure/api'; +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; import { i18nService } from '@/infrastructure/i18n'; import { createLogger } from '@/shared/utils/logger'; import { extractProviderSegmentFromBaseUrl, matchProviderCatalogItemByBaseUrl } from './providerCatalog'; @@ -15,6 +15,7 @@ const log = createLogger('ConfigManager'); class ConfigManagerImpl implements IConfigManager { private configCache: Map = new Map(); + private inFlightReads: Map> = new Map(); private listeners: Set<(path: string, oldValue: any, newValue: any) => void> = new Set(); private pathListeners: Map void>> = new Map(); @@ -67,6 +68,23 @@ class ConfigManagerImpl implements IConfigManager { + private getReadKey(path?: string): string { + return path ?? ''; + } + + private async readConfig(path?: string): Promise { + const config = await configAPI.getConfig(path); + const resolvedConfig = path === 'ai.models' + ? await this.migrateLegacyAiModelsIfNeeded(config) + : config; + + if (path) { + this.configCache.set(path, resolvedConfig); + } + + return resolvedConfig as T; + } + async getConfig(path?: string): Promise { try { @@ -74,18 +92,21 @@ class ConfigManagerImpl implements IConfigManager { return this.configCache.get(path); } - - const config = await configAPI.getConfig(path); - const resolvedConfig = path === 'ai.models' - ? await this.migrateLegacyAiModelsIfNeeded(config) - : config; + const readKey = this.getReadKey(path); + const existingRead = this.inFlightReads.get(readKey); + if (existingRead) { + return (await existingRead) as T; + } - - if (path) { - this.configCache.set(path, resolvedConfig); + const readPromise = this.readConfig(path); + this.inFlightReads.set(readKey, readPromise); + try { + return await readPromise; + } finally { + if (this.inFlightReads.get(readKey) === readPromise) { + this.inFlightReads.delete(readKey); + } } - - return resolvedConfig as T; } catch (error) { log.error('Failed to get config', { path, error }); // Return defaults to avoid breaking the UI. @@ -108,6 +129,7 @@ class ConfigManagerImpl implements IConfigManager { async setConfig(path: string, value: T): Promise { try { const oldValue = this.configCache.get(path); + this.inFlightReads.delete(this.getReadKey(path)); await configAPI.setConfig(path, value); @@ -130,8 +152,10 @@ class ConfigManagerImpl implements IConfigManager { if (path) { this.configCache.delete(path); + this.inFlightReads.delete(this.getReadKey(path)); } else { this.configCache.clear(); + this.inFlightReads.clear(); } } catch (error) { log.error('Failed to reset config', { path, error }); @@ -189,6 +213,7 @@ class ConfigManagerImpl implements IConfigManager { async refreshCache(): Promise { try { this.configCache.clear(); + this.inFlightReads.clear(); } catch (error) { log.error('Failed to refresh cache', error); } @@ -196,6 +221,7 @@ class ConfigManagerImpl implements IConfigManager { clearCache(): void { this.configCache.clear(); + this.inFlightReads.clear(); } @@ -259,6 +285,7 @@ class ConfigManagerImpl implements IConfigManager { async reload(): Promise { try { this.configCache.clear(); + this.inFlightReads.clear(); await this.getConfig('ai.models'); await this.getConfig('ai.agent_models'); @@ -274,4 +301,4 @@ class ConfigManagerImpl implements IConfigManager { export const configManager = new ConfigManagerImpl(); -export default configManager; \ No newline at end of file +export default configManager; diff --git a/src/web-ui/src/infrastructure/config/services/FrontendLogLevelSync.ts b/src/web-ui/src/infrastructure/config/services/FrontendLogLevelSync.ts index a63b57ca80..ed7802dce9 100644 --- a/src/web-ui/src/infrastructure/config/services/FrontendLogLevelSync.ts +++ b/src/web-ui/src/infrastructure/config/services/FrontendLogLevelSync.ts @@ -1,4 +1,4 @@ -import { configAPI } from '@/infrastructure/api'; +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; import { LogLevel, createLogger, @@ -6,13 +6,13 @@ import { setIncludeSensitiveDiagnostics, } from '@/shared/utils/logger'; import type { BackendLogLevel } from '../types'; -import { configManager } from './ConfigManager'; const log = createLogger('FrontendLogLevelSync'); const LOGGING_LEVEL_PATH = 'app.logging.level'; const LOGGING_INCLUDE_SENSITIVE_PATH = 'app.logging.include_sensitive_diagnostics'; -let initialized = false; +let initialSettingsLoaded = false; +let configWatcherInstalled = false; function toFrontendLogLevel(level: string | null | undefined): LogLevel | null { switch (level?.trim().toLowerCase()) { @@ -74,7 +74,7 @@ function applyFrontendLogLevel(level: string | null | undefined, source: string) async function resolveInitialLogLevel(): Promise { const [savedLevelResult, runtimeInfoResult] = await Promise.allSettled([ - configManager.getConfig(LOGGING_LEVEL_PATH), + configAPI.getConfig(LOGGING_LEVEL_PATH) as Promise, configAPI.getRuntimeLoggingInfo(), ]); @@ -93,27 +93,16 @@ async function resolveInitialLogLevel(): Promise { } async function resolveInitialSensitiveDiagnosticsPreference(): Promise { - const value = await configManager.getConfig(LOGGING_INCLUDE_SENSITIVE_PATH); + const value = await configAPI.getConfig(LOGGING_INCLUDE_SENSITIVE_PATH) as boolean | undefined; return value ?? true; } export async function initializeFrontendLogLevelSync(): Promise { - if (initialized) { + if (initialSettingsLoaded) { return; } - initialized = true; - - configManager.onConfigChange((path, _oldValue, newValue) => { - if (path === LOGGING_LEVEL_PATH) { - applyFrontendLogLevel(typeof newValue === 'string' ? newValue : undefined, 'config_change'); - return; - } - - if (path === LOGGING_INCLUDE_SENSITIVE_PATH) { - setIncludeSensitiveDiagnostics(typeof newValue === 'boolean' ? newValue : true); - } - }); + initialSettingsLoaded = true; try { const [initialLevel, includeSensitiveDiagnostics] = await Promise.all([ @@ -126,3 +115,28 @@ export async function initializeFrontendLogLevelSync(): Promise { log.error('Failed to initialize frontend log level sync', error); } } + +export async function installFrontendLogLevelConfigWatcher(): Promise { + if (configWatcherInstalled) { + return; + } + + configWatcherInstalled = true; + + try { + const { configManager } = await import('./ConfigManager'); + configManager.onConfigChange((path, _oldValue, newValue) => { + if (path === LOGGING_LEVEL_PATH) { + applyFrontendLogLevel(typeof newValue === 'string' ? newValue : undefined, 'config_change'); + return; + } + + if (path === LOGGING_INCLUDE_SENSITIVE_PATH) { + setIncludeSensitiveDiagnostics(typeof newValue === 'boolean' ? newValue : true); + } + }); + } catch (error) { + configWatcherInstalled = false; + log.error('Failed to install frontend log level config watcher', error); + } +} diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 02f0fede63..2b47e9a176 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -39,6 +39,13 @@ "runtimeStatus": { "waitingForModelResponse": "Waiting for model response..." }, + "historyState": { + "loadingTitle": "Loading saved session", + "loadingDescription": "Preparing the conversation history.", + "failedTitle": "Session history did not load", + "failedDescription": "Retry loading the saved conversation.", + "retry": "Retry" + }, "usage": { "title": "Session Usage", "unavailable": "Unavailable", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index ac3a5a6362..15dc8fed22 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -39,6 +39,13 @@ "runtimeStatus": { "waitingForModelResponse": "等待模型响应..." }, + "historyState": { + "loadingTitle": "正在加载历史会话", + "loadingDescription": "正在准备该会话的历史内容。", + "failedTitle": "历史会话加载失败", + "failedDescription": "可以重新加载已保存的会话内容。", + "retry": "重试" + }, "usage": { "title": "会话用量", "unavailable": "不可用", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 1e509cbbed..f7e17f177a 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -39,6 +39,13 @@ "runtimeStatus": { "waitingForModelResponse": "等待模型回應..." }, + "historyState": { + "loadingTitle": "正在載入歷史會話", + "loadingDescription": "正在準備該會話的歷史內容。", + "failedTitle": "歷史會話載入失敗", + "failedDescription": "可以重新載入已儲存的會話內容。", + "retry": "重試" + }, "usage": { "title": "工作階段用量", "unavailable": "不可用", diff --git a/src/web-ui/src/main.tsx b/src/web-ui/src/main.tsx index 48a8c533db..5a83acd919 100644 --- a/src/web-ui/src/main.tsx +++ b/src/web-ui/src/main.tsx @@ -18,6 +18,7 @@ import { loader } from '@monaco-editor/react'; import { getMonacoPath, getMonacoWorkerPath, logMonacoResourceCheck } from './tools/editor/utils/monacoPathHelper'; import { bootstrapLogger, createLogger, initLogger } from './shared/utils/logger'; import { elapsedMs, logElapsed, measureAsyncAndLog, nowMs } from './shared/utils/timing'; +import { startupTrace } from './shared/utils/startupTrace'; import { buildReactCrashLogPayload, isMinifiedReactErrorMessage, @@ -27,6 +28,7 @@ import { bootstrapLogger(); const log = createLogger('App'); +startupTrace.markPhase('first_script_eval'); /** Dedupe only for white-screen heuristic (empty #root), not for Error Boundary logs. */ const WHITE_SCREEN_LOGGED_FLAG = '__bitfun_white_screen_crash_logged__'; @@ -225,6 +227,7 @@ const DEFAULT_WORKER = 'base/worker/workerMain.js'; /** Logger, theme, and minimal deps — must finish before first React paint (F5 / webview reload does not re-run Tauri init script). */ async function initializeBeforeRender(): Promise { const phaseStartedAt = nowMs(); + startupTrace.markPhase('before_render_start'); await measureAsyncAndLog(log, 'Startup step completed', () => initLogger(), { data: { step: 'initLogger' }, }); @@ -239,20 +242,6 @@ async function initializeBeforeRender(): Promise { log.debug('Monaco loader configured', { vs: monacoPath, isDev }); log.info('Initializing BitFun'); - await measureAsyncAndLog(log, 'Startup step completed', async () => { - const { registerDefaultContextTypes } = await import('./shared/context-system/core/registerDefaultTypes'); - registerDefaultContextTypes(); - }, { - data: { step: 'registerDefaultContextTypes' }, - }); - - await measureAsyncAndLog(log, 'Startup step completed', async () => { - const { initRecommendationProviders } = await import('./flow_chat/components/smart-recommendations'); - initRecommendationProviders(); - }, { - data: { step: 'initRecommendationProviders' }, - }); - await measureAsyncAndLog(log, 'Startup step completed', async () => { const { themeService } = await import('./infrastructure/theme'); await themeService.initialize(); @@ -263,20 +252,36 @@ async function initializeBeforeRender(): Promise { logElapsed(log, 'Startup phase completed', phaseStartedAt, { data: { phase: 'initializeBeforeRender' }, }); + startupTrace.markPhase('before_render_end', { + durationMs: elapsedMs(phaseStartedAt), + }); } /** Rest of startup runs after the shell is visible so refresh latency stays reasonable. */ async function initializeAfterRender(): Promise { const phaseStartedAt = nowMs(); + startupTrace.markPhase('after_render_start'); const { fontPreferenceService } = await import('./infrastructure/font-preference'); await fontPreferenceService.initialize(); log.info('Font preference initialized at startup'); - const { configManager } = await import('./infrastructure/config'); + const { configManager } = await import('./infrastructure/config/services/ConfigManager'); await configManager.getConfig('editor'); log.info('Editor configuration preloaded'); const initResults = await Promise.allSettled([ + (async () => { + const { installFrontendLogLevelConfigWatcher } = await import('./infrastructure/config/services/FrontendLogLevelSync'); + await installFrontendLogLevelConfigWatcher(); + })(), + (async () => { + const { registerDefaultContextTypes } = await import('./shared/context-system/core/registerDefaultTypes'); + registerDefaultContextTypes(); + })(), + (async () => { + const { initRecommendationProviders } = await import('./flow_chat/components/smart-recommendations'); + initRecommendationProviders(); + })(), initializeAllTools(), (async () => { initContextMenuSystem({ @@ -299,7 +304,14 @@ async function initializeAfterRender(): Promise { ]); initResults.forEach((result, index) => { - const names = ['Tools', 'ContextMenu', 'Editors']; + const names = [ + 'LogLevelConfigWatcher', + 'DefaultContextTypes', + 'RecommendationProviders', + 'Tools', + 'ContextMenu', + 'Editors', + ]; if (result.status === 'rejected') { log.warn('Initialization failed', { module: names[index], error: result.reason }); } @@ -309,10 +321,14 @@ async function initializeAfterRender(): Promise { logElapsed(log, 'Startup phase completed', phaseStartedAt, { data: { phase: 'initializeAfterRender' }, }); + startupTrace.markPhase('after_render_end', { + durationMs: elapsedMs(phaseStartedAt), + }); } async function startApplication(): Promise { const appStartedAt = nowMs(); + startupTrace.markPhase('start_application_start'); try { await initializeBeforeRender(); } catch (error) { @@ -345,6 +361,10 @@ async function startApplication(): Promise { sinceStartupMs: elapsedMs(appStartedAt), }, }); + startupTrace.markPhase('agent_companion_render_scheduled', { + sinceStartupMs: elapsedMs(appStartedAt), + }); + startupTrace.flushSummary('agent_companion_render_scheduled'); return; } @@ -363,6 +383,9 @@ async function startApplication(): Promise { sinceStartupMs: elapsedMs(appStartedAt), }, }); + startupTrace.markPhase('react_render_scheduled', { + sinceStartupMs: elapsedMs(appStartedAt), + }); try { await initializeAfterRender(); @@ -373,6 +396,10 @@ async function startApplication(): Promise { logElapsed(log, 'Startup phase completed', appStartedAt, { data: { phase: 'startApplication' }, }); + startupTrace.markPhase('start_application_end', { + durationMs: elapsedMs(appStartedAt), + }); + startupTrace.flushSummary('start_application_completed'); } void startApplication(); diff --git a/src/web-ui/src/shared/utils/startupTrace.test.ts b/src/web-ui/src/shared/utils/startupTrace.test.ts new file mode 100644 index 0000000000..a35cbd5c3f --- /dev/null +++ b/src/web-ui/src/shared/utils/startupTrace.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createStartupTrace, + estimateJsonBytes, + isRemoteTraceContext, + isRemoteTraceRequest, +} from './startupTrace'; +import type { LoggerLike } from './timing'; + +function createTestLogger(): LoggerLike & { + debug: ReturnType; + info: ReturnType; + warn: ReturnType; +} { + return { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +describe('startupTrace', () => { + it('records startup phases without exposing sensitive fields', () => { + const logger = createTestLogger(); + const trace = createStartupTrace({ + logger, + traceId: 'trace-test', + now: () => 100, + }); + + trace.markPhase('before_render_start', { + apiKey: 'secret', + command: 'get_config', + request: { nested: 'payload' }, + remoteConnectionId: 'ssh-user@example.com', + sshHost: 'example.internal', + remote: true, + }); + + expect(logger.debug).toHaveBeenCalledTimes(1); + const [, payload] = logger.debug.mock.calls[0]; + expect(payload).toMatchObject({ + traceId: 'trace-test', + phase: 'before_render_start', + command: 'get_config', + remote: true, + }); + expect(payload).not.toHaveProperty('apiKey'); + expect(payload).not.toHaveProperty('request'); + expect(payload).not.toHaveProperty('remoteConnectionId'); + expect(payload).not.toHaveProperty('sshHost'); + }); + + it('aggregates API calls by command and remote status', () => { + const logger = createTestLogger(); + const trace = createStartupTrace({ + logger, + traceId: 'trace-test', + now: () => 100, + }); + + trace.recordApiCall({ + type: 'tauri', + command: 'list_persisted_sessions', + durationMs: 12.4, + requestBytes: 100, + responseBytes: 500, + remote: true, + cacheOutcome: 'miss', + }); + trace.recordApiCall({ + type: 'tauri', + command: 'list_persisted_sessions', + durationMs: 7.6, + requestBytes: 80, + responseBytes: 300, + remote: true, + cacheOutcome: 'hit', + }); + trace.recordApiCall({ + type: 'tauri', + command: 'get_config', + durationMs: 5, + requestBytes: 40, + responseBytes: 60, + remote: false, + }); + trace.recordApiCall({ + type: 'tauri', + command: 'git_get_status', + durationMs: 8, + requestBytes: 20, + remote: false, + outcome: 'failure', + }); + + trace.flushSummary('test'); + + expect(logger.info).toHaveBeenCalledTimes(1); + const [, payload] = logger.info.mock.calls[0]; + expect(payload).toMatchObject({ + traceId: 'trace-test', + reason: 'test', + phases: { + events: [], + }, + api: { + totalCount: 4, + successCount: 3, + failureCount: 1, + cacheHitCount: 1, + cacheMissCount: 1, + cacheUnknownCount: 2, + remoteCount: 2, + requestBytes: 240, + responseBytes: 860, + }, + }); + expect(payload.api.byCommand).toEqual([ + { + command: 'list_persisted_sessions', + count: 2, + successCount: 2, + failureCount: 0, + cacheHitCount: 1, + cacheMissCount: 1, + cacheUnknownCount: 0, + remoteCount: 2, + totalDurationMs: 20, + maxDurationMs: 12.4, + requestBytes: 180, + responseBytes: 800, + }, + { + command: 'git_get_status', + count: 1, + successCount: 0, + failureCount: 1, + cacheHitCount: 0, + cacheMissCount: 0, + cacheUnknownCount: 1, + remoteCount: 0, + totalDurationMs: 8, + maxDurationMs: 8, + requestBytes: 20, + responseBytes: 0, + }, + { + command: 'get_config', + count: 1, + successCount: 1, + failureCount: 0, + cacheHitCount: 0, + cacheMissCount: 0, + cacheUnknownCount: 1, + remoteCount: 0, + totalDurationMs: 5, + maxDurationMs: 5, + requestBytes: 40, + responseBytes: 60, + }, + ]); + }); + + it('flushes bounded phase records so early events survive logger startup timing', () => { + const logger = createTestLogger(); + let now = 10; + const trace = createStartupTrace({ + logger, + traceId: 'trace-test', + now: () => now, + maxPhaseEvents: 2, + }); + + trace.markPhase('first_script_eval', { remote: false }); + now = 20; + trace.markPhase('before_render_start'); + now = 30; + trace.markPhase('ignored_after_limit'); + trace.flushSummary('test'); + + const [, payload] = logger.info.mock.calls[0]; + expect(payload.phases).toMatchObject({ + count: 2, + events: [ + { + traceId: 'trace-test', + phase: 'first_script_eval', + atMs: 10, + remote: false, + }, + { + traceId: 'trace-test', + phase: 'before_render_start', + atMs: 20, + }, + ], + }); + }); + + it('does not log when disabled', () => { + const logger = createTestLogger(); + const trace = createStartupTrace({ + enabled: false, + logger, + traceId: 'trace-test', + now: () => 100, + }); + + trace.markPhase('first_script_eval'); + trace.recordApiCall({ + type: 'tauri', + command: 'get_config', + durationMs: 1, + remote: false, + }); + trace.flushSummary('disabled'); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('uses the desktop injected trace id when available', () => { + const previousTraceId = (globalThis as { __BITFUN_STARTUP_TRACE_ID__?: string }) + .__BITFUN_STARTUP_TRACE_ID__; + (globalThis as { __BITFUN_STARTUP_TRACE_ID__?: string }).__BITFUN_STARTUP_TRACE_ID__ = + 'desktop-123'; + + try { + const trace = createStartupTrace({ + logger: createTestLogger(), + now: () => 100, + }); + + expect(trace.traceId).toBe('desktop-123'); + } finally { + if (previousTraceId === undefined) { + delete (globalThis as { __BITFUN_STARTUP_TRACE_ID__?: string }) + .__BITFUN_STARTUP_TRACE_ID__; + } else { + (globalThis as { __BITFUN_STARTUP_TRACE_ID__?: string }).__BITFUN_STARTUP_TRACE_ID__ = + previousTraceId; + } + } + }); +}); + +describe('startupTrace payload helpers', () => { + it('estimates JSON payload size with a hard cap', () => { + const value = { + small: 'ok', + large: 'x'.repeat(10_000), + }; + + expect(estimateJsonBytes(value, 128)).toBe(128); + }); + + it('detects remote requests without needing full payload serialization', () => { + expect(isRemoteTraceRequest({ + request: { + remoteConnectionId: 'ssh-user@example.com', + }, + })).toBe(true); + expect(isRemoteTraceRequest({ + request: { + workspacePath: 'D:/workspace/bitfun', + }, + })).toBe(false); + expect(isRemoteTraceRequest({ + request: { + sshHost: 'localhost', + }, + })).toBe(false); + expect(isRemoteTraceRequest({ + request: { + sshHost: 'example.internal', + }, + })).toBe(true); + expect(isRemoteTraceRequest({ + request: { + remoteSshHost: 'localhost', + }, + })).toBe(false); + expect(isRemoteTraceRequest({ + request: { + remoteSshHost: 'example.internal', + }, + })).toBe(true); + }); + + it('keeps local ssh hosts out of remote session counters', () => { + expect(isRemoteTraceContext(undefined, 'localhost')).toBe(false); + expect(isRemoteTraceContext(undefined, '127.0.0.1')).toBe(false); + expect(isRemoteTraceContext('connection-1', 'localhost')).toBe(true); + expect(isRemoteTraceContext(undefined, 'example.internal')).toBe(true); + }); +}); diff --git a/src/web-ui/src/shared/utils/startupTrace.ts b/src/web-ui/src/shared/utils/startupTrace.ts new file mode 100644 index 0000000000..6e3eece770 --- /dev/null +++ b/src/web-ui/src/shared/utils/startupTrace.ts @@ -0,0 +1,370 @@ +import { createLogger } from './logger'; +import { roundDurationMs, type LoggerLike } from './timing'; + +type NowFn = () => number; +type TraceData = Record; + +export type StartupTraceRequestType = 'tauri' | 'http'; + +export interface StartupTraceApiCall { + type: StartupTraceRequestType; + command: string; + durationMs: number; + outcome?: 'success' | 'failure'; + cacheOutcome?: 'hit' | 'miss' | 'unknown'; + requestBytes?: number; + responseBytes?: number; + payloadEstimateDurationMs?: number; + remote: boolean; +} + +export interface StartupTraceOptions { + enabled?: boolean; + logger?: LoggerLike; + traceId?: string; + now?: NowFn; + maxPhaseEvents?: number; +} + +interface CommandAggregate { + command: string; + count: number; + successCount: number; + failureCount: number; + cacheHitCount: number; + cacheMissCount: number; + cacheUnknownCount: number; + remoteCount: number; + totalDurationMs: number; + maxDurationMs: number; + requestBytes: number; + responseBytes: number; +} + +interface PhaseRecord { + traceId: string; + phase: string; + atMs: number; + [key: string]: unknown; +} + +const DEFAULT_MAX_ESTIMATED_BYTES = 64 * 1024; +const SENSITIVE_KEY_PATTERN = + /(api[-_]?key|authorization|bearer|token|secret|password|credential|payload|request|response|args|remoteconnectionid|remote[_-]?connection[_-]?id|remotesshhost|remote[_-]?ssh[_-]?host|sshhost|ssh[_-]?host|workspacepath|workspace[_-]?path)/i; + +function createTraceId(): string { + const injectedTraceId = (globalThis as { __BITFUN_STARTUP_TRACE_ID__?: unknown }) + .__BITFUN_STARTUP_TRACE_ID__; + if (typeof injectedTraceId === 'string' && injectedTraceId.trim().length > 0) { + return injectedTraceId; + } + + const cryptoLike = globalThis.crypto; + if (cryptoLike && typeof cryptoLike.randomUUID === 'function') { + return cryptoLike.randomUUID(); + } + return `startup-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function isSafeScalar(value: unknown): value is string | number | boolean | null { + return ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +} + +function sanitizeTraceData(data?: TraceData): TraceData | undefined { + if (!data) { + return undefined; + } + + const sanitized: TraceData = {}; + for (const [key, value] of Object.entries(data)) { + if (SENSITIVE_KEY_PATTERN.test(key)) { + continue; + } + if (isSafeScalar(value)) { + sanitized[key] = value; + continue; + } + if (Array.isArray(value) && value.every(isSafeScalar)) { + sanitized[key] = value; + } + } + return sanitized; +} + +function isNonLocalRemoteHost(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return false; + } + return !( + normalized === 'localhost' || + normalized.startsWith('localhost:') || + normalized === '127.0.0.1' || + normalized.startsWith('127.0.0.1:') || + normalized === '::1' || + normalized === '[::1]' || + normalized.startsWith('[::1]:') + ); +} + +export function isRemoteTraceContext( + remoteConnectionId?: unknown, + remoteSshHost?: unknown +): boolean { + if (typeof remoteConnectionId === 'string' && remoteConnectionId.trim().length > 0) { + return true; + } + return isNonLocalRemoteHost(remoteSshHost); +} + +function hasRemoteKey(key: string, value: unknown): boolean { + const normalized = key.toLowerCase(); + if ( + normalized === 'remoteconnectionid' || + normalized === 'remote_connection_id' + ) { + return typeof value === 'string' && value.trim().length > 0; + } + if ( + normalized === 'remotesshhost' || + normalized === 'remote_ssh_host' || + normalized === 'sshhost' || + normalized === 'ssh_host' + ) { + return isNonLocalRemoteHost(value); + } + if (normalized === 'workspacekind' || normalized === 'workspace_kind') { + return typeof value === 'string' && value.toLowerCase() === 'remote'; + } + return false; +} + +export function isRemoteTraceRequest(value: unknown, maxDepth = 4): boolean { + if (!value || typeof value !== 'object' || maxDepth < 0) { + return false; + } + + if (Array.isArray(value)) { + return value.some(item => isRemoteTraceRequest(item, maxDepth - 1)); + } + + for (const [key, nested] of Object.entries(value as Record)) { + if (hasRemoteKey(key, nested)) { + return true; + } + if (nested && typeof nested === 'object' && isRemoteTraceRequest(nested, maxDepth - 1)) { + return true; + } + } + + return false; +} + +export function estimateJsonBytes(value: unknown, maxBytes = DEFAULT_MAX_ESTIMATED_BYTES): number { + const seen = new WeakSet(); + let total = 0; + + const add = (amount: number) => { + total = Math.min(maxBytes, total + amount); + }; + + const visit = (current: unknown, depth: number) => { + if (total >= maxBytes || depth < 0) { + return; + } + + if (current === null || current === undefined) { + add(4); + return; + } + + if (typeof current === 'string') { + add(current.length + 2); + return; + } + + if (typeof current === 'number' || typeof current === 'boolean') { + add(String(current).length); + return; + } + + if (typeof current !== 'object') { + add(String(current).length + 2); + return; + } + + if (seen.has(current)) { + add(12); + return; + } + seen.add(current); + + if (Array.isArray(current)) { + add(2); + for (const item of current) { + add(1); + visit(item, depth - 1); + if (total >= maxBytes) { + return; + } + } + return; + } + + add(2); + for (const [key, nested] of Object.entries(current as Record)) { + add(key.length + 3); + visit(nested, depth - 1); + if (total >= maxBytes) { + return; + } + } + }; + + visit(value, 8); + return total; +} + +export class StartupTrace { + private readonly enabled: boolean; + private readonly logger: LoggerLike; + private readonly now: NowFn; + private readonly maxPhaseEvents: number; + readonly traceId: string; + private phaseEvents = 0; + private readonly phaseRecords: PhaseRecord[] = []; + private readonly commandAggregates = new Map(); + private totalApiCount = 0; + private successfulApiCount = 0; + private failedApiCount = 0; + private cacheHitCount = 0; + private cacheMissCount = 0; + private cacheUnknownCount = 0; + private remoteApiCount = 0; + private requestBytes = 0; + private responseBytes = 0; + private payloadEstimateDurationMs = 0; + + constructor(options: StartupTraceOptions = {}) { + this.enabled = options.enabled ?? true; + this.logger = options.logger ?? createLogger('StartupTrace'); + this.traceId = options.traceId ?? createTraceId(); + this.now = options.now ?? (() => globalThis.performance?.now?.() ?? Date.now()); + this.maxPhaseEvents = options.maxPhaseEvents ?? 80; + } + + markPhase(phase: string, data?: TraceData): void { + if (!this.enabled || this.phaseEvents >= this.maxPhaseEvents) { + return; + } + this.phaseEvents += 1; + const phaseRecord = { + traceId: this.traceId, + phase, + atMs: roundDurationMs(this.now()), + ...(sanitizeTraceData(data) ?? {}), + }; + this.phaseRecords.push(phaseRecord); + this.logger.debug('Startup trace event', phaseRecord); + } + + recordApiCall(call: StartupTraceApiCall): void { + if (!this.enabled) { + return; + } + + const durationMs = roundDurationMs(call.durationMs); + const requestBytes = call.requestBytes ?? 0; + const responseBytes = call.responseBytes ?? 0; + const succeeded = call.outcome !== 'failure'; + const cacheOutcome = call.cacheOutcome ?? 'unknown'; + this.totalApiCount += 1; + this.successfulApiCount += succeeded ? 1 : 0; + this.failedApiCount += succeeded ? 0 : 1; + this.cacheHitCount += cacheOutcome === 'hit' ? 1 : 0; + this.cacheMissCount += cacheOutcome === 'miss' ? 1 : 0; + this.cacheUnknownCount += cacheOutcome === 'unknown' ? 1 : 0; + this.remoteApiCount += call.remote ? 1 : 0; + this.requestBytes += requestBytes; + this.responseBytes += responseBytes; + this.payloadEstimateDurationMs += call.payloadEstimateDurationMs ?? 0; + + const existing = this.commandAggregates.get(call.command) ?? { + command: call.command, + count: 0, + successCount: 0, + failureCount: 0, + cacheHitCount: 0, + cacheMissCount: 0, + cacheUnknownCount: 0, + remoteCount: 0, + totalDurationMs: 0, + maxDurationMs: 0, + requestBytes: 0, + responseBytes: 0, + }; + + existing.count += 1; + existing.successCount += succeeded ? 1 : 0; + existing.failureCount += succeeded ? 0 : 1; + existing.cacheHitCount += cacheOutcome === 'hit' ? 1 : 0; + existing.cacheMissCount += cacheOutcome === 'miss' ? 1 : 0; + existing.cacheUnknownCount += cacheOutcome === 'unknown' ? 1 : 0; + existing.remoteCount += call.remote ? 1 : 0; + existing.totalDurationMs = roundDurationMs(existing.totalDurationMs + durationMs); + existing.maxDurationMs = Math.max(existing.maxDurationMs, durationMs); + existing.requestBytes += requestBytes; + existing.responseBytes += responseBytes; + this.commandAggregates.set(call.command, existing); + } + + flushSummary(reason: string): void { + if (!this.enabled) { + return; + } + + const byCommand = Array.from(this.commandAggregates.values()) + .sort((left, right) => right.totalDurationMs - left.totalDurationMs) + .map(item => ({ + ...item, + totalDurationMs: roundDurationMs(item.totalDurationMs), + maxDurationMs: roundDurationMs(item.maxDurationMs), + })); + + this.logger.info('Startup trace summary', { + traceId: this.traceId, + reason, + phases: { + count: this.phaseEvents, + events: this.phaseRecords, + }, + api: { + totalCount: this.totalApiCount, + successCount: this.successfulApiCount, + failureCount: this.failedApiCount, + cacheHitCount: this.cacheHitCount, + cacheMissCount: this.cacheMissCount, + cacheUnknownCount: this.cacheUnknownCount, + remoteCount: this.remoteApiCount, + requestBytes: this.requestBytes, + responseBytes: this.responseBytes, + payloadEstimateDurationMs: roundDurationMs(this.payloadEstimateDurationMs), + byCommand, + }, + }); + } +} + +export function createStartupTrace(options: StartupTraceOptions = {}): StartupTrace { + return new StartupTrace(options); +} + +export const startupTrace = createStartupTrace(); From 858ae5ca0785da27dc47d161229078662c65795d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 16 May 2026 18:45:38 +0800 Subject: [PATCH 05/76] fix(agent): Edit tool hardening and flow chat code block UI (#755) * fix edit tool * fix edit tool hide status * fix code block demonstration --- .../tools/implementations/file_edit_tool.rs | 28 ++- src/crates/tool-runtime/src/fs/edit_file.rs | 73 +++++++- .../components/Markdown/Markdown.tsx | 165 ++++++++++++++---- .../flow_chat/components/FlowTextBlock.tsx | 9 +- .../components/modern/ModelRoundItem.tsx | 8 +- .../flow_chat/tool-cards/TerminalToolCard.tsx | 1 - 6 files changed, 240 insertions(+), 44 deletions(-) diff --git a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs index c6641d108d..b6a1732b01 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -1,8 +1,8 @@ -use crate::agentic::tools::ToolPathOperation; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use tool_runtime::fs::edit_file::{apply_edit_to_content, edit_file}; pub struct FileEditTool; @@ -73,8 +73,9 @@ Usage: }, "old_string": { "type": "string", + "minLength": 1, "default": "", - "description": "The exact current text to replace. It must match the current file contents exactly, including whitespace and indentation, and must be unique unless replace_all is true. Copy it from a fresh Read result, excluding the line-number prefix. If this file was edited earlier in the turn, read the target area again before building old_string. Include stable surrounding context when a short snippet may appear multiple times." + "description": "The non-empty exact current text to replace. It must match the current file contents exactly, including whitespace and indentation, and must be unique unless replace_all is true. Copy it from a fresh Read result, excluding the line-number prefix. If this file was edited earlier in the turn, read the target area again before building old_string. Include stable surrounding context when a short snippet may appear multiple times." }, "new_string": { "type": "string", @@ -169,6 +170,23 @@ Usage: .get("new_string") .and_then(|v| v.as_str()) .unwrap_or(""); + if old_string.is_empty() { + return ValidationResult { + result: false, + message: Some("old_string cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + if old_string == new_string { + return ValidationResult { + result: false, + message: Some("new_string must be different from old_string".to_string()), + error_code: Some(400), + meta: None, + }; + } + let largest_lines = old_string.lines().count().max(new_string.lines().count()); let largest_bytes = old_string.len().max(new_string.len()); if largest_lines > LARGE_EDIT_SOFT_LINE_LIMIT || largest_bytes > LARGE_EDIT_SOFT_BYTE_LIMIT @@ -308,10 +326,12 @@ mod tests { fn edit_multiple_match_error_includes_unique_context_guidance() { let message = FileEditTool::enhance_edit_error( "src/lib.rs", - "`old_string` appears 2 times in file".to_string(), + "`old_string` appears 2 times in file\nMatched contexts:\n[match 1 starts at line 4]" + .to_string(), ); assert!(message.contains("old_string")); + assert!(message.contains("[match 1 starts at line 4]")); assert!(message.contains("include more surrounding context")); assert!(message.contains("replace_all only when every occurrence should change")); } diff --git a/src/crates/tool-runtime/src/fs/edit_file.rs b/src/crates/tool-runtime/src/fs/edit_file.rs index 125a1026c7..e6731c058e 100644 --- a/src/crates/tool-runtime/src/fs/edit_file.rs +++ b/src/crates/tool-runtime/src/fs/edit_file.rs @@ -1,6 +1,10 @@ use crate::util::string::normalize_string; use std::fs; +const MAX_MATCH_CONTEXTS: usize = 5; +const CONTEXT_LINES_BEFORE: usize = 2; +const CONTEXT_LINES_AFTER: usize = 2; + /// Edit result, contains line number range information #[derive(Debug, Clone, PartialEq, Eq)] pub struct EditResult { @@ -30,6 +34,40 @@ fn count_newlines(s: &str) -> usize { s.matches('\n').count() } +fn match_contexts(content: &str, old_string: &str, matches: &[(usize, &str)]) -> String { + let lines: Vec<&str> = content.split('\n').collect(); + let old_line_count = count_newlines(old_string) + 1; + let mut contexts = Vec::new(); + + for (idx, (byte_pos, _)) in matches.iter().take(MAX_MATCH_CONTEXTS).enumerate() { + let start_line = count_lines_before(content, *byte_pos); + let old_end_line = start_line + old_line_count.saturating_sub(1); + let context_start_line = start_line.saturating_sub(CONTEXT_LINES_BEFORE).max(1); + let context_end_line = (old_end_line + CONTEXT_LINES_AFTER).min(lines.len().max(1)); + let snippet = lines[(context_start_line - 1)..context_end_line].join("\n"); + + contexts.push(format!( + "[match {} starts at line {}]\n{}", + idx + 1, + start_line, + snippet + )); + } + + let omitted = matches.len().saturating_sub(MAX_MATCH_CONTEXTS); + let omitted_note = if omitted > 0 { + format!("\n... {omitted} more matches omitted.") + } else { + String::new() + }; + + format!( + "Matched contexts (copy exact text from a snippet and add stable surrounding lines to make `old_string` unique):\n{}{}", + contexts.join("\n---\n"), + omitted_note + ) +} + pub fn apply_edit_to_content( content: &str, old_string: &str, @@ -40,6 +78,11 @@ pub fn apply_edit_to_content( let normalized_old = normalize_string(old_string); let normalized_new = normalize_string(new_string); let normalized_content = normalize_string(content); + + if normalized_old.is_empty() { + return Err("old_string cannot be empty.".to_string()); + } + let matches: Vec<_> = normalized_content.match_indices(&normalized_old).collect(); if matches.is_empty() { @@ -48,8 +91,9 @@ pub fn apply_edit_to_content( if matches.len() > 1 && !replace_all { return Err(format!( - "`old_string` appears {} times in file, either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.", - matches.len() + "`old_string` appears {} times in file, either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.\n{}", + matches.len(), + match_contexts(&normalized_content, &normalized_old, &matches) )); } @@ -142,6 +186,31 @@ mod tests { assert_eq!(result.edit_result.start_line, 1); } + #[test] + fn apply_edit_to_content_rejects_empty_old_string() { + let error = apply_edit_to_content("alpha\n", "", "beta", false) + .expect_err("empty old_string should fail"); + + assert_eq!(error, "old_string cannot be empty."); + } + + #[test] + fn apply_edit_to_content_multiple_match_error_includes_contexts() { + let error = apply_edit_to_content( + "first block\n same();\nend first\n\nsecond block\n same();\nend second\n", + " same();", + " changed();", + false, + ) + .expect_err("ambiguous edit should fail"); + + assert!(error.contains("`old_string` appears 2 times in file")); + assert!(error.contains("[match 1 starts at line 2]")); + assert!(error.contains("first block")); + assert!(error.contains("[match 2 starts at line 6]")); + assert!(error.contains("second block")); + } + #[test] fn edit_file_preserves_crlf_when_editing_with_lf_old_string() { let path = write_temp_file("first\r\nalpha\r\nbeta\r\n"); diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx index 8dea64b978..e1c1f95ba1 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx @@ -524,6 +524,71 @@ function formatCodeLanguageLabel(lang: string): string { return raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase(); } +interface StreamingCodeFallbackProps { + code: string; + language: string; + bodyStyle: React.CSSProperties; + codeTagStyle: React.CSSProperties; + gutterColor: string; +} + +/** + * Lightweight, stable line-numbered code renderer used while the surrounding + * markdown is still streaming. Its layout deliberately matches the + * `react-syntax-highlighter` `showLineNumbers` output: a fixed-width inline + * line-number column followed by the line content, separated visually by the + * same padding. This keeps the code block from visibly jumping when streaming + * completes and the heavy Prism highlighter takes over. + */ +const StreamingCodeFallback: React.FC = ({ + code, + language, + bodyStyle, + codeTagStyle, + gutterColor, +}) => { + const lineCount = code.length === 0 ? 1 : code.split('\n').length; + let gutterText = ''; + for (let i = 1; i <= lineCount; i++) { + gutterText += i === lineCount ? `${i}` : `${i}\n`; + } + + return ( +
+      
+        
+        
+          {code}
+        
+      
+    
+ ); +}; + const CopyButton: React.FC<{ code: string }> = ({ code }) => { const { t } = useI18n('components'); const [copied, setCopied] = useState(false); @@ -616,15 +681,31 @@ export const Markdown = React.memo(({ const { markdownContent, reproductionSteps } = useMemo(() => { const regex = /([\s\S]*?)<\/reproduction[\s_]*steps\s*>?/g; const match = regex.exec(contentStr); - + + let body = contentStr; + let steps: string | null = null; if (match) { - const steps = match[1].trim(); - const cleanedContent = contentStr.replace(regex, '').trim(); - return { markdownContent: cleanedContent, reproductionSteps: steps }; + steps = match[1].trim(); + body = contentStr.replace(regex, '').trim(); } - - return { markdownContent: contentStr, reproductionSteps: null }; - }, [contentStr]); + + // While streaming, the model may emit an opening ```lang fence long before + // the closing ```. react-markdown then flips between parsing the tail as a + // paragraph (raw text) and as a fenced code block as more tokens arrive, + // which unmounts/remounts the code block and shifts its position every + // tick. Append a synthetic closing fence so the AST stays a stable code + // block from the moment the opening fence appears. + if (isStreaming) { + const fenceMatches = body.match(/^[ \t]{0,3}(`{3,}|~{3,})/gm); + if (fenceMatches && fenceMatches.length % 2 === 1) { + const lastFence = fenceMatches[fenceMatches.length - 1].trim(); + const needsLeadingNewline = !body.endsWith('\n'); + body = `${body}${needsLeadingNewline ? '\n' : ''}${lastFence}`; + } + } + + return { markdownContent: body, reproductionSteps: steps }; + }, [contentStr, isStreaming]); const linkMap = useMemo(() => { const map = new Map(); @@ -847,7 +928,16 @@ export const Markdown = React.memo(({ } const normalizedLang = getPrismLanguageFromAlias(language); - + const codeBodyStyle: React.CSSProperties = { + margin: 0, + borderRadius: '0 0 8px 8px', + fontSize: '0.875rem', + lineHeight: '1.55', + }; + const codeTagStyle: React.CSSProperties = { + fontFamily: 'var(--markdown-font-mono, "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace)', + }; + return (
@@ -855,31 +945,40 @@ export const Markdown = React.memo(({
- - {code} - + {isStreaming ? ( + // While the text is still streaming, skip the heavy Prism + // tokenization on every tick (it re-highlights the entire + // code each frame, which is the main source of code-block + // jitter in the chat). Render a lightweight, line-numbered + //
 that matches Prism's `showLineNumbers` layout so the
+            // gutter width and line indentation stay visually stable
+            // across the eventual fallback -> Prism swap when streaming
+            // completes.
+            
+          ) : (
+            
+              {code}
+            
+          )}
           
); diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx index ef075bb3eb..a29d065845 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx @@ -101,7 +101,14 @@ export const FlowTextBlock = React.memo(({ {textItem.isMarkdown ? ( 500ms idle and forces the + // fallback
 / Prism highlighter to swap back and forth,
+          // which makes line numbers and the code body visibly shake
+          // until the stream finally completes.
+          isStreaming={isStreaming}
           onFileViewRequest={onFileViewRequest}
           onTabOpen={onTabOpen}
           onOpenVisualization={(visualization) => {
diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
index c8178baa87..0ff8f41e32 100644
--- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
@@ -697,11 +697,13 @@ const FlowItemRenderer: React.FC = ({ item, turnId, isLas
     case 'tool': {
       const toolItem = item as FlowToolItem;
       const isCompletedTool = toolItem.status === 'completed';
+      const isCollapsible = isCollapsibleTool(toolItem.toolName);
       const toolClassName = [
         'flowchat-flow-item',
-        'flowchat-flow-item--tool-transition',
-        isCompletedTool ? 'flowchat-flow-item--tool-completed' : 'flowchat-flow-item--tool-active',
-      ].join(' ');
+        isCollapsible && isCompletedTool ? 'flowchat-flow-item--tool-transition' : null,
+        isCollapsible && isCompletedTool ? 'flowchat-flow-item--tool-completed' : null,
+        isCollapsible && !isCompletedTool ? 'flowchat-flow-item--tool-active' : null,
+      ].filter(Boolean).join(' ');
 
       return wrapContent(
         
diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index 646b30fe43..18a9e90f10 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -104,7 +104,6 @@ function renderTerminalExpandedContent(params: {
From 8c18ee41674d9dd4a94fdbf399efc0515fa57294 Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 16 May 2026 19:45:16 +0800 Subject: [PATCH 06/76] perf(web): reduce startup background contention Add a bounded background task scheduler and move editor config/Monaco warmup off the awaited after-render startup path. Reduce early Git and session metadata pressure by using basic-only startup refresh, forcing fresh state before dangerous Git operations, and batching persisted-session model config reads. Keep docs and baseline artifacts out of the PR; high-risk session paging, worker conversion, native splash, and threshold changes remain out of scope. --- .../src/flow_chat/store/FlowChatStore.test.ts | 65 +++++ .../src/flow_chat/store/FlowChatStore.ts | 183 ++++++++------ src/web-ui/src/main.tsx | 27 +- .../utils/backgroundTaskScheduler.test.ts | 122 +++++++++ .../shared/utils/backgroundTaskScheduler.ts | 235 ++++++++++++++++++ .../services/MonacoStartupWarmup.test.ts | 51 ++++ .../editor/services/MonacoStartupWarmup.ts | 63 +++++ .../src/tools/git/services/GitService.test.ts | 68 +++++ .../src/tools/git/services/GitService.ts | 21 ++ .../services/WorkspaceGitInitializer.test.ts | 38 +++ .../git/services/WorkspaceGitInitializer.ts | 4 +- .../tools/git/state/GitStateManager.test.ts | 199 +++++++++++++++ .../src/tools/git/state/GitStateManager.ts | 32 ++- 13 files changed, 1009 insertions(+), 99 deletions(-) create mode 100644 src/web-ui/src/shared/utils/backgroundTaskScheduler.test.ts create mode 100644 src/web-ui/src/shared/utils/backgroundTaskScheduler.ts create mode 100644 src/web-ui/src/tools/editor/services/MonacoStartupWarmup.test.ts create mode 100644 src/web-ui/src/tools/editor/services/MonacoStartupWarmup.ts create mode 100644 src/web-ui/src/tools/git/services/GitService.test.ts create mode 100644 src/web-ui/src/tools/git/services/WorkspaceGitInitializer.test.ts create mode 100644 src/web-ui/src/tools/git/state/GitStateManager.test.ts diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 4f86272abd..95b91cd83e 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -251,6 +251,71 @@ describe('FlowChatStore historical session hydration state', () => { }); }); + it('loads model config once while processing multiple persisted sessions', async () => { + configManagerMock.getConfig.mockImplementation(async (path: string) => { + if (path === 'ai.models') return [{ id: 'primary-model', context_window: 256000 }]; + if (path === 'ai.default_models') return { primary: 'primary-model' }; + return undefined; + }); + apiMocks.listSessions.mockResolvedValueOnce([ + { + sessionId: 'history-1', + title: 'Saved session 1', + agentType: 'agentic', + createdAt: 10, + lastActiveAt: 20, + }, + { + sessionId: 'history-2', + title: 'Saved session 2', + agentType: 'agentic', + createdAt: 11, + lastActiveAt: 21, + }, + ]); + + await flowChatStore.initializeFromDisk('D:/workspace/BitFun'); + + const configPaths = configManagerMock.getConfig.mock.calls.map(([path]) => path); + expect(configPaths.filter(path => path === 'ai.models')).toHaveLength(1); + expect(configPaths.filter(path => path === 'ai.default_models')).toHaveLength(1); + expect(flowChatStore.getState().sessions.get('history-1')?.maxContextTokens).toBe(256000); + expect(flowChatStore.getState().sessions.get('history-2')?.maxContextTokens).toBe(256000); + }); + + it('skips one bad metadata entry without dropping the rest of the session list', async () => { + apiMocks.listSessions.mockResolvedValueOnce([ + { + sessionId: 'bad-1', + title: 'Bad session', + agentType: 'agentic', + createdAt: 10, + lastActiveAt: 20, + }, + { + sessionId: 'good-1', + title: 'Good session', + agentType: 'agentic', + createdAt: 11, + lastActiveAt: 21, + }, + ]); + stateMachineManagerMock.getOrCreate.mockImplementation((sessionId: string) => { + if (sessionId === 'bad-1') { + throw new Error('bad metadata'); + } + return {}; + }); + + await flowChatStore.initializeFromDisk('D:/workspace/BitFun'); + + expect(flowChatStore.getState().sessions.has('bad-1')).toBe(false); + expect(flowChatStore.getState().sessions.get('good-1')).toMatchObject({ + sessionId: 'good-1', + historyState: 'metadata-only', + }); + }); + it('marks historical sessions hydrating while turns are loading and ready after completion', async () => { const turns = createDeferred(); apiMocks.restoreSession.mockResolvedValueOnce(undefined); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index a0461d6f15..e10a15728e 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -1747,35 +1747,53 @@ export class FlowChatStore { startupTrace.markPhase('session_metadata_list_loaded', { remote, sessionCount }); const { stateMachineManager } = await import('../state-machine'); - sessions.forEach(metadata => { - stateMachineManager.getOrCreate(metadata.sessionId); - }); - - const processSession = async (metadata: any) => { - const existingSession = this.state.sessions.get(metadata.sessionId); - if (existingSession) { - return; + + let models: any[] = []; + let defaultModels: Record = {}; + try { + const { configManager } = await import('@/infrastructure/config/services/ConfigManager'); + const [modelsResult, defaultModelsResult] = await Promise.allSettled([ + configManager.getConfig('ai.models'), + configManager.getConfig>('ai.default_models'), + ]); + + if (modelsResult.status === 'fulfilled' && Array.isArray(modelsResult.value)) { + models = modelsResult.value; } - if (isLegacyPersistedBtwSession(metadata)) { - return; + if ( + defaultModelsResult.status === 'fulfilled' && + defaultModelsResult.value && + typeof defaultModelsResult.value === 'object' + ) { + defaultModels = defaultModelsResult.value; } - - let maxContextTokens = 128128; + } catch (error) { + log.warn('Failed to load model config for session metadata, using defaults', { error }); + } + + const processSession = async (metadata: any) => { try { - const { configManager } = await import('@/infrastructure/config/services/ConfigManager'); - const models = await configManager.getConfig('ai.models') || []; - + const existingSession = this.state.sessions.get(metadata.sessionId); + if (existingSession) { + return; + } + if (isLegacyPersistedBtwSession(metadata)) { + return; + } + + stateMachineManager.getOrCreate(metadata.sessionId); + + let maxContextTokens = 128128; if (metadata.modelName) { const model = models.find((m: any) => m.name === metadata.modelName || m.id === metadata.modelName); if (model?.context_window) { maxContextTokens = model.context_window; } } - + if (maxContextTokens === 128128) { - const defaultModels = await configManager.getConfig>('ai.default_models'); const primaryModelId = defaultModels?.primary; - + if (primaryModelId) { const primaryModel = models.find((m: any) => m.id === primaryModelId); if (primaryModel?.context_window) { @@ -1783,71 +1801,74 @@ export class FlowChatStore { } } } + + const relationship = deriveSessionRelationshipFromMetadata(metadata); + const lastFinishedAt = deriveLastFinishedAtFromMetadata(metadata); + const titleState = deriveSessionTitleStateFromMetadata(metadata); + const hasDynamicDefaultTitle = titleState.titleSource === 'i18n'; + + this.setState(prev => { + if (prev.sessions.has(metadata.sessionId)) { + return prev; + } + + const rawAgentType = metadata.agentType || 'agentic'; + const validatedAgentType = isValidPersistedAgentType(rawAgentType) ? rawAgentType : 'agentic'; + + if (rawAgentType !== validatedAgentType) { + log.warn('Invalid agentType, falling back to agentic', { sessionId: metadata.sessionId, rawAgentType, validatedAgentType }); + } + + const session: Session = { + sessionId: metadata.sessionId, + title: titleState.title, + titleSource: titleState.titleSource, + titleI18nKey: titleState.titleI18nKey, + titleI18nParams: titleState.titleI18nParams, + titleStatus: hasDynamicDefaultTitle ? undefined : 'generated', + dialogTurns: [], + status: 'idle', + config: { + agentType: validatedAgentType, + modelName: metadata.modelName, + }, + createdAt: metadata.createdAt, + lastActiveAt: metadata.lastActiveAt, + lastFinishedAt, + error: null, + isHistorical: true, + historyState: 'metadata-only', + todos: (metadata as any).todos || [], + maxContextTokens, + mode: validatedAgentType, + workspacePath: (metadata as any).workspacePath || workspacePath, + remoteConnectionId: metadata.remoteConnectionId || remoteConnectionId, + remoteSshHost: + metadata.remoteSshHost || metadata.workspaceHostname || remoteSshHost, + parentSessionId: relationship.parentSessionId, + sessionKind: relationship.sessionKind, + btwThreads: [], + btwOrigin: relationship.btwOrigin, + hasUnreadCompletion: metadata.unreadCompletion, + needsUserAttention: metadata.needsUserAttention, + deepReviewRunManifest: metadata.deepReviewRunManifest, + isTransient: false, + }; + + const newSessions = new Map(prev.sessions); + newSessions.set(metadata.sessionId, session); + + return { + ...prev, + sessions: newSessions, + }; + }); } catch (error) { - log.warn('Failed to get model context window size, using default', { sessionId: metadata.sessionId, error }); + log.warn('Failed to process persisted session metadata', { + sessionId: metadata?.sessionId, + error, + }); } - - const relationship = deriveSessionRelationshipFromMetadata(metadata); - const lastFinishedAt = deriveLastFinishedAtFromMetadata(metadata); - const titleState = deriveSessionTitleStateFromMetadata(metadata); - const hasDynamicDefaultTitle = titleState.titleSource === 'i18n'; - - this.setState(prev => { - if (prev.sessions.has(metadata.sessionId)) { - return prev; - } - - const rawAgentType = metadata.agentType || 'agentic'; - const validatedAgentType = isValidPersistedAgentType(rawAgentType) ? rawAgentType : 'agentic'; - - if (rawAgentType !== validatedAgentType) { - log.warn('Invalid agentType, falling back to agentic', { sessionId: metadata.sessionId, rawAgentType, validatedAgentType }); - } - - const session: Session = { - sessionId: metadata.sessionId, - title: titleState.title, - titleSource: titleState.titleSource, - titleI18nKey: titleState.titleI18nKey, - titleI18nParams: titleState.titleI18nParams, - titleStatus: hasDynamicDefaultTitle ? undefined : 'generated', - dialogTurns: [], - status: 'idle', - config: { - agentType: validatedAgentType, - modelName: metadata.modelName, - }, - createdAt: metadata.createdAt, - lastActiveAt: metadata.lastActiveAt, - lastFinishedAt, - error: null, - isHistorical: true, - historyState: 'metadata-only', - todos: (metadata as any).todos || [], - maxContextTokens, - mode: validatedAgentType, - workspacePath: (metadata as any).workspacePath || workspacePath, - remoteConnectionId: metadata.remoteConnectionId || remoteConnectionId, - remoteSshHost: - metadata.remoteSshHost || metadata.workspaceHostname || remoteSshHost, - parentSessionId: relationship.parentSessionId, - sessionKind: relationship.sessionKind, - btwThreads: [], - btwOrigin: relationship.btwOrigin, - hasUnreadCompletion: metadata.unreadCompletion, - needsUserAttention: metadata.needsUserAttention, - deepReviewRunManifest: metadata.deepReviewRunManifest, - isTransient: false, - }; - - const newSessions = new Map(prev.sessions); - newSessions.set(metadata.sessionId, session); - - return { - ...prev, - sessions: newSessions, - }; - }); }; await Promise.all(sessions.map(processSession)); diff --git a/src/web-ui/src/main.tsx b/src/web-ui/src/main.tsx index 5a83acd919..6c5e96c356 100644 --- a/src/web-ui/src/main.tsx +++ b/src/web-ui/src/main.tsx @@ -265,11 +265,19 @@ async function initializeAfterRender(): Promise { await fontPreferenceService.initialize(); log.info('Font preference initialized at startup'); - const { configManager } = await import('./infrastructure/config/services/ConfigManager'); - await configManager.getConfig('editor'); - log.info('Editor configuration preloaded'); - const initResults = await Promise.allSettled([ + (async () => { + const { backgroundTaskScheduler } = await import('./shared/utils/backgroundTaskScheduler'); + backgroundTaskScheduler.schedule(async () => { + const { configManager } = await import('./infrastructure/config/services/ConfigManager'); + await configManager.getConfig('editor'); + log.info('Editor configuration preloaded'); + }, { + idle: true, + inFlightKey: 'startup:editor-config-preload', + priority: 'low', + }); + })(), (async () => { const { installFrontendLogLevelConfigWatcher } = await import('./infrastructure/config/services/FrontendLogLevelSync'); await installFrontendLogLevelConfigWatcher(); @@ -294,23 +302,20 @@ async function initializeAfterRender(): Promise { registerNotificationContextMenu(); })(), (async () => { - const { MonacoManager } = await import('./tools/editor'); - await MonacoManager.initialize(); - - const { monacoThemeSync } = await import('./infrastructure/theme/integrations/MonacoThemeSync'); - await monacoThemeSync.initialize(); - log.info('Monaco theme sync initialized'); + const { scheduleMonacoStartupWarmup } = await import('./tools/editor/services/MonacoStartupWarmup'); + scheduleMonacoStartupWarmup(); })(), ]); initResults.forEach((result, index) => { const names = [ + 'EditorConfigPreload', 'LogLevelConfigWatcher', 'DefaultContextTypes', 'RecommendationProviders', 'Tools', 'ContextMenu', - 'Editors', + 'EditorWarmup', ]; if (result.status === 'rejected') { log.warn('Initialization failed', { module: names[index], error: result.reason }); diff --git a/src/web-ui/src/shared/utils/backgroundTaskScheduler.test.ts b/src/web-ui/src/shared/utils/backgroundTaskScheduler.test.ts new file mode 100644 index 0000000000..6e477e1161 --- /dev/null +++ b/src/web-ui/src/shared/utils/backgroundTaskScheduler.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { + BackgroundTaskCancelledError, + BackgroundTaskScheduler, +} from './backgroundTaskScheduler'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('BackgroundTaskScheduler', () => { + afterEach(() => { + vi.useRealTimers(); + delete (globalThis as any).requestIdleCallback; + delete (globalThis as any).cancelIdleCallback; + }); + + it('respects concurrency limit and runs higher priority queued tasks first', async () => { + const scheduler = new BackgroundTaskScheduler({ concurrency: 1 }); + const first = deferred(); + const order: string[] = []; + + const firstTask = scheduler.schedule(async () => { + order.push('first:start'); + await first.promise; + order.push('first:end'); + return 'first'; + }, { priority: 'low' }); + + const lowTask = scheduler.schedule(async () => { + order.push('low'); + return 'low'; + }, { priority: 'low' }); + + const highTask = scheduler.schedule(async () => { + order.push('high'); + return 'high'; + }, { priority: 'high' }); + + await Promise.resolve(); + expect(order).toEqual(['first:start']); + + first.resolve('first'); + await expect(firstTask.promise).resolves.toBe('first'); + await expect(highTask.promise).resolves.toBe('high'); + await expect(lowTask.promise).resolves.toBe('low'); + expect(order).toEqual(['first:start', 'first:end', 'high', 'low']); + }); + + it('deduplicates queued or running tasks with the same in-flight key', async () => { + const scheduler = new BackgroundTaskScheduler({ concurrency: 1 }); + const task = vi.fn(async () => 'shared'); + + const first = scheduler.schedule(task, { inFlightKey: 'startup:monaco' }); + const second = scheduler.schedule(task, { inFlightKey: 'startup:monaco' }); + + await expect(first.promise).resolves.toBe('shared'); + await expect(second.promise).resolves.toBe('shared'); + expect(first.promise).toBe(second.promise); + expect(task).toHaveBeenCalledTimes(1); + }); + + it('cancels queued work without running it', async () => { + const scheduler = new BackgroundTaskScheduler({ concurrency: 1 }); + const blocker = deferred(); + const queuedTask = vi.fn(async () => 'queued'); + + scheduler.schedule(async () => { + await blocker.promise; + }); + const queued = scheduler.schedule(queuedTask); + + queued.cancel(); + blocker.resolve(); + + await expect(queued.promise).rejects.toBeInstanceOf(BackgroundTaskCancelledError); + expect(queuedTask).not.toHaveBeenCalled(); + }); + + it('uses requestIdleCallback for idle tasks', async () => { + const scheduler = new BackgroundTaskScheduler({ concurrency: 1 }); + const idleCallbacks: Array<() => void> = []; + const task = vi.fn(async () => 'idle'); + (globalThis as any).requestIdleCallback = vi.fn((callback: () => void) => { + idleCallbacks.push(callback); + return idleCallbacks.length; + }); + (globalThis as any).cancelIdleCallback = vi.fn(); + + const scheduled = scheduler.schedule(task, { idle: true }); + await Promise.resolve(); + + expect(task).not.toHaveBeenCalled(); + expect((globalThis as any).requestIdleCallback).toHaveBeenCalledTimes(1); + + idleCallbacks[0](); + await expect(scheduled.promise).resolves.toBe('idle'); + expect(task).toHaveBeenCalledTimes(1); + }); + + it('releases the concurrency slot when an idle task is cancelled', async () => { + const scheduler = new BackgroundTaskScheduler({ concurrency: 1 }); + const task = vi.fn(async () => 'next'); + (globalThis as any).requestIdleCallback = vi.fn(() => 1); + (globalThis as any).cancelIdleCallback = vi.fn(); + + const idle = scheduler.schedule(async () => 'idle', { idle: true }); + idle.cancel(); + + const next = scheduler.schedule(task); + + await expect(idle.promise).rejects.toBeInstanceOf(BackgroundTaskCancelledError); + await expect(next.promise).resolves.toBe('next'); + expect(task).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/web-ui/src/shared/utils/backgroundTaskScheduler.ts b/src/web-ui/src/shared/utils/backgroundTaskScheduler.ts new file mode 100644 index 0000000000..cefa16cb6c --- /dev/null +++ b/src/web-ui/src/shared/utils/backgroundTaskScheduler.ts @@ -0,0 +1,235 @@ +import { createLogger } from './logger'; + +const log = createLogger('BackgroundTaskScheduler'); + +export type BackgroundTaskPriority = 'high' | 'normal' | 'low'; + +export interface BackgroundTaskOptions { + priority?: BackgroundTaskPriority; + idle?: boolean; + inFlightKey?: string; +} + +export interface BackgroundTaskHandle { + promise: Promise; + cancel: () => void; +} + +export interface BackgroundTaskSchedulerOptions { + concurrency?: number; +} + +type TaskStatus = 'queued' | 'idle' | 'running' | 'settled' | 'cancelled'; + +interface TaskEntry { + id: number; + priority: BackgroundTaskPriority; + idle: boolean; + inFlightKey?: string; + status: TaskStatus; + task: (signal: AbortSignal) => Promise | T; + controller: AbortController; + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; + idleHandle?: number; +} + +export class BackgroundTaskCancelledError extends Error { + constructor() { + super('Background task cancelled'); + this.name = 'BackgroundTaskCancelledError'; + } +} + +const PRIORITY_RANK: Record = { + high: 0, + normal: 1, + low: 2, +}; + +function requestIdle(callback: () => void): number { + const requestIdleCallback = (globalThis as { + requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number; + }).requestIdleCallback; + + if (typeof requestIdleCallback === 'function') { + return requestIdleCallback(callback, { timeout: 2000 }); + } + + return globalThis.setTimeout(callback, 0) as unknown as number; +} + +function cancelIdle(handle: number): void { + const cancelIdleCallback = (globalThis as { + cancelIdleCallback?: (handle: number) => void; + }).cancelIdleCallback; + + if (typeof cancelIdleCallback === 'function') { + cancelIdleCallback(handle); + return; + } + + globalThis.clearTimeout(handle); +} + +export class BackgroundTaskScheduler { + private readonly concurrency: number; + private nextId = 1; + private runningCount = 0; + private queue: Array> = []; + private keyedTasks = new Map>(); + + constructor(options: BackgroundTaskSchedulerOptions = {}) { + this.concurrency = Math.max(1, options.concurrency ?? 2); + } + + schedule( + task: (signal: AbortSignal) => Promise | T, + options: BackgroundTaskOptions = {} + ): BackgroundTaskHandle { + const inFlightKey = options.inFlightKey; + if (inFlightKey) { + const existing = this.keyedTasks.get(inFlightKey) as TaskEntry | undefined; + if (existing && existing.status !== 'cancelled' && existing.status !== 'settled') { + return this.toHandle(existing); + } + } + + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + const entry: TaskEntry = { + id: this.nextId++, + priority: options.priority ?? 'normal', + idle: options.idle ?? false, + inFlightKey, + status: 'queued', + task, + controller: new AbortController(), + promise, + resolve, + reject, + }; + + this.queue.push(entry as TaskEntry); + if (inFlightKey) { + this.keyedTasks.set(inFlightKey, entry as TaskEntry); + } + this.drain(); + + return this.toHandle(entry); + } + + private toHandle(entry: TaskEntry): BackgroundTaskHandle { + return { + promise: entry.promise, + cancel: () => this.cancel(entry), + }; + } + + private cancel(entry: TaskEntry): void { + if (entry.status === 'settled' || entry.status === 'cancelled') { + return; + } + + if (entry.status === 'running') { + entry.controller.abort(); + return; + } + + const shouldReleaseSlot = entry.status === 'idle'; + entry.status = 'cancelled'; + entry.controller.abort(); + if (entry.idleHandle !== undefined) { + cancelIdle(entry.idleHandle); + entry.idleHandle = undefined; + } + this.queue = this.queue.filter((item) => item !== entry); + this.deleteKeyIfCurrent(entry); + entry.reject(new BackgroundTaskCancelledError()); + if (shouldReleaseSlot) { + this.runningCount -= 1; + } + this.drain(); + } + + private drain(): void { + while (this.runningCount < this.concurrency) { + const next = this.shiftNextRunnable(); + if (!next) { + return; + } + + this.runningCount += 1; + if (next.idle) { + next.status = 'idle'; + next.idleHandle = requestIdle(() => { + next.idleHandle = undefined; + void this.run(next); + }); + } else { + void this.run(next); + } + } + } + + private shiftNextRunnable(): TaskEntry | null { + this.queue = this.queue.filter((entry) => entry.status === 'queued'); + if (this.queue.length === 0) { + return null; + } + + this.queue.sort((a, b) => { + const priorityDiff = PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority]; + return priorityDiff !== 0 ? priorityDiff : a.id - b.id; + }); + + return this.queue.shift() ?? null; + } + + private async run(entry: TaskEntry): Promise { + if (entry.status === 'cancelled') { + this.runningCount -= 1; + this.drain(); + return; + } + + entry.status = 'running'; + try { + if (entry.controller.signal.aborted) { + throw new BackgroundTaskCancelledError(); + } + entry.resolve(await entry.task(entry.controller.signal)); + } catch (error) { + if (entry.controller.signal.aborted && !(error instanceof BackgroundTaskCancelledError)) { + entry.reject(new BackgroundTaskCancelledError()); + } else { + entry.reject(error); + } + if (!(error instanceof BackgroundTaskCancelledError)) { + log.warn('Background task failed', { inFlightKey: entry.inFlightKey, error }); + } + } finally { + entry.status = 'settled'; + this.deleteKeyIfCurrent(entry); + this.runningCount -= 1; + this.drain(); + } + } + + private deleteKeyIfCurrent(entry: TaskEntry): void { + if (!entry.inFlightKey) { + return; + } + if (this.keyedTasks.get(entry.inFlightKey) === entry) { + this.keyedTasks.delete(entry.inFlightKey); + } + } +} + +export const backgroundTaskScheduler = new BackgroundTaskScheduler(); diff --git a/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.test.ts b/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.test.ts new file mode 100644 index 0000000000..676ca9e8b3 --- /dev/null +++ b/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; +import { scheduleMonacoStartupWarmup } from './MonacoStartupWarmup'; + +describe('scheduleMonacoStartupWarmup', () => { + it('schedules Monaco initialization as low-priority idle background work', async () => { + const initializeMonaco = vi.fn(async () => undefined); + const initializeThemeSync = vi.fn(async () => undefined); + const signal = { aborted: false } as AbortSignal; + const schedule = vi.fn((task: (signal: AbortSignal) => Promise, options: unknown) => ({ + promise: task(signal), + cancel: vi.fn(), + options, + })); + + const handle = scheduleMonacoStartupWarmup({ + scheduler: { schedule }, + initializeMonaco, + initializeThemeSync, + }); + + expect(schedule).toHaveBeenCalledWith(expect.any(Function), { + idle: true, + inFlightKey: 'startup:monaco-warmup', + priority: 'low', + }); + await expect(handle.promise).resolves.toBeUndefined(); + expect(initializeMonaco).toHaveBeenCalledTimes(1); + expect(initializeThemeSync).toHaveBeenCalledTimes(1); + }); + + it('skips theme sync when the warmup task is cancelled before Monaco resolves', async () => { + const initializeMonaco = vi.fn(async () => undefined); + const initializeThemeSync = vi.fn(async () => undefined); + const signal = { aborted: true } as AbortSignal; + const schedule = vi.fn((task: (signal: AbortSignal) => Promise, options: unknown) => ({ + promise: task(signal), + cancel: vi.fn(), + options, + })); + + const handle = scheduleMonacoStartupWarmup({ + scheduler: { schedule }, + initializeMonaco, + initializeThemeSync, + }); + + await expect(handle.promise).resolves.toBeUndefined(); + expect(initializeMonaco).toHaveBeenCalledTimes(1); + expect(initializeThemeSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.ts b/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.ts new file mode 100644 index 0000000000..aa6cf43b39 --- /dev/null +++ b/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.ts @@ -0,0 +1,63 @@ +import { + backgroundTaskScheduler, + type BackgroundTaskHandle, +} from '@/shared/utils/backgroundTaskScheduler'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('MonacoStartupWarmup'); + +interface SchedulerLike { + schedule( + task: (signal: AbortSignal) => Promise | T, + options: { + idle: boolean; + inFlightKey: string; + priority: 'low'; + } + ): BackgroundTaskHandle; +} + +interface MonacoStartupWarmupOptions { + scheduler?: SchedulerLike; + initializeMonaco?: () => Promise; + initializeThemeSync?: () => Promise; +} + +async function defaultInitializeMonaco(): Promise { + const { MonacoManager } = await import('./MonacoInitManager'); + await MonacoManager.initialize(); +} + +async function defaultInitializeThemeSync(): Promise { + const { monacoThemeSync } = await import('@/infrastructure/theme/integrations/MonacoThemeSync'); + await monacoThemeSync.initialize(); +} + +export function scheduleMonacoStartupWarmup( + options: MonacoStartupWarmupOptions = {} +): BackgroundTaskHandle { + const scheduler = options.scheduler ?? backgroundTaskScheduler; + const initializeMonaco = options.initializeMonaco ?? defaultInitializeMonaco; + const initializeThemeSync = options.initializeThemeSync ?? defaultInitializeThemeSync; + + return scheduler.schedule(async (signal) => { + try { + await initializeMonaco(); + if (signal.aborted) { + return; + } + await initializeThemeSync(); + log.info('Monaco startup warmup completed'); + } catch (error) { + if (signal.aborted) { + return; + } + log.warn('Monaco startup warmup failed', error); + throw error; + } + }, { + idle: true, + inFlightKey: 'startup:monaco-warmup', + priority: 'low', + }); +} diff --git a/src/web-ui/src/tools/git/services/GitService.test.ts b/src/web-ui/src/tools/git/services/GitService.test.ts new file mode 100644 index 0000000000..274f6aa52a --- /dev/null +++ b/src/web-ui/src/tools/git/services/GitService.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { gitService } from './GitService'; + +const gitApiMocks = vi.hoisted(() => ({ + commit: vi.fn(), + push: vi.fn(), + resetFiles: vi.fn(), +})); + +const gitStateManagerMock = vi.hoisted(() => ({ + refresh: vi.fn(), +})); + +vi.mock('@/infrastructure/api', () => ({ + gitAPI: gitApiMocks, +})); + +vi.mock('@/infrastructure/i18n', () => ({ + i18nService: { + t: (key: string) => key, + }, +})); + +vi.mock('../state/GitStateManager', () => ({ + gitStateManager: gitStateManagerMock, +})); + +const repositoryPath = 'D:/workspace/BitFun'; + +describe('GitService dangerous operation refresh guard', () => { + beforeEach(() => { + vi.clearAllMocks(); + gitStateManagerMock.refresh.mockResolvedValue(undefined); + gitApiMocks.commit.mockResolvedValue({ success: true }); + gitApiMocks.push.mockResolvedValue({ success: true }); + gitApiMocks.resetFiles.mockResolvedValue({ success: true }); + }); + + it('forces a fresh basic/status refresh before committing', async () => { + const order: string[] = []; + gitStateManagerMock.refresh.mockImplementation(async () => { + order.push('refresh'); + }); + gitApiMocks.commit.mockImplementation(async () => { + order.push('commit'); + return { success: true }; + }); + + await gitService.commit(repositoryPath, { message: 'test' }); + + expect(order).toEqual(['refresh', 'commit']); + expect(gitStateManagerMock.refresh).toHaveBeenCalledWith(repositoryPath, { + force: true, + layers: ['basic', 'status'], + reason: 'operation', + silent: true, + }); + }); + + it('forces a fresh basic/status refresh before push and reset operations', async () => { + await gitService.push(repositoryPath); + await gitService.resetFiles(repositoryPath, ['src/app.ts'], false); + + expect(gitStateManagerMock.refresh).toHaveBeenCalledTimes(2); + expect(gitApiMocks.push).toHaveBeenCalledTimes(1); + expect(gitApiMocks.resetFiles).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/web-ui/src/tools/git/services/GitService.ts b/src/web-ui/src/tools/git/services/GitService.ts index c95d480bdb..abda3528df 100644 --- a/src/web-ui/src/tools/git/services/GitService.ts +++ b/src/web-ui/src/tools/git/services/GitService.ts @@ -6,6 +6,7 @@ import { gitAPI } from '@/infrastructure/api'; import { createLogger } from '@/shared/utils/logger'; import { measureAsync } from '@/shared/utils/timing'; import { i18nService } from '@/infrastructure/i18n'; +import { gitStateManager } from '../state/GitStateManager'; const log = createLogger('GitService'); export type { @@ -71,6 +72,15 @@ export class GitService { }); } + private async ensureFreshOperationState(repositoryPath: string): Promise { + await gitStateManager.refresh(repositoryPath, { + force: true, + layers: ['basic', 'status'], + reason: 'operation', + silent: true, + }); + } + private isInNonGitCache(path: string): boolean { this.clearExpiredCache(); return this.nonGitRepositoryCache.has(path); @@ -275,6 +285,7 @@ export class GitService { */ async commit(repositoryPath: string, params: GitCommitParams): Promise { try { + await this.ensureFreshOperationState(repositoryPath); const result = await gitAPI.commit(repositoryPath, params); return result; } catch (error) { @@ -291,6 +302,7 @@ export class GitService { */ async push(repositoryPath: string, params: GitPushParams = {}): Promise { try { + await this.ensureFreshOperationState(repositoryPath); const result = await gitAPI.push(repositoryPath, params); return result; } catch (error) { @@ -307,6 +319,7 @@ export class GitService { */ async pull(repositoryPath: string, params: GitPullParams = {}): Promise { try { + await this.ensureFreshOperationState(repositoryPath); const result = await gitAPI.pull(repositoryPath, params); return result; } catch (error) { @@ -323,6 +336,7 @@ export class GitService { */ async checkoutBranch(repositoryPath: string, branchName: string): Promise { try { + await this.ensureFreshOperationState(repositoryPath); const result = await gitAPI.checkoutBranch(repositoryPath, branchName); return result; } catch (error) { @@ -339,6 +353,7 @@ export class GitService { */ async createBranch(repositoryPath: string, branchName: string, startPoint?: string): Promise { try { + await this.ensureFreshOperationState(repositoryPath); const result = await gitAPI.createBranch(repositoryPath, branchName, startPoint); return result; } catch (error) { @@ -355,6 +370,7 @@ export class GitService { */ async deleteBranch(repositoryPath: string, branchName: string, force: boolean = false): Promise { try { + await this.ensureFreshOperationState(repositoryPath); const result = await gitAPI.deleteBranch(repositoryPath, branchName, force); return result; } catch (error) { @@ -384,6 +400,7 @@ export class GitService { */ async resetFiles(repositoryPath: string, files: string[], staged: boolean = false): Promise { try { + await this.ensureFreshOperationState(repositoryPath); const result = await gitAPI.resetFiles(repositoryPath, files, staged); return result; } catch (error) { @@ -400,6 +417,7 @@ export class GitService { */ async resetToCommit(repositoryPath: string, commitHash: string, mode: 'soft' | 'mixed' | 'hard' = 'mixed'): Promise { try { + await this.ensureFreshOperationState(repositoryPath); const result = await gitAPI.resetToCommit(repositoryPath, commitHash, mode); return result; } catch (error) { @@ -443,6 +461,7 @@ export class GitService { */ async cherryPick(repositoryPath: string, commitHash: string, noCommit: boolean = false): Promise { try { + await this.ensureFreshOperationState(repositoryPath); const result = await gitAPI.cherryPick(repositoryPath, commitHash, noCommit); return result; } catch (error) { @@ -459,6 +478,7 @@ export class GitService { */ async cherryPickAbort(repositoryPath: string): Promise { try { + await this.ensureFreshOperationState(repositoryPath); const result = await gitAPI.cherryPickAbort(repositoryPath); return result; } catch (error) { @@ -475,6 +495,7 @@ export class GitService { */ async cherryPickContinue(repositoryPath: string): Promise { try { + await this.ensureFreshOperationState(repositoryPath); const result = await gitAPI.cherryPickContinue(repositoryPath); return result; } catch (error) { diff --git a/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.test.ts b/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.test.ts new file mode 100644 index 0000000000..907fd21164 --- /dev/null +++ b/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; + +const workspaceManagerMock = vi.hoisted(() => ({ + addEventListener: vi.fn(() => vi.fn()), + getState: vi.fn(() => ({ + currentWorkspace: { + rootPath: 'D:/workspace/BitFun', + }, + })), +})); + +const gitStateManagerMock = vi.hoisted(() => ({ + refresh: vi.fn(async () => undefined), + invalidateCache: vi.fn(), +})); + +vi.mock('@/infrastructure/services/business/workspaceManager', () => ({ + workspaceManager: workspaceManagerMock, +})); + +vi.mock('../state/GitStateManager', () => ({ + gitStateManager: gitStateManagerMock, +})); + +import { workspaceGitInitializer } from './WorkspaceGitInitializer'; + +describe('WorkspaceGitInitializer startup refresh', () => { + it('refreshes only the basic Git layer for the current workspace on startup', async () => { + workspaceGitInitializer.start(); + await Promise.resolve(); + + expect(gitStateManagerMock.refresh).toHaveBeenCalledWith('D:/workspace/BitFun', { + layers: ['basic'], + reason: 'mount', + force: true, + }); + }); +}); diff --git a/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.ts b/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.ts index d9ab3e43b2..a57a87ff27 100644 --- a/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.ts +++ b/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.ts @@ -65,7 +65,7 @@ class WorkspaceGitInitializer { try { this.currentWorkspacePath = workspacePath; await gitStateManager.refresh(workspacePath, { - layers: ['basic', 'status'], + layers: ['basic'], reason: 'mount', force: true, }); @@ -92,7 +92,7 @@ class WorkspaceGitInitializer { } this.currentWorkspacePath = workspacePath; await gitStateManager.refresh(workspacePath, { - layers: ['basic', 'status'], + layers: ['basic'], reason: 'mount', force: true, }); diff --git a/src/web-ui/src/tools/git/state/GitStateManager.test.ts b/src/web-ui/src/tools/git/state/GitStateManager.test.ts new file mode 100644 index 0000000000..24c1790ea7 --- /dev/null +++ b/src/web-ui/src/tools/git/state/GitStateManager.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { GitStateManager } from './GitStateManager'; + +const gitApiMocks = vi.hoisted(() => ({ + isGitRepository: vi.fn(), + getRepository: vi.fn(), + getStatus: vi.fn(), + getBranches: vi.fn(), + getCommits: vi.fn(), +})); + +const gitEventServiceMock = vi.hoisted(() => ({ + on: vi.fn(), + emit: vi.fn(), +})); + +vi.mock('@/infrastructure/api', () => ({ + gitAPI: gitApiMocks, +})); + +vi.mock('../services/GitEventService', () => ({ + gitEventService: gitEventServiceMock, +})); + +vi.mock('@/infrastructure/event-bus', () => ({ + globalEventBus: { + emit: vi.fn(), + }, +})); + +vi.mock('@/shared/utils/debugProbe', () => ({ + sendDebugProbe: vi.fn(), +})); + +vi.mock('@/infrastructure/i18n', () => ({ + i18nService: { + t: (key: string) => key, + }, +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const repositoryPath = 'D:/workspace/BitFun'; + +describe('GitStateManager refresh performance guards', () => { + let manager: GitStateManager; + + beforeEach(() => { + vi.useFakeTimers(); + GitStateManager.resetInstance(); + manager = GitStateManager.getInstance(); + manager.setCacheConfig({ basic: 0, status: 0, detailed: 0 }); + + gitApiMocks.isGitRepository.mockResolvedValue(true); + gitApiMocks.getRepository.mockResolvedValue({ + path: repositoryPath, + name: 'BitFun', + current_branch: 'main', + is_bare: false, + has_changes: true, + remotes: ['origin'], + }); + gitApiMocks.getStatus.mockResolvedValue({ + staged: [], + unstaged: [], + untracked: [], + conflicts: [], + current_branch: 'main', + ahead: 0, + behind: 0, + }); + }); + + afterEach(() => { + manager.dispose(); + GitStateManager.resetInstance(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('refreshes the basic layer without fetching full status', async () => { + const refresh = manager.refresh(repositoryPath, { + layers: ['basic'], + force: true, + reason: 'mount', + }); + + await vi.advanceTimersByTimeAsync(100); + await refresh; + + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1); + expect(gitApiMocks.getRepository).toHaveBeenCalledTimes(1); + expect(gitApiMocks.getStatus).not.toHaveBeenCalled(); + expect(manager.getState(repositoryPath)).toMatchObject({ + isRepository: true, + currentBranch: 'main', + hasChanges: true, + }); + }); + + it('merges duplicate mount refreshes for the same repository and layer', async () => { + const first = manager.refresh(repositoryPath, { + layers: ['basic', 'status'], + reason: 'mount', + }); + const second = manager.refresh(repositoryPath, { + layers: ['basic', 'status'], + reason: 'mount', + }); + + await vi.advanceTimersByTimeAsync(100); + await Promise.all([first, second]); + + expect(gitApiMocks.getStatus).toHaveBeenCalledTimes(1); + }); + + it('does not run force refresh concurrently with an in-flight refresh', async () => { + const firstStatus = deferred>>(); + gitApiMocks.getStatus + .mockReturnValueOnce(firstStatus.promise) + .mockResolvedValueOnce({ + staged: [], + unstaged: [{ path: 'changed.ts', status: 'modified' }], + untracked: [], + conflicts: [], + current_branch: 'main', + ahead: 0, + behind: 0, + }); + + const first = manager.refresh(repositoryPath, { + layers: ['basic', 'status'], + force: true, + reason: 'mount', + }); + await vi.advanceTimersByTimeAsync(100); + await Promise.resolve(); + expect(gitApiMocks.getStatus).toHaveBeenCalledTimes(1); + + const forced = manager.refresh(repositoryPath, { + layers: ['basic', 'status'], + force: true, + reason: 'operation', + }); + await vi.advanceTimersByTimeAsync(100); + await Promise.resolve(); + + expect(gitApiMocks.getStatus).toHaveBeenCalledTimes(1); + + firstStatus.resolve({ + staged: [], + unstaged: [], + untracked: [], + conflicts: [], + current_branch: 'main', + ahead: 0, + behind: 0, + }); + + await first; + await forced; + expect(gitApiMocks.getStatus).toHaveBeenCalledTimes(2); + }); + + it('propagates an in-flight refresh failure to non-force joiners', async () => { + const firstStatus = deferred>>(); + const failure = new Error('status failed'); + gitApiMocks.getStatus.mockReturnValueOnce(firstStatus.promise); + + const first = manager.refresh(repositoryPath, { + layers: ['basic', 'status'], + force: true, + reason: 'mount', + }); + await vi.advanceTimersByTimeAsync(100); + await Promise.resolve(); + + const second = manager.refresh(repositoryPath, { + layers: ['basic', 'status'], + reason: 'mount', + }); + await vi.advanceTimersByTimeAsync(100); + await Promise.resolve(); + + firstStatus.reject(failure); + + await expect(first).rejects.toThrow('status failed'); + await expect(second).rejects.toThrow('status failed'); + expect(gitApiMocks.getStatus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/web-ui/src/tools/git/state/GitStateManager.ts b/src/web-ui/src/tools/git/state/GitStateManager.ts index d90c4fde1a..4bcb326e93 100644 --- a/src/web-ui/src/tools/git/state/GitStateManager.ts +++ b/src/web-ui/src/tools/git/state/GitStateManager.ts @@ -329,9 +329,17 @@ export class GitStateManager { silent: boolean ): Promise { const existingLock = this.refreshLocks.get(repositoryPath); - if (existingLock && !force) { - await existingLock; - return; + if (existingLock) { + if (!force) { + await existingLock; + return; + } + try { + await existingLock; + } catch { + // A force refresh after a failed in-flight refresh should still get a + // chance to rebuild fresh state. + } } const startedAt = nowMs(); @@ -377,7 +385,7 @@ export class GitStateManager { if (layersToRefresh.includes('basic') || layersToRefresh.includes('status')) { - await this.refreshBasicAndStatus(repositoryPath); + await this.refreshBasicAndStatus(repositoryPath, layersToRefresh); } if (layersToRefresh.includes('detailed')) { @@ -446,7 +454,10 @@ export class GitStateManager { /** * Refresh basic + status layers. */ - private async refreshBasicAndStatus(repositoryPath: string): Promise { + private async refreshBasicAndStatus( + repositoryPath: string, + layersToRefresh: GitStateLayer[] + ): Promise { try { const isRepo = await gitAPI.isGitRepository(repositoryPath); @@ -465,6 +476,17 @@ export class GitStateManager { return; } + const shouldRefreshStatus = layersToRefresh.includes('status'); + if (!shouldRefreshStatus) { + const repository = await gitAPI.getRepository(repositoryPath); + this.updateState(repositoryPath, { + isRepository: true, + currentBranch: repository.current_branch || repository.branch || null, + hasChanges: Boolean(repository.has_changes), + }); + return; + } + const status = await gitAPI.getStatus(repositoryPath); const hasChanges = From b899901677c00344aa4d4b35486dfe411b31a91c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 17 May 2026 13:04:45 +0800 Subject: [PATCH 07/76] chore: web typography tokens and Rust formatting sweep (#758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * font size update * fix(core): align Git tool schema copy with git_schema test assertions Capitalize “Do not omit this field” in the operation property description so the unit test substring check matches reliably. --- src/apps/cli/build.rs | 21 +- src/apps/cli/src/acp_cli.rs | 27 +- src/apps/cli/src/agent/core_adapter.rs | 33 +- src/apps/cli/src/agent/mod.rs | 7 +- src/apps/cli/src/chat_state.rs | 277 +++--- src/apps/cli/src/config.rs | 14 +- src/apps/cli/src/main.rs | 148 +-- src/apps/cli/src/modes/exec.rs | 24 +- src/apps/cli/src/modes/mod.rs | 1 - src/apps/cli/src/ui/agent_selector.rs | 1 - src/apps/cli/src/ui/chat.rs | 1 - src/apps/cli/src/ui/command_menu.rs | 32 +- src/apps/cli/src/ui/diff_render.rs | 28 +- src/apps/cli/src/ui/markdown.rs | 297 +++--- src/apps/cli/src/ui/mcp_add_dialog.rs | 117 ++- src/apps/cli/src/ui/mcp_selector.rs | 12 +- src/apps/cli/src/ui/mod.rs | 58 +- src/apps/cli/src/ui/model_selector.rs | 11 +- src/apps/cli/src/ui/permission.rs | 115 ++- src/apps/cli/src/ui/question.rs | 184 ++-- src/apps/cli/src/ui/session_selector.rs | 6 +- src/apps/cli/src/ui/skill_selector.rs | 25 +- src/apps/cli/src/ui/string_utils.rs | 10 +- src/apps/cli/src/ui/syntax_highlight.rs | 8 +- src/apps/cli/src/ui/theme.rs | 134 ++- src/apps/cli/src/ui/theme_selector.rs | 1 - src/apps/cli/src/ui/tool_cards.rs | 557 ++++++++--- src/apps/cli/src/ui/widgets.rs | 12 +- src/apps/desktop/src/api/commands.rs | 43 +- src/apps/desktop/src/api/subagent_api.rs | 4 +- src/apps/desktop/src/api/system_api.rs | 18 +- src/apps/desktop/src/logging.rs | 6 +- src/crates/acp/src/client/manager.rs | 46 +- src/crates/ai-adapters/src/lib.rs | 6 +- .../ai-adapters/src/providers/shared.rs | 2 +- .../src/stream/stream_handler/anthropic.rs | 4 +- .../src/agentic/agents/citation_renumber.rs | 20 +- .../agentic/agents/definitions/modes/mod.rs | 4 +- .../definitions/review/review_specialists.rs | 2 +- src/crates/core/src/agentic/agents/mod.rs | 14 +- .../agentic/agents/registry/availability.rs | 64 +- .../src/agentic/agents/registry/builtin.rs | 4 +- .../src/agentic/agents/registry/catalog.rs | 6 +- .../src/agentic/agents/registry/custom.rs | 37 +- .../agentic/agents/registry/custom_loader.rs | 12 +- .../core/src/agentic/agents/registry/mod.rs | 12 +- .../core/src/agentic/agents/registry/query.rs | 13 +- .../src/agentic/agents/registry/support.rs | 7 +- .../core/src/agentic/agents/registry/tests.rs | 72 +- .../core/src/agentic/agents/registry/types.rs | 21 +- .../src/agentic/agents/registry/visibility.rs | 6 +- .../agentic/deep_review/execution_policy.rs | 16 +- .../src/agentic/execution/execution_engine.rs | 7 +- .../src/agentic/execution/round_executor.rs | 18 +- .../tools/implementations/control_hub_tool.rs | 3 +- .../tools/implementations/file_write_tool.rs | 18 +- .../implementations/get_tool_spec_tool.rs | 25 +- .../agentic/tools/implementations/git_tool.rs | 10 +- .../tools/implementations/grep_tool.rs | 12 +- .../tools/implementations/mcp_tools.rs | 3 +- .../src/agentic/tools/implementations/mod.rs | 4 +- .../implementations/session_message_tool.rs | 3 +- .../tools/implementations/skill_tool.rs | 28 +- .../tools/implementations/skills/registry.rs | 7 +- .../src/agentic/tools/manifest_resolver.rs | 36 +- src/crates/core/src/agentic/tools/mod.rs | 4 +- src/crates/core/src/agentic/tools/registry.rs | 1 - src/crates/core/src/miniapp/builtin/mod.rs | 20 +- src/crates/core/src/service/config/manager.rs | 2 +- .../config/mode_config_canonicalizer.rs | 9 +- src/crates/core/src/service/config/types.rs | 18 +- .../core/src/service/remote_ssh/manager.rs | 2 +- .../src/service/remote_ssh/password_vault.rs | 12 +- .../src/service/search/flashgrep/client.rs | 3 +- .../core/src/service/search/flashgrep/mod.rs | 2 +- src/crates/core/src/service/search/service.rs | 8 +- .../services-integrations/src/mcp/auth.rs | 2 +- .../src/app/scenes/session/SessionScene.scss | 2 +- .../components/Markdown/Markdown.scss | 935 ++++++------------ .../components/Markdown/MermaidBlock.scss | 40 +- .../Markdown/ReproductionStepsBlock.scss | 62 +- .../styles/flowchat-markdown-code-vars.scss | 12 +- .../styles/flowchat-markdown-table-vars.scss | 4 +- .../src/component-library/styles/tokens.scss | 34 +- .../flow_chat/components/ChatEmptyState.scss | 6 +- .../src/flow_chat/components/ChatInput.scss | 101 +- .../components/ChatInputWorkspaceStrip.scss | 2 +- .../src/flow_chat/components/CodePreview.scss | 6 +- .../flow_chat/components/CopyOutputButton.css | 4 +- .../components/CoworkExampleCards.scss | 30 +- .../components/DeepReviewConsentDialog.scss | 50 +- .../components/FileMentionPicker.scss | 33 +- .../flow_chat/components/FlowTextBlock.scss | 146 +-- .../components/ImageAnalysisCard.scss | 22 +- .../components/InlineDiffPreview.scss | 14 +- .../flow_chat/components/ModelSelector.scss | 18 +- .../components/PendingQueuePanel.scss | 12 +- .../flow_chat/components/PlannerButton.scss | 4 +- .../flow_chat/components/RichTextInput.scss | 8 +- .../components/ScrollToBottomButton.scss | 2 +- .../components/SnapshotRollbackButton.scss | 4 +- .../TaskDetailPanel/TaskDetailPanel.scss | 36 +- .../components/TokenUsageIndicator.scss | 7 +- .../components/TurnHistoryPanel.scss | 12 +- .../components/TurnRollbackButton.scss | 4 +- .../src/flow_chat/components/UserMessage.css | 6 +- .../src/flow_chat/components/UserMessage.scss | 4 +- .../src/flow_chat/components/WelcomePanel.css | 42 +- .../components/btw/BtwSessionPanel.scss | 14 +- .../components/btw/DeepReviewActionBar.scss | 108 +- .../components/modern/ExploreRegion.scss | 6 +- .../components/modern/ExportImageButton.scss | 8 +- .../components/modern/ModelRoundItem.scss | 20 +- .../modern/ModernFlowChatContainer.scss | 21 +- .../components/modern/ScrollAnchor.scss | 24 +- .../modern/SessionFileModificationsBar.scss | 14 +- .../components/modern/SessionFilesBadge.scss | 4 +- .../components/modern/SubagentItems.scss | 4 +- .../components/modern/UserMessageItem.scss | 47 +- .../SmartRecommendations.scss | 4 +- .../components/usage/SessionUsagePanel.scss | 42 +- .../usage/SessionUsageReportCard.scss | 8 +- .../tool-cards/AcpPermissionActions.scss | 10 +- .../tool-cards/AskUserQuestionCard.scss | 34 +- .../flow_chat/tool-cards/BaseToolCard.scss | 41 +- .../tool-cards/CodeReviewToolCard.scss | 146 +-- .../flow_chat/tool-cards/CompactToolCard.scss | 25 +- .../tool-cards/ContextCompressionDisplay.scss | 19 +- .../tool-cards/CreatePlanDisplay.scss | 54 +- .../flow_chat/tool-cards/DefaultToolCard.scss | 41 +- .../tool-cards/FileOperationToolCard.scss | 28 +- .../tool-cards/GenerativeWidgetToolCard.scss | 14 +- .../tool-cards/GetFileDiffDisplay.scss | 26 +- .../flow_chat/tool-cards/GitToolDisplay.scss | 66 +- .../flow_chat/tool-cards/MCPToolDisplay.scss | 36 +- .../tool-cards/MiniAppToolDisplay.scss | 54 +- .../tool-cards/ModelThinkingDisplay.scss | 166 +--- .../tool-cards/ReviewSessionSummaryCard.scss | 12 +- .../SnapshotFullscreenDiffViewer.css | 24 +- .../flow_chat/tool-cards/TaskToolDisplay.scss | 74 +- .../tool-cards/TerminalToolCard.scss | 103 +- .../tool-cards/TodoWriteDisplay.scss | 14 +- .../tool-cards/ToolCommandPreview.scss | 6 +- .../tool-cards/ToolTimeoutIndicator.scss | 26 +- .../tool-cards/_tool-card-common.scss | 14 +- 145 files changed, 3026 insertions(+), 2850 deletions(-) diff --git a/src/apps/cli/build.rs b/src/apps/cli/build.rs index dfde3bb73c..467fbc9ea4 100644 --- a/src/apps/cli/build.rs +++ b/src/apps/cli/build.rs @@ -38,7 +38,10 @@ fn read_prompt_files( if path.is_dir() { read_prompt_files(&path, base_dir, prompts)?; - } else if matches!(path.extension().and_then(|e| e.to_str()), Some("md" | "txt")) { + } else if matches!( + path.extension().and_then(|e| e.to_str()), + Some("md" | "txt") + ) { let content = fs::read_to_string(&path)?; let relative = path .strip_prefix(base_dir)? @@ -67,11 +70,18 @@ fn generate_prompts_code( writeln!(f, "use std::collections::HashMap;")?; writeln!(f, "use once_cell::sync::Lazy;")?; writeln!(f)?; - writeln!(f, "pub static CLI_PROMPTS: Lazy> = Lazy::new(|| {{")?; + writeln!( + f, + "pub static CLI_PROMPTS: Lazy> = Lazy::new(|| {{" + )?; writeln!(f, " let mut m = HashMap::new();")?; for (key, content) in prompts { - writeln!(f, " m.insert(r###\"{}\"###, r###\"{}\"###);", key, content)?; + writeln!( + f, + " m.insert(r###\"{}\"###, r###\"{}\"###);", + key, content + )?; } writeln!(f, " m")?; @@ -79,7 +89,10 @@ fn generate_prompts_code( writeln!(f)?; writeln!(f, "/// Get an embedded CLI prompt by name")?; - writeln!(f, "pub fn get_cli_prompt(name: &str) -> Option<&'static str> {{")?; + writeln!( + f, + "pub fn get_cli_prompt(name: &str) -> Option<&'static str> {{" + )?; writeln!(f, " CLI_PROMPTS.get(name).copied()")?; writeln!(f, "}}")?; diff --git a/src/apps/cli/src/acp_cli.rs b/src/apps/cli/src/acp_cli.rs index 3b59785f5b..2f81af94f9 100644 --- a/src/apps/cli/src/acp_cli.rs +++ b/src/apps/cli/src/acp_cli.rs @@ -50,7 +50,10 @@ impl ExternalAcpClient { fn config(self) -> AcpClientConfig { let (command, args) = match self { Self::Opencode => ("opencode", vec!["acp"]), - Self::ClaudeCode => ("npx", vec!["--yes", "@zed-industries/claude-code-acp@latest"]), + Self::ClaudeCode => ( + "npx", + vec!["--yes", "@zed-industries/claude-code-acp@latest"], + ), Self::Codex => ("npx", vec!["--yes", "@zed-industries/codex-acp@latest"]), }; AcpClientConfig { @@ -251,10 +254,7 @@ pub async fn list_external_clients() -> Result<()> { } for info in configured.values() { - if !matches!( - info.id.as_str(), - "opencode" | "claude-code" | "codex" - ) { + if !matches!(info.id.as_str(), "opencode" | "claude-code" | "codex") { print_client_info(info); } } @@ -287,7 +287,9 @@ pub async fn doctor_external_clients() -> Result { if has_runnable { println!("At least one external ACP agent is runnable."); } else { - println!("No external ACP agent is runnable yet. Install opencode, Claude Code, or Codex first."); + println!( + "No external ACP agent is runnable yet. Install opencode, Claude Code, or Codex first." + ); } Ok(has_runnable) } @@ -410,7 +412,7 @@ pub async fn run_external_client( if output.trim().is_empty() { println!("External ACP agent completed without text output."); } else { - println!("{}", output); + println!("{}", output); } Ok(()) } @@ -489,7 +491,10 @@ fn update_client_config_json( entry_object.insert("enabled".to_string(), json!(enabled)); if let Some(permission_mode) = permission_mode { - entry_object.insert("permissionMode".to_string(), serde_json::to_value(permission_mode)?); + entry_object.insert( + "permissionMode".to_string(), + serde_json::to_value(permission_mode)?, + ); } Ok(value) @@ -542,7 +547,11 @@ fn print_requirement_probe(probe: &AcpClientRequirementProbe) { } fn print_requirement_item(label: &str, item: &bitfun_acp::client::AcpRequirementProbeItem) { - let installed = if item.installed { "installed" } else { "missing" }; + let installed = if item.installed { + "installed" + } else { + "missing" + }; let mut details = Vec::new(); if let Some(version) = item.version.as_ref().filter(|value| !value.is_empty()) { details.push(format!("version {}", version)); diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index cc79937cae..1e5cbd8074 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -129,7 +129,11 @@ impl CoreAgentAdapter { ); let workspace = self.workspace_path_buf(); - match self.coordinator.restore_session(&workspace, session_id).await { + match self + .coordinator + .restore_session(&workspace, session_id) + .await + { Ok(_) => { tracing::info!("Backend session restored: {}", session_id); Ok(()) @@ -211,7 +215,8 @@ impl Agent for CoreAgentAdapter { session_id, err ); - self.ensure_backend_session_alive(&session_id, agent_type).await?; + self.ensure_backend_session_alive(&session_id, agent_type) + .await?; self.coordinator .start_dialog_turn( session_id, @@ -237,11 +242,7 @@ impl Agent for CoreAgentAdapter { let turn_id_guard = self.current_turn_id.lock().await; if let (Some(session_id), Some(turn_id)) = (&*session_id_guard, &*turn_id_guard) { - tracing::info!( - "Cancelling turn: session={}, turn={}", - session_id, - turn_id - ); + tracing::info!("Cancelling turn: session={}, turn={}", session_id, turn_id); self.coordinator .cancel_dialog_turn(session_id, turn_id) .await?; @@ -276,7 +277,9 @@ impl Agent for CoreAgentAdapter { async fn restore_session(&self, session_id: &str) -> Result<()> { tracing::info!("Restoring session: {}", session_id); let workspace = self.workspace_path_buf(); - self.coordinator.restore_session(&workspace, session_id).await?; + self.coordinator + .restore_session(&workspace, session_id) + .await?; let mut session_id_guard = self.session_id.lock().await; *session_id_guard = Some(session_id.to_string()); @@ -284,7 +287,11 @@ impl Agent for CoreAgentAdapter { Ok(()) } - async fn confirm_tool(&self, tool_id: &str, updated_input: Option) -> Result<()> { + async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> Result<()> { tracing::info!("Confirming tool execution: {}", tool_id); self.coordinator .confirm_tool(tool_id, updated_input) @@ -304,12 +311,16 @@ impl Agent for CoreAgentAdapter { tracing::info!("Submitting user answers for tool: {}", tool_id); use bitfun_core::agentic::tools::user_input_manager::get_user_input_manager; let manager = get_user_input_manager(); - manager.send_answer(tool_id, answers) + manager + .send_answer(tool_id, answers) .map_err(|e| anyhow::anyhow!("Submit user answers failed: {}", e)) } fn session_id(&self) -> Option { // Try to get session_id without blocking (best effort for sync context) - self.session_id.try_lock().ok().and_then(|guard| guard.clone()) + self.session_id + .try_lock() + .ok() + .and_then(|guard| guard.clone()) } } diff --git a/src/apps/cli/src/agent/mod.rs b/src/apps/cli/src/agent/mod.rs index a5e78f29e8..2eb855591b 100644 --- a/src/apps/cli/src/agent/mod.rs +++ b/src/apps/cli/src/agent/mod.rs @@ -3,7 +3,6 @@ /// Wraps interaction with bitfun-core's Agentic system. /// The Agent trait provides a thin adapter over ConversationCoordinator. /// Event consumption is done externally (in the chat/exec mode main loops). - pub mod agentic_system; pub mod core_adapter; @@ -31,7 +30,11 @@ pub trait Agent: Send + Sync { async fn restore_session(&self, session_id: &str) -> Result<()>; /// Confirm tool execution (allow once) - async fn confirm_tool(&self, tool_id: &str, updated_input: Option) -> Result<()>; + async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> Result<()>; /// Reject tool execution async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<()>; diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index fd48592bd7..2d83329f6b 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -1,11 +1,10 @@ +use std::collections::HashMap; /// Chat state module /// /// Pure UI rendering state for the chat interface. /// All session lifecycle and persistence is handled by bitfun-core. /// This module only maintains transient state needed for TUI rendering. - use std::time::SystemTime; -use std::collections::HashMap; use bitfun_core::agentic::core::message::{ Message as CoreMessage, MessageContent, MessageRole as CoreMessageRole, @@ -116,18 +115,11 @@ pub struct ToolDisplayState { #[derive(Debug, Clone)] pub enum FlowItem { /// Text content block - Text { - content: String, - is_streaming: bool, - }, + Text { content: String, is_streaming: bool }, /// AI thinking/reasoning block - Thinking { - content: String, - }, + Thinking { content: String }, /// Tool call block - Tool { - tool_state: ToolDisplayState, - }, + Tool { tool_state: ToolDisplayState }, } /// A chat message for UI rendering (converted from core Message + streaming state) @@ -277,7 +269,6 @@ pub struct ChatState { pub metadata: ChatMetadata, // -- Streaming state (transient, not persisted) -- - /// Current turn ID being processed current_turn_id: Option, /// Ordered flow items for the current streaming message. @@ -290,12 +281,10 @@ pub struct ChatState { pub is_processing: bool, // -- Permission state -- - /// Current pending permission prompt (if a tool needs user confirmation) pub permission_prompt: Option, // -- Question state -- - /// Current pending question prompt (if AskUserQuestion tool is waiting for answers) pub question_prompt: Option, } @@ -451,7 +440,9 @@ impl ChatState { pub fn handle_thinking_chunk(&mut self, content: &str) { // Try to append to the last Thinking block // (Thinking usually comes before text, so check the last item) - let appended = if let Some(FlowItem::Thinking { content: existing }) = self.current_flow_items.last_mut() { + let appended = if let Some(FlowItem::Thinking { content: existing }) = + self.current_flow_items.last_mut() + { existing.push_str(content); true } else { @@ -475,19 +466,23 @@ impl ChatState { pub fn handle_tool_event(&mut self, tool_event: &ToolEventData) { match tool_event { ToolEventData::EarlyDetected { tool_id, tool_name } => { - self.insert_or_update_tool(tool_id, |_existing| { - // Should not exist yet, but handle gracefully - }, || ToolDisplayState { - tool_id: tool_id.clone(), - tool_name: tool_name.clone(), - parameters: serde_json::Value::Null, - status: ToolDisplayStatus::EarlyDetected, - result: None, - progress_message: None, - duration_ms: None, - metadata: None, - subagent_progress: None, - }); + self.insert_or_update_tool( + tool_id, + |_existing| { + // Should not exist yet, but handle gracefully + }, + || ToolDisplayState { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + parameters: serde_json::Value::Null, + status: ToolDisplayStatus::EarlyDetected, + result: None, + progress_message: None, + duration_ms: None, + metadata: None, + subagent_progress: None, + }, + ); self.rebuild_streaming_message(); } @@ -528,8 +523,7 @@ impl ChatState { if !tool.status.is_execution_phase() { tool.status = ToolDisplayStatus::Waiting; } - tool.progress_message = - Some(format!("Waiting for: {:?}", dependencies)); + tool.progress_message = Some(format!("Waiting for: {:?}", dependencies)); }); self.rebuild_streaming_message(); } @@ -543,28 +537,29 @@ impl ChatState { let params_for_update = params.clone(); let params_for_create = params.clone(); let tool_name_clone = tool_name.clone(); - self.insert_or_update_tool(tool_id, |tool| { - tool.status = ToolDisplayStatus::Running; - tool.parameters = params_for_update; - }, || ToolDisplayState { - tool_id: tool_id.clone(), - tool_name: tool_name_clone, - parameters: params_for_create, - status: ToolDisplayStatus::Running, - result: None, - progress_message: None, - duration_ms: None, - metadata: None, - subagent_progress: None, - }); + self.insert_or_update_tool( + tool_id, + |tool| { + tool.status = ToolDisplayStatus::Running; + tool.parameters = params_for_update; + }, + || ToolDisplayState { + tool_id: tool_id.clone(), + tool_name: tool_name_clone, + parameters: params_for_create, + status: ToolDisplayStatus::Running, + result: None, + progress_message: None, + duration_ms: None, + metadata: None, + subagent_progress: None, + }, + ); self.metadata.tool_calls += 1; // Auto-create question prompt for AskUserQuestion tool if tool_name == "AskUserQuestion" { - if let Some(prompt) = QuestionPrompt::from_params( - tool_id.clone(), - params, - ) { + if let Some(prompt) = QuestionPrompt::from_params(tool_id.clone(), params) { self.question_prompt = Some(prompt); } } @@ -588,17 +583,19 @@ impl ChatState { } => { self.update_tool(tool_id, |tool| { tool.status = ToolDisplayStatus::Streaming; - tool.progress_message = - Some(format!("Received {} chunks", chunks_received)); + tool.progress_message = Some(format!("Received {} chunks", chunks_received)); }); self.rebuild_streaming_message(); } - ToolEventData::ConfirmationNeeded { tool_id, tool_name, params } => { + ToolEventData::ConfirmationNeeded { + tool_id, + tool_name, + params, + } => { self.update_tool(tool_id, |tool| { tool.status = ToolDisplayStatus::ConfirmationNeeded; - tool.progress_message = - Some("Waiting for user confirmation".to_string()); + tool.progress_message = Some("Waiting for user confirmation".to_string()); }); // Auto-create permission prompt for user interaction self.permission_prompt = Some(PermissionPrompt::new( @@ -641,16 +638,14 @@ impl ChatState { .. } => { // Prefer result_for_assistant from tool, fallback to extracting from JSON - let result_str = result_for_assistant.clone() + let result_str = result_for_assistant + .clone() .unwrap_or_else(|| extract_fallback_summary(result)); let metadata = result.clone(); let dur = *duration_ms; self.update_tool(tool_id, |tool| { let is_hmos_failed = tool_name == "HmosCompilation" - && result - .get("success") - .and_then(|v| v.as_bool()) - == Some(false); + && result.get("success").and_then(|v| v.as_bool()) == Some(false); tool.status = if is_hmos_failed { ToolDisplayStatus::Failed } else { @@ -667,9 +662,7 @@ impl ChatState { self.rebuild_streaming_message(); } - ToolEventData::Failed { - tool_id, error, .. - } => { + ToolEventData::Failed { tool_id, error, .. } => { let err = error.clone(); self.update_tool(tool_id, |tool| { tool.status = ToolDisplayStatus::Failed; @@ -706,56 +699,68 @@ impl ChatState { /// /// When a subagent emits events (tool started, completed, etc.), we forward /// key information to the parent Task tool so the UI can show real-time progress. - pub fn handle_subagent_event(&mut self, parent_tool_id: &str, event: &bitfun_events::AgenticEvent) { + pub fn handle_subagent_event( + &mut self, + parent_tool_id: &str, + event: &bitfun_events::AgenticEvent, + ) { use bitfun_events::AgenticEvent; match event { - AgenticEvent::ToolEvent { tool_event, .. } => { - match tool_event { - ToolEventData::Started { - tool_name, - params, - .. - } => { - let title = extract_tool_title(tool_name, params); - self.update_tool(parent_tool_id, |tool| { - let progress = tool.subagent_progress.get_or_insert_with(SubagentProgress::default); - progress.tool_count += 1; - progress.current_tool_name = Some(tool_name.clone()); - progress.current_tool_title = title; - }); - self.rebuild_streaming_message(); - } - ToolEventData::Completed { - tool_name, - result_for_assistant, - result: _, - .. - } => { - let summary = result_for_assistant.clone() - .unwrap_or_else(|| tool_name.clone()); - self.update_tool(parent_tool_id, |tool| { - let progress = tool.subagent_progress.get_or_insert_with(SubagentProgress::default); - progress.current_tool_name = Some(tool_name.clone()); - progress.current_tool_title = Some(summary); - }); - self.rebuild_streaming_message(); - } - ToolEventData::Failed { tool_name, error, .. } => { - self.update_tool(parent_tool_id, |tool| { - let progress = tool.subagent_progress.get_or_insert_with(SubagentProgress::default); - progress.current_tool_name = Some(tool_name.clone()); - progress.current_tool_title = Some(format!("Error: {}", truncate_string(error, 60))); - }); - self.rebuild_streaming_message(); - } - _ => {} + AgenticEvent::ToolEvent { tool_event, .. } => match tool_event { + ToolEventData::Started { + tool_name, params, .. + } => { + let title = extract_tool_title(tool_name, params); + self.update_tool(parent_tool_id, |tool| { + let progress = tool + .subagent_progress + .get_or_insert_with(SubagentProgress::default); + progress.tool_count += 1; + progress.current_tool_name = Some(tool_name.clone()); + progress.current_tool_title = title; + }); + self.rebuild_streaming_message(); } - } + ToolEventData::Completed { + tool_name, + result_for_assistant, + result: _, + .. + } => { + let summary = result_for_assistant + .clone() + .unwrap_or_else(|| tool_name.clone()); + self.update_tool(parent_tool_id, |tool| { + let progress = tool + .subagent_progress + .get_or_insert_with(SubagentProgress::default); + progress.current_tool_name = Some(tool_name.clone()); + progress.current_tool_title = Some(summary); + }); + self.rebuild_streaming_message(); + } + ToolEventData::Failed { + tool_name, error, .. + } => { + self.update_tool(parent_tool_id, |tool| { + let progress = tool + .subagent_progress + .get_or_insert_with(SubagentProgress::default); + progress.current_tool_name = Some(tool_name.clone()); + progress.current_tool_title = + Some(format!("Error: {}", truncate_string(error, 60))); + }); + self.rebuild_streaming_message(); + } + _ => {} + }, AgenticEvent::ModelRoundStarted { round_index, .. } => { if *round_index > 0 { self.update_tool(parent_tool_id, |tool| { - let progress = tool.subagent_progress.get_or_insert_with(SubagentProgress::default); + let progress = tool + .subagent_progress + .get_or_insert_with(SubagentProgress::default); progress.current_tool_name = None; progress.current_tool_title = Some(format!("Round {}", round_index + 1)); }); @@ -905,11 +910,7 @@ impl ChatState { /// Update an existing tool in current_flow_items via tool_index. /// No-op if the tool_id is not found (defensive). - fn update_tool( - &mut self, - tool_id: &str, - update_fn: impl FnOnce(&mut ToolDisplayState), - ) { + fn update_tool(&mut self, tool_id: &str, update_fn: impl FnOnce(&mut ToolDisplayState)) { if let Some(&idx) = self.tool_index.get(tool_id) { if let Some(FlowItem::Tool { tool_state }) = self.current_flow_items.get_mut(idx) { update_fn(tool_state); @@ -976,30 +977,42 @@ fn extract_tool_title(tool_name: &str, params: &serde_json::Value) -> Option { - obj.get("path").and_then(|v| v.as_str()).map(|s| truncate_string(s, 50)) - } - "Bash" => { - obj.get("command").and_then(|v| v.as_str()).map(|s| truncate_string(s, 50)) - } - "Grep" => { - obj.get("pattern").and_then(|v| v.as_str()).map(|s| truncate_string(s, 40)) - } - "Glob" | "LS" => { - obj.get("glob_pattern") - .or_else(|| obj.get("target_directory")) - .and_then(|v| v.as_str()) - .map(|s| truncate_string(s, 50)) - } - "WebSearch" => { - obj.get("search_term").and_then(|v| v.as_str()).map(|s| truncate_string(s, 40)) - } - "WebFetch" => { - obj.get("url").and_then(|v| v.as_str()).map(|s| truncate_string(s, 50)) - } + "Read" | "Write" | "Edit" | "Delete" | "GetFileDiff" => obj + .get("path") + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 50)), + "Bash" => obj + .get("command") + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 50)), + "Grep" => obj + .get("pattern") + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 40)), + "Glob" | "LS" => obj + .get("glob_pattern") + .or_else(|| obj.get("target_directory")) + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 50)), + "WebSearch" => obj + .get("search_term") + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 40)), + "WebFetch" => obj + .get("url") + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 50)), _ => { // Generic: try common parameter names - for key in &["path", "file_path", "command", "query", "pattern", "url", "description"] { + for key in &[ + "path", + "file_path", + "command", + "query", + "pattern", + "url", + "description", + ] { if let Some(s) = obj.get(*key).and_then(|v| v.as_str()) { if !s.is_empty() { return Some(truncate_string(s, 50)); diff --git a/src/apps/cli/src/config.rs b/src/apps/cli/src/config.rs index dad45048ba..0740e37a42 100644 --- a/src/apps/cli/src/config.rs +++ b/src/apps/cli/src/config.rs @@ -1,12 +1,11 @@ /// Configuration management module -/// +/// /// CLI uses core's GlobalConfig system directly. /// Only CLI-specific configuration is kept here (UI, shortcuts, etc.) - use anyhow::Result; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; use std::fs; +use std::path::PathBuf; /// CLI configuration (contains only CLI-specific config) /// AI model configuration uses core's GlobalConfig @@ -139,14 +138,14 @@ impl CliConfig { .join(".config") .join("bitfun") }; - + Ok(config_dir.join("config.toml")) } /// Load configuration pub fn load() -> Result { let config_path = Self::config_path()?; - + if !config_path.exists() { tracing::info!("Config file not found, using defaults"); let config = Self::default(); @@ -163,7 +162,7 @@ impl CliConfig { /// Save configuration pub fn save(&self) -> Result<()> { let config_path = Self::config_path()?; - + if let Some(parent) = config_path.parent() { fs::create_dir_all(parent)?; } @@ -186,7 +185,7 @@ impl CliConfig { .join(".config") .join("bitfun") }; - + fs::create_dir_all(&config_dir)?; Ok(config_dir) } @@ -198,5 +197,4 @@ impl CliConfig { fs::create_dir_all(&sessions_dir)?; Ok(sessions_dir) } - } diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 6a41f383a9..471ec21473 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -1,24 +1,23 @@ /// BitFun CLI -/// +/// /// Command-line interface version, supports: /// - Interactive TUI /// - Single command execution /// - Batch task processing - mod acp_cli; -mod config; +mod agent; #[allow(dead_code)] mod chat_state; mod commands; -mod ui; +mod config; mod modes; -mod agent; mod prompts; +mod ui; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use std::sync::OnceLock; use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::OnceLock; use config::CliConfig; use modes::chat::ChatMode; @@ -61,7 +60,7 @@ pub fn get_mcp_service() -> Option<&'static std::sync::Arc, - + /// Enable verbose logging #[arg(short, long, global = true)] verbose: bool, @@ -75,39 +74,39 @@ enum Commands { #[arg(short, long, default_value = "agentic")] agent: String, }, - + /// Execute single command Exec { /// User message message: String, - + /// Agent type #[arg(short, long, default_value = "agentic")] agent: String, - + /// Output git diff patch after execution (for SWE-bench evaluation) /// Without path outputs to terminal, with path saves to file /// Example: --output-patch or --output-patch ./result.patch #[arg(long, num_args = 0..=1, default_missing_value = "-")] output_patch: Option, - + /// Tool execution requires confirmation (default: no confirmation to avoid blocking non-interactive mode) #[arg(long)] confirm: bool, }, - + /// Session management Sessions { #[command(subcommand)] action: SessionAction, }, - + /// Configuration management Config { #[command(subcommand)] action: ConfigAction, }, - + /// Health check Health, @@ -313,10 +312,10 @@ async fn initialize_core_services( Ok(mcp_service) => { let mcp_service = std::sync::Arc::new(mcp_service); MCP_SERVICE.set(mcp_service.clone()).ok(); - + // Mark as in progress get_mcp_init_status().store(1, Ordering::Relaxed); - + // Background async initialization tokio::spawn(async move { let result = mcp_service.server_manager().initialize_all().await; @@ -345,9 +344,7 @@ async fn initialize_core_services( /// Restore original tool confirmation setting async fn restore_tool_confirmation(original: bool) { if let Ok(svc) = bitfun_core::service::config::get_global_config_service().await { - let _ = svc - .set_config("ai.skip_tool_confirmation", original) - .await; + let _ = svc.set_config("ai.skip_tool_confirmation", original).await; } } @@ -380,8 +377,7 @@ async fn run_interactive( let workspace = setup_workspace(); // 3. Initialize core services - let (agentic_system, original_skip_confirmation) = - initialize_core_services(true).await?; + let (agentic_system, original_skip_confirmation) = initialize_core_services(true).await?; // 4. Show startup page (with full command support) let mut startup_page = StartupPage::new( @@ -435,7 +431,7 @@ async fn run_interactive( #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); - + let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); let is_acp_command = matches!(cli.command, Some(Commands::Acp { .. })); let is_acp_serve = matches!( @@ -454,22 +450,19 @@ async fn main() -> Result<()> { } else { tracing::Level::INFO }; - + if is_tui_mode { use std::fs::OpenOptions; - - let log_dir = CliConfig::config_dir().ok() + + let log_dir = CliConfig::config_dir() + .ok() .map(|d| d.join("logs")) .unwrap_or_else(|| std::env::temp_dir().join("bitfun-cli")); - + std::fs::create_dir_all(&log_dir).ok(); let log_file = log_dir.join("bitfun-cli.log"); - - if let Ok(file) = OpenOptions::new() - .create(true) - .append(true) - .open(log_file) - { + + if let Ok(file) = OpenOptions::new().create(true).append(true).open(log_file) { tracing_subscriber::fmt() .with_max_level(log_level) .with_writer(move || file.try_clone().unwrap()) @@ -495,7 +488,7 @@ async fn main() -> Result<()> { .with_target(false) .init(); } - + let config = CliConfig::load().unwrap_or_else(|e| { if !is_tui_mode { eprintln!("Warning: Failed to load config: {}", e); @@ -503,28 +496,33 @@ async fn main() -> Result<()> { } CliConfig::default() }); - + match cli.command { Some(Commands::Chat { agent }) => { // Interactive mode with startup page, scoped to the current directory. run_interactive(config, agent, ".".to_string()).await?; } - - Some(Commands::Exec { message, agent, output_patch, confirm }) => { + + Some(Commands::Exec { + message, + agent, + output_patch, + confirm, + }) => { let workspace_path_resolved = std::env::current_dir().ok(); - + if let Some(ref ws_path) = workspace_path_resolved { tracing::info!("Workspace path set: {:?}", ws_path); } - + let skip_confirmation = !confirm; let (agentic_system, original_skip_confirmation) = initialize_core_services(skip_confirmation).await?; - + let mut exec_mode = ExecMode::new( - config, - message, - agent, + config, + message, + agent, &agentic_system, workspace_path_resolved, output_patch, @@ -536,15 +534,15 @@ async fn main() -> Result<()> { run_result?; } - + Some(Commands::Sessions { action }) => { handle_session_action(action).await?; } - + Some(Commands::Config { action }) => { handle_config_action(action, &config)?; } - + Some(Commands::Health) => { println!("BitFun CLI is running normally"); println!("Version: {}", env!("CARGO_PKG_VERSION")); @@ -599,23 +597,21 @@ async fn main() -> Result<()> { Some(Commands::Acp { action: Some(AcpAction::Clients { action }), - }) => { - match action { - AcpClientsAction::List => acp_cli::list_external_clients().await?, - AcpClientsAction::Doctor => { - if !acp_cli::doctor_external_clients().await? { - std::process::exit(1); - } - } - AcpClientsAction::Enable { client, permission } => { - acp_cli::enable_external_client(client, permission).await?; + }) => match action { + AcpClientsAction::List => acp_cli::list_external_clients().await?, + AcpClientsAction::Doctor => { + if !acp_cli::doctor_external_clients().await? { + std::process::exit(1); } - AcpClientsAction::Disable { client_id } => { - acp_cli::disable_external_client(&client_id).await?; - } - AcpClientsAction::Config => acp_cli::print_external_client_config().await?, } - } + AcpClientsAction::Enable { client, permission } => { + acp_cli::enable_external_client(client, permission).await?; + } + AcpClientsAction::Disable { client_id } => { + acp_cli::disable_external_client(&client_id).await?; + } + AcpClientsAction::Config => acp_cli::print_external_client_config().await?, + }, Some(Commands::Acp { action: @@ -629,7 +625,7 @@ async fn main() -> Result<()> { }) => { acp_cli::run_external_client(client, prompt, workspace, timeout, permission).await?; } - + None => { // Default: interactive TUI with startup page let workspace_str = ".".to_string(); @@ -638,7 +634,7 @@ async fn main() -> Result<()> { run_interactive(config, default_agent, workspace_str).await?; } } - + Ok(()) } @@ -653,8 +649,7 @@ async fn handle_session_action(action: SessionAction) -> Result<()> { .expect("Failed to initialize agentic system"); let coordinator = agentic_system.coordinator.clone(); - let workspace_path = std::env::current_dir() - .unwrap_or_else(|_| std::path::PathBuf::from(".")); + let workspace_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); match action { SessionAction::List => { @@ -676,7 +671,8 @@ async fn handle_session_action(action: SessionAction) -> Result<()> { for (i, info) in sessions.iter().enumerate() { let last_updated = { - let duration = info.last_activity_at + let duration = info + .last_activity_at .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default(); let secs = duration.as_secs() as i64; @@ -729,21 +725,31 @@ async fn handle_session_action(action: SessionAction) -> Result<()> { bitfun_core::agentic::core::message::MessageContent::Text(text) => { text.lines().next().unwrap_or("").to_string() } - bitfun_core::agentic::core::message::MessageContent::Multimodal { text, images } => { + bitfun_core::agentic::core::message::MessageContent::Multimodal { + text, + images, + } => { if text.is_empty() { format!("[{} images]", images.len()) } else { text.lines().next().unwrap_or("").to_string() } } - bitfun_core::agentic::core::message::MessageContent::Mixed { text, tool_calls, .. } => { + bitfun_core::agentic::core::message::MessageContent::Mixed { + text, + tool_calls, + .. + } => { if text.is_empty() { format!("[{} tool calls]", tool_calls.len()) } else { text.lines().next().unwrap_or("").to_string() } } - bitfun_core::agentic::core::message::MessageContent::ToolResult { tool_name, .. } => { + bitfun_core::agentic::core::message::MessageContent::ToolResult { + tool_name, + .. + } => { format!("[Tool result: {}]", tool_name) } }; @@ -787,7 +793,7 @@ fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> println!(); println!("Config file: {:?}", CliConfig::config_path()?); } - + ConfigAction::Edit => { let config_path = CliConfig::config_path()?; println!("Config file location: {:?}", config_path); @@ -797,13 +803,13 @@ fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> println!(" or"); println!(" code {:?}", config_path); } - + ConfigAction::Reset => { let default_config = CliConfig::default(); default_config.save()?; println!("Reset to default configuration"); } } - + Ok(()) } diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 5d35f8fb94..236c32f3d2 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -2,15 +2,14 @@ /// /// Single command execution mode (non-interactive). /// Consumes core events directly from EventQueue. - use anyhow::Result; use std::path::PathBuf; use std::sync::Arc; use bitfun_events::AgenticEvent; +use crate::agent::{agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent}; use crate::config::CliConfig; -use crate::agent::{Agent, core_adapter::CoreAgentAdapter, agentic_system::AgenticSystem}; pub struct ExecMode { #[allow(dead_code)] @@ -87,7 +86,10 @@ impl ExecMode { println!("Thinking..."); - let _turn_id = self.agent.send_message(self.message.clone(), &self.agent_type).await?; + let _turn_id = self + .agent + .send_message(self.message.clone(), &self.agent_type) + .await?; // Consume events from EventQueue until turn completes let mut total_tool_calls = 0usize; @@ -103,7 +105,12 @@ impl ExecMode { // Only process events for our session if event.session_id() != Some(&session_id) { // Check if this is a subagent event whose parent is in our session - if let AgenticEvent::ToolEvent { tool_event, subagent_parent_info, .. } = event { + if let AgenticEvent::ToolEvent { + tool_event, + subagent_parent_info, + .. + } = event + { if subagent_parent_info .as_ref() .map(|info| info.session_id.as_str()) @@ -125,7 +132,9 @@ impl ExecMode { .unwrap_or_else(|| result.to_string()); println!(" [subagent] {} ✓ {}", tool_name, summary); } - ToolEventData::Failed { tool_name, error, .. } => { + ToolEventData::Failed { + tool_name, error, .. + } => { println!(" [subagent] {} ✗ {}", tool_name, error); } _ => {} @@ -184,10 +193,7 @@ impl ExecMode { println!("\n"); println!("Execution complete"); if total_tool_calls > 0 { - println!( - "\nTool call statistics: {} tools invoked", - total_tool_calls - ); + println!("\nTool call statistics: {} tools invoked", total_tool_calls); } // Break out of the event loop self.output_patch_if_needed(); diff --git a/src/apps/cli/src/modes/mod.rs b/src/apps/cli/src/modes/mod.rs index cfdcaa4766..2ce28d6531 100644 --- a/src/apps/cli/src/modes/mod.rs +++ b/src/apps/cli/src/modes/mod.rs @@ -1,4 +1,3 @@ /// Different interaction modes - pub mod chat; pub mod exec; diff --git a/src/apps/cli/src/ui/agent_selector.rs b/src/apps/cli/src/ui/agent_selector.rs index b66a529469..9c273481e3 100644 --- a/src/apps/cli/src/ui/agent_selector.rs +++ b/src/apps/cli/src/ui/agent_selector.rs @@ -2,7 +2,6 @@ /// /// Overlay popup that displays all available agent modes /// and allows the user to select one to switch to. - use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, diff --git a/src/apps/cli/src/ui/chat.rs b/src/apps/cli/src/ui/chat.rs index b426d1bb63..a0b0155f16 100644 --- a/src/apps/cli/src/ui/chat.rs +++ b/src/apps/cli/src/ui/chat.rs @@ -9,4 +9,3 @@ include!("chat/input.rs"); include!("chat/popups.rs"); include!("chat/scroll.rs"); include!("chat/mouse.rs"); - diff --git a/src/apps/cli/src/ui/command_menu.rs b/src/apps/cli/src/ui/command_menu.rs index 0872f623b3..a755905fda 100644 --- a/src/apps/cli/src/ui/command_menu.rs +++ b/src/apps/cli/src/ui/command_menu.rs @@ -1,5 +1,4 @@ /// Slash command menu rendering and state - use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, @@ -37,7 +36,12 @@ impl CommandMenuState { self.update_with_commands(input, cursor, COMMAND_SPECS); } - pub fn update_with_commands(&mut self, input: &str, cursor: usize, commands: &'static [CommandSpec]) { + pub fn update_with_commands( + &mut self, + input: &str, + cursor: usize, + commands: &'static [CommandSpec], + ) { if self.suppressed && input == self.last_input { return; } @@ -111,16 +115,20 @@ impl CommandMenuState { return; } - let items: Vec = self.items.iter().map(|spec| { - let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); - let desc_style = theme.style(StyleKind::Muted); - let line = Line::from(vec![ - Span::styled(spec.name, name_style), - Span::raw(" - "), - Span::styled(spec.description, desc_style), - ]); - ListItem::new(line) - }).collect(); + let items: Vec = self + .items + .iter() + .map(|spec| { + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let desc_style = theme.style(StyleKind::Muted); + let line = Line::from(vec![ + Span::styled(spec.name, name_style), + Span::raw(" - "), + Span::styled(spec.description, desc_style), + ]); + ListItem::new(line) + }) + .collect(); let desired_height = (items.len() as u16).saturating_add(2); let height = desired_height.min(area.height); diff --git a/src/apps/cli/src/ui/diff_render.rs b/src/apps/cli/src/ui/diff_render.rs index 6f42517cc5..d767928a7a 100644 --- a/src/apps/cli/src/ui/diff_render.rs +++ b/src/apps/cli/src/ui/diff_render.rs @@ -2,12 +2,11 @@ /// /// Uses the `similar` crate to compute line-level diffs and renders them /// with line numbers, hunk headers, and full-line background colors. - use ratatui::text::{Line, Span}; use similar::{ChangeTag, TextDiff}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -use super::theme::{Theme, StyleKind}; +use super::theme::{StyleKind, Theme}; /// Diff view mode #[derive(Debug, Clone, Copy, PartialEq)] @@ -278,7 +277,10 @@ fn render_unified<'a>( for (visual_idx, segment) in wrapped.iter().enumerate() { if shown >= max_lines { lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + format!( + "\u{2026} ({} more changes)", + entries.len().saturating_sub(i) + ), theme.style(StyleKind::Muted), ))); return lines; @@ -462,7 +464,10 @@ fn render_split<'a>( for (row_idx, segment) in wrapped.iter().enumerate() { if shown >= max_lines { lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + format!( + "\u{2026} ({} more changes)", + entries.len().saturating_sub(i) + ), theme.style(StyleKind::Muted), ))); return lines; @@ -512,7 +517,10 @@ fn render_split<'a>( for row_idx in 0..row_count { if shown >= max_lines { lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + format!( + "\u{2026} ({} more changes)", + entries.len().saturating_sub(i) + ), theme.style(StyleKind::Muted), ))); return lines; @@ -562,7 +570,10 @@ fn render_split<'a>( for (row_idx, old_piece) in old_wrapped.iter().enumerate() { if shown >= max_lines { lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + format!( + "\u{2026} ({} more changes)", + entries.len().saturating_sub(i) + ), theme.style(StyleKind::Muted), ))); return lines; @@ -603,7 +614,10 @@ fn render_split<'a>( for (row_idx, new_piece) in new_wrapped.iter().enumerate() { if shown >= max_lines { lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + format!( + "\u{2026} ({} more changes)", + entries.len().saturating_sub(i) + ), theme.style(StyleKind::Muted), ))); return lines; diff --git a/src/apps/cli/src/ui/markdown.rs b/src/apps/cli/src/ui/markdown.rs index 8c2f0f600a..bf10b9f981 100644 --- a/src/apps/cli/src/ui/markdown.rs +++ b/src/apps/cli/src/ui/markdown.rs @@ -1,16 +1,15 @@ /// Markdown rendering utilities - use std::collections::HashMap; use std::hash::{Hash, Hasher}; -use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd, HeadingLevel}; +use pulldown_cmark::{Alignment, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use ratatui::{ style::{Modifier, Style}, text::{Line, Span}, }; -use unicode_width::{UnicodeWidthStr, UnicodeWidthChar}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -use super::theme::{Theme, StyleKind}; +use super::theme::{StyleKind, Theme}; /// Markdown renderer with built-in cache for parsed results. /// Avoids re-parsing the same markdown content on every frame. @@ -23,7 +22,10 @@ pub struct MarkdownRenderer { impl MarkdownRenderer { pub fn new(theme: Theme) -> Self { - Self { theme, cache: HashMap::new() } + Self { + theme, + cache: HashMap::new(), + } } /// Compute a cache key from content and width @@ -49,21 +51,21 @@ impl MarkdownRenderer { pub fn clear_cache(&mut self) { self.cache.clear(); } - + pub fn render(&self, markdown: &str, width: usize) -> Vec> { let mut lines = Vec::new(); let mut current_line_spans: Vec> = Vec::new(); - + // Style stack let mut style_stack: Vec = Vec::new(); let mut list_level: usize = 0; let mut in_code_block = false; let mut code_block_lang = String::new(); let wrap_width = if width > 0 { width } else { 80 }; - + // Table state let mut table_state = TableState::new(); - + // Flush current_line_spans into `lines`, wrapping at `wrap_width`. // Headings skip wrapping. let flush_with_wrap = |spans: &mut Vec>, @@ -83,106 +85,106 @@ impl MarkdownRenderer { let options = Options::all(); let parser = Parser::new_ext(markdown, options); - + for event in parser { match event { - Event::Start(tag) => { - match tag { - Tag::Heading { level, .. } => { - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); - lines.push(Line::from("")); - - let prefix = match level { - HeadingLevel::H1 => "# ", - HeadingLevel::H2 => "## ", - HeadingLevel::H3 => "### ", - HeadingLevel::H4 => "#### ", - HeadingLevel::H5 => "##### ", - HeadingLevel::H6 => "###### ", - }; - current_line_spans.push(Span::styled( - prefix.to_string(), - self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) - )); - - style_stack.push(StyleModifier::Heading); - } - Tag::Paragraph => {} - Tag::BlockQuote(_) => { - current_line_spans.push(Span::styled( - "\u{2502} ".to_string(), - self.theme.style(StyleKind::Muted) - )); - style_stack.push(StyleModifier::Quote); - } - Tag::CodeBlock(kind) => { - in_code_block = true; - if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind { - code_block_lang = lang.to_string(); - } - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); - lines.push(Line::from("")); - - if !code_block_lang.is_empty() { - current_line_spans.push(Span::styled( - format!("```{}", code_block_lang), - self.theme.style(StyleKind::Muted) - )); - lines.push(Line::from(std::mem::take(&mut current_line_spans))); - } - } - Tag::List(_) => { - list_level += 1; - if list_level == 1 { - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); - } - } - Tag::Item => { - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); - let indent = " ".repeat(list_level.saturating_sub(1)); - current_line_spans.push(Span::raw(indent)); - current_line_spans.push(Span::styled( - "\u{2022} ".to_string(), - self.theme.style(StyleKind::Primary) - )); - } - Tag::Strong => { - style_stack.push(StyleModifier::Bold); - } - Tag::Emphasis => { - style_stack.push(StyleModifier::Italic); - } - Tag::Link { .. } => { - style_stack.push(StyleModifier::Link); + Event::Start(tag) => match tag { + Tag::Heading { level, .. } => { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + lines.push(Line::from("")); + + let prefix = match level { + HeadingLevel::H1 => "# ", + HeadingLevel::H2 => "## ", + HeadingLevel::H3 => "### ", + HeadingLevel::H4 => "#### ", + HeadingLevel::H5 => "##### ", + HeadingLevel::H6 => "###### ", + }; + current_line_spans.push(Span::styled( + prefix.to_string(), + self.theme + .style(StyleKind::Primary) + .add_modifier(Modifier::BOLD), + )); + + style_stack.push(StyleModifier::Heading); + } + Tag::Paragraph => {} + Tag::BlockQuote(_) => { + current_line_spans.push(Span::styled( + "\u{2502} ".to_string(), + self.theme.style(StyleKind::Muted), + )); + style_stack.push(StyleModifier::Quote); + } + Tag::CodeBlock(kind) => { + in_code_block = true; + if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind { + code_block_lang = lang.to_string(); } - Tag::Image { .. } => { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + lines.push(Line::from("")); + + if !code_block_lang.is_empty() { current_line_spans.push(Span::styled( - "[Image]".to_string(), - self.theme.style(StyleKind::Info) + format!("```{}", code_block_lang), + self.theme.style(StyleKind::Muted), )); + lines.push(Line::from(std::mem::take(&mut current_line_spans))); } - Tag::Table(alignment) => { + } + Tag::List(_) => { + list_level += 1; + if list_level == 1 { flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); - lines.push(Line::from("")); - table_state.start_table(alignment.into_iter().collect()); - } - Tag::TableHead => { - table_state.is_header = true; - table_state.start_row(); - } - Tag::TableRow => { - table_state.start_row(); } - Tag::TableCell => { - table_state.start_cell(); - if table_state.is_header { - style_stack.push(StyleModifier::TableHeader); - } + } + Tag::Item => { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + let indent = " ".repeat(list_level.saturating_sub(1)); + current_line_spans.push(Span::raw(indent)); + current_line_spans.push(Span::styled( + "\u{2022} ".to_string(), + self.theme.style(StyleKind::Primary), + )); + } + Tag::Strong => { + style_stack.push(StyleModifier::Bold); + } + Tag::Emphasis => { + style_stack.push(StyleModifier::Italic); + } + Tag::Link { .. } => { + style_stack.push(StyleModifier::Link); + } + Tag::Image { .. } => { + current_line_spans.push(Span::styled( + "[Image]".to_string(), + self.theme.style(StyleKind::Info), + )); + } + Tag::Table(alignment) => { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + lines.push(Line::from("")); + table_state.start_table(alignment.into_iter().collect()); + } + Tag::TableHead => { + table_state.is_header = true; + table_state.start_row(); + } + Tag::TableRow => { + table_state.start_row(); + } + Tag::TableCell => { + table_state.start_cell(); + if table_state.is_header { + style_stack.push(StyleModifier::TableHeader); } - _ => {} } - } - + _ => {} + }, + Event::End(tag_end) => { match tag_end { TagEnd::Heading(_) => { @@ -194,7 +196,12 @@ impl MarkdownRenderer { } TagEnd::Paragraph => { if !in_code_block && !table_state.in_table { - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + flush_with_wrap( + &mut current_line_spans, + &mut lines, + wrap_width, + true, + ); lines.push(Line::from("")); } } @@ -212,7 +219,7 @@ impl MarkdownRenderer { if !code_block_lang.is_empty() { lines.push(Line::from(Span::styled( "```".to_string(), - self.theme.style(StyleKind::Muted) + self.theme.style(StyleKind::Muted), ))); code_block_lang.clear(); } @@ -221,7 +228,12 @@ impl MarkdownRenderer { TagEnd::List(_) => { list_level = list_level.saturating_sub(1); if list_level == 0 { - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + flush_with_wrap( + &mut current_line_spans, + &mut lines, + wrap_width, + true, + ); lines.push(Line::from("")); } } @@ -265,10 +277,10 @@ impl MarkdownRenderer { _ => {} } } - + Event::Text(text) => { let style = self.compute_style(&style_stack, in_code_block); - + if in_code_block { for line in text.lines() { // Code blocks preserve style but still wrap to viewport width. @@ -285,82 +297,90 @@ impl MarkdownRenderer { current_line_spans.push(Span::styled(text.to_string(), style)); } } - + Event::Code(code) => { - let span = Span::styled( - format!("`{}`", code), - self.theme.style(StyleKind::Success) - ); + let span = + Span::styled(format!("`{}`", code), self.theme.style(StyleKind::Success)); if table_state.in_cell { table_state.push_span(span); } else { current_line_spans.push(span); } } - + Event::SoftBreak | Event::HardBreak => { if !in_code_block && !table_state.in_table { flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); } } - + Event::Rule => { let rule_w = wrap_width.min(60); lines.push(Line::from(Span::styled( "\u{2500}".repeat(rule_w), - self.theme.style(StyleKind::Muted) + self.theme.style(StyleKind::Muted), ))); } - + _ => {} } } - + // Process remaining spans with wrapping if !current_line_spans.is_empty() { wrap_spans_to_lines(current_line_spans, wrap_width, &mut lines); } - + // Remove trailing empty lines - while lines.last().map_or(false, |line| line.spans.is_empty() || - (line.spans.len() == 1 && line.spans[0].content.is_empty())) { + while lines.last().map_or(false, |line| { + line.spans.is_empty() || (line.spans.len() == 1 && line.spans[0].content.is_empty()) + }) { lines.pop(); } - + lines } - + fn compute_style(&self, stack: &[StyleModifier], in_code_block: bool) -> Style { let mut style = Style::default(); - + if in_code_block { return self.theme.style(StyleKind::Success); } - + for modifier in stack { style = match modifier { StyleModifier::Bold => style.add_modifier(Modifier::BOLD), StyleModifier::Italic => style.add_modifier(Modifier::ITALIC), - StyleModifier::Heading => self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + StyleModifier::Heading => self + .theme + .style(StyleKind::Primary) + .add_modifier(Modifier::BOLD), StyleModifier::Quote => style.fg(self.theme.muted), - StyleModifier::Link => self.theme.style(StyleKind::Info).add_modifier(Modifier::UNDERLINED), - StyleModifier::TableHeader => self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + StyleModifier::Link => self + .theme + .style(StyleKind::Info) + .add_modifier(Modifier::UNDERLINED), + StyleModifier::TableHeader => self + .theme + .style(StyleKind::Primary) + .add_modifier(Modifier::BOLD), }; } - + style } - + pub fn has_markdown_syntax(text: &str) -> bool { - text.contains("**") || - text.contains("__") || - text.contains("*") || - text.contains("_") || - text.contains("`") || - text.contains("#") || - text.contains("[") || - text.contains(">") || - text.contains("```") + text.contains("**") + || text.contains("__") + || text.contains("*") + || text.contains("_") + || text.contains("`") + || text.contains("#") + || text.contains("[") + || text.contains(">") + || text.contains("```") } } @@ -373,7 +393,10 @@ fn wrap_spans_to_lines(spans: Vec>, max_width: usize, out: &mut Ve return; } - let total_width: usize = spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum(); + let total_width: usize = spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); if total_width <= max_width { out.push(Line::from(spans)); return; @@ -723,4 +746,4 @@ impl TableState { lines.push(build_border('\u{2514}', '\u{2534}', '\u{2518}')); lines } -} \ No newline at end of file +} diff --git a/src/apps/cli/src/ui/mcp_add_dialog.rs b/src/apps/cli/src/ui/mcp_add_dialog.rs index e79c513047..760a461bfa 100644 --- a/src/apps/cli/src/ui/mcp_add_dialog.rs +++ b/src/apps/cli/src/ui/mcp_add_dialog.rs @@ -5,7 +5,6 @@ /// Step 3: Enter command (local) or URL (remote) /// /// Enter advances to next step, Esc goes back or cancels. - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ layout::Rect, @@ -23,10 +22,7 @@ pub enum McpAddAction { /// No action, dialog consumed the key None, /// User completed all steps — returns name + generated JSON config string - Confirm { - name: String, - config_json: String, - }, + Confirm { name: String, config_json: String }, /// User cancelled (Esc on step 1) Cancel, } @@ -295,7 +291,8 @@ impl McpAddDialogState { format!(r#"{{"type":"stdio","command":"{}"}}"#, value) } else { let cmd = parts[0]; - let args: Vec = parts[1..].iter().map(|s| format!("\"{}\"", s)).collect(); + let args: Vec = + parts[1..].iter().map(|s| format!("\"{}\"", s)).collect(); format!( r#"{{"type":"stdio","command":"{}","args":[{}]}}"#, cmd, @@ -378,7 +375,12 @@ impl McpAddDialogState { // ── Render completed steps as read-only summary ── if self.step == Step::ServerType || self.step == Step::CommandOrUrl { - let name_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let name_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let name_line = Line::from(vec![ Span::styled("\u{2713} ", theme.style(StyleKind::Success)), Span::styled("Name: ", theme.style(StyleKind::Muted)), @@ -392,11 +394,16 @@ impl McpAddDialogState { } if self.step == Step::CommandOrUrl { - let type_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; - let type_label = match self.server_type { - ServerType::Local => "local (stdio)", - ServerType::Remote => "remote (streamable-http)", - }; + let type_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; + let type_label = match self.server_type { + ServerType::Local => "local (stdio)", + ServerType::Remote => "remote (streamable-http)", + }; let type_line = Line::from(vec![ Span::styled("\u{2713} ", theme.style(StyleKind::Success)), Span::styled("Type: ", theme.style(StyleKind::Muted)), @@ -414,7 +421,12 @@ impl McpAddDialogState { match self.step { Step::Name => { // Prompt - let prompt_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let prompt_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let prompt_line = Line::from(Span::styled( "Enter MCP server name (used as identifier):", theme.style(StyleKind::Info), @@ -423,7 +435,12 @@ impl McpAddDialogState { row += 1; // Input - let input_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let input_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let line = render_input_line( " > ", &self.name_buf, @@ -437,7 +454,12 @@ impl McpAddDialogState { } Step::ServerType => { // Prompt - let prompt_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let prompt_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let prompt_line = Line::from(Span::styled( "Select MCP server type:", theme.style(StyleKind::Info), @@ -446,7 +468,12 @@ impl McpAddDialogState { row += 1; // Selector - let type_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let type_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let local_style = if self.server_type == ServerType::Local { Style::default() .fg(theme.selection_foreground()) @@ -474,7 +501,12 @@ impl McpAddDialogState { } Step::CommandOrUrl => { // Prompt - let prompt_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let prompt_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let (prompt_text, placeholder) = match self.server_type { ServerType::Local => ( "Enter command to run the MCP server:", @@ -485,15 +517,18 @@ impl McpAddDialogState { "https://example.com/mcp", ), }; - let prompt_line = Line::from(Span::styled( - prompt_text, - theme.style(StyleKind::Info), - )); + let prompt_line = + Line::from(Span::styled(prompt_text, theme.style(StyleKind::Info))); frame.render_widget(Paragraph::new(prompt_line), prompt_area); row += 1; // Input - let input_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let input_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let line = render_input_line( " > ", &self.value_buf, @@ -511,8 +546,14 @@ impl McpAddDialogState { if let Some(ref err) = self.error { if inner.y + row < inner.y + inner.height { - let err_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; - let err_line = Line::from(Span::styled(err.as_str(), theme.style(StyleKind::Error))); + let err_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; + let err_line = + Line::from(Span::styled(err.as_str(), theme.style(StyleKind::Error))); frame.render_widget(Paragraph::new(err_line), err_area); row += 1; } @@ -521,7 +562,12 @@ impl McpAddDialogState { // ── Hint line ── if inner.y + row < inner.y + inner.height { - let hint_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let hint_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let hint_text = match self.step { Step::Name => "Enter: Next Esc: Cancel", Step::ServerType => "\u{2190}\u{2192}/Tab: Switch Enter: Next Esc: Back", @@ -579,11 +625,17 @@ fn render_input_line<'a>( // Empty buffer: show placeholder with cursor at start if buffer.is_empty() { - let placeholder_display: String = placeholder.chars().take(field_width.saturating_sub(1)).collect(); + let placeholder_display: String = placeholder + .chars() + .take(field_width.saturating_sub(1)) + .collect(); return Line::from(vec![ Span::styled(label, label_style), Span::styled(" ", Style::default().fg(Color::Black).bg(Color::White)), - Span::styled(placeholder_display, theme.style(StyleKind::Muted).add_modifier(Modifier::DIM)), + Span::styled( + placeholder_display, + theme.style(StyleKind::Muted).add_modifier(Modifier::DIM), + ), ]); } @@ -605,7 +657,11 @@ fn render_input_line<'a>( let cursor_char: String = visible_chars.chars().skip(cursor_in_view).take(1).collect(); let after: String = visible_chars.chars().skip(cursor_in_view + 1).collect(); - let cursor_display = if cursor_char.is_empty() { " ".to_string() } else { cursor_char }; + let cursor_display = if cursor_char.is_empty() { + " ".to_string() + } else { + cursor_char + }; let has_more_left = scroll > 0; let has_more_right = scroll + field_width < total_chars; @@ -620,7 +676,10 @@ fn render_input_line<'a>( spans.push(Span::styled(before, text_style)); } - spans.push(Span::styled(cursor_display, Style::default().fg(Color::Black).bg(Color::White))); + spans.push(Span::styled( + cursor_display, + Style::default().fg(Color::Black).bg(Color::White), + )); if has_more_right { let after_len = after.chars().count(); diff --git a/src/apps/cli/src/ui/mcp_selector.rs b/src/apps/cli/src/ui/mcp_selector.rs index 9e2037d6d9..2d980991e6 100644 --- a/src/apps/cli/src/ui/mcp_selector.rs +++ b/src/apps/cli/src/ui/mcp_selector.rs @@ -8,7 +8,6 @@ /// - Space key toggles server on/off /// - Enter key also toggles /// - Status indicators: ✓ Connected (green), ○ Stopped (gray), ✗ Failed (red), ⋯ Loading (yellow) - use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, @@ -84,7 +83,8 @@ impl McpSelectorState { // Preserve selection if possible if let Some(idx) = selected_idx { if idx >= self.items.len() { - self.list_state.select(Some(self.items.len().saturating_sub(1))); + self.list_state + .select(Some(self.items.len().saturating_sub(1))); } } } @@ -159,7 +159,9 @@ impl McpSelectorState { let popup_width = area.width.saturating_sub(4).min(72); // +5 for border(2) + title(1) + hint(1) + padding(1) - let popup_height = (self.items.len() as u16 + 5).min(area.height.saturating_sub(2)).max(6); + let popup_height = (self.items.len() as u16 + 5) + .min(area.height.saturating_sub(2)) + .max(6); if popup_height < 5 || popup_width < 30 { self.last_area = None; return; @@ -185,7 +187,9 @@ impl McpSelectorState { .iter() .map(|item| { let is_loading = loading_id.as_ref().map_or(false, |id| id == &item.id); - let is_confirm_delete = confirm_delete_id.as_ref().map_or(false, |id| id == &item.id); + let is_confirm_delete = confirm_delete_id + .as_ref() + .map_or(false, |id| id == &item.id); // If this item is pending delete confirmation, show special style if is_confirm_delete { diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index e4a50a7116..6f629c57fd 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -1,35 +1,34 @@ /// TUI interface module -/// +/// /// Build terminal user interface using ratatui - pub mod agent_selector; pub mod chat; pub mod command_menu; pub mod command_palette; -pub mod model_config_form; +pub mod diff_render; +pub mod markdown; pub mod mcp_add_dialog; pub mod mcp_selector; +pub mod model_config_form; pub mod model_selector; +pub mod permission; pub mod provider_selector; +pub mod question; pub mod session_selector; pub mod skill_selector; +pub mod startup; +pub mod string_utils; pub mod subagent_selector; +pub mod syntax_highlight; +pub mod text_input; pub mod theme; pub mod theme_selector; -pub mod text_input; -pub mod widgets; -pub mod startup; pub mod tool_cards; -pub mod string_utils; -pub mod markdown; -pub mod diff_render; -pub mod permission; -pub mod question; -pub mod syntax_highlight; +pub mod widgets; use anyhow::Result; use crossterm::{ - event::{EnableMouseCapture, DisableMouseCapture, EnableBracketedPaste, DisableBracketedPaste}, + event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -47,7 +46,12 @@ use std::io; pub fn init_terminal() -> Result>> { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?; + execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + )?; let backend = CrosstermBackend::new(stdout); let terminal = Terminal::new(backend)?; Ok(terminal) @@ -56,13 +60,21 @@ pub fn init_terminal() -> Result>> { /// Restore terminal pub fn restore_terminal(mut terminal: Terminal>) -> Result<()> { disable_raw_mode()?; - execute!(terminal.backend_mut(), DisableBracketedPaste, DisableMouseCapture, LeaveAlternateScreen)?; + execute!( + terminal.backend_mut(), + DisableBracketedPaste, + DisableMouseCapture, + LeaveAlternateScreen + )?; terminal.show_cursor()?; Ok(()) } /// Render a loading/status message on the terminal (stays in alternate screen) -pub fn render_loading(terminal: &mut Terminal>, message: &str) -> Result<()> { +pub fn render_loading( + terminal: &mut Terminal>, + message: &str, +) -> Result<()> { let msg = message.to_string(); terminal.draw(|frame| { let area = frame.area(); @@ -75,14 +87,12 @@ pub fn render_loading(terminal: &mut Terminal>, mes ]) .split(area); - let text = vec![ - Line::from(Span::styled( - msg, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )), - ]; + let text = vec![Line::from(Span::styled( + msg, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))]; let paragraph = Paragraph::new(text).alignment(Alignment::Center); frame.render_widget(paragraph, chunks[1]); diff --git a/src/apps/cli/src/ui/model_selector.rs b/src/apps/cli/src/ui/model_selector.rs index 8d6b6b4867..aef051d9aa 100644 --- a/src/apps/cli/src/ui/model_selector.rs +++ b/src/apps/cli/src/ui/model_selector.rs @@ -2,7 +2,6 @@ /// /// Full-screen overlay popup that displays all available models /// and allows the user to select one. - use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::{Alignment, Rect}, @@ -194,12 +193,10 @@ impl ModelSelectorState { height: 1.min(area.y + area.height - popup_area.y - popup_area.height), }; if hint_area.height > 0 { - let hint = Paragraph::new(Line::from(vec![ - Span::styled( - " Selecting a model will apply to all modes ", - theme.style(StyleKind::Info), - ), - ])) + let hint = Paragraph::new(Line::from(vec![Span::styled( + " Selecting a model will apply to all modes ", + theme.style(StyleKind::Info), + )])) .alignment(Alignment::Center); frame.render_widget(hint, hint_area); } diff --git a/src/apps/cli/src/ui/permission.rs b/src/apps/cli/src/ui/permission.rs index f8e09461e7..449d81e630 100644 --- a/src/apps/cli/src/ui/permission.rs +++ b/src/apps/cli/src/ui/permission.rs @@ -5,7 +5,6 @@ /// - Allow once: execute this tool call only /// - Allow always: auto-approve this tool type for the session /// - Reject: deny execution (optionally with a reason) - use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, @@ -15,8 +14,8 @@ use ratatui::{ Frame, }; -use super::theme::{Theme, StyleKind, tool_icon}; use super::string_utils::truncate_str; +use super::theme::{tool_icon, StyleKind, Theme}; // ============ Data Types ============ @@ -123,8 +122,10 @@ impl PermissionPrompt { fn handle_confirm_always_key(&mut self, key: KeyEvent) -> PermissionAction { match (key.code, key.modifiers) { - (KeyCode::Left, _) | (KeyCode::Right, _) - | (KeyCode::Char('h'), KeyModifiers::NONE) | (KeyCode::Char('l'), KeyModifiers::NONE) => { + (KeyCode::Left, _) + | (KeyCode::Right, _) + | (KeyCode::Char('h'), KeyModifiers::NONE) + | (KeyCode::Char('l'), KeyModifiers::NONE) => { self.selected_option = if self.selected_option == 0 { 1 } else { 0 }; PermissionAction::None } @@ -197,12 +198,7 @@ pub fn render_permission_overlay( } /// Render the main permission prompt (Allow once / Allow always / Reject) -fn render_permission_main( - frame: &mut Frame, - prompt: &PermissionPrompt, - theme: &Theme, - area: Rect, -) { +fn render_permission_main(frame: &mut Frame, prompt: &PermissionPrompt, theme: &Theme, area: Rect) { // Calculate overlay height based on content let overlay_height = 8u16.min(area.height.saturating_sub(2)); let overlay_area = Rect { @@ -237,7 +233,12 @@ fn render_permission_main( let mut lines = vec![ Line::from(vec![ Span::styled("\u{25b3} ", theme.style(StyleKind::Warning)), // △ - Span::styled("Permission required", Style::default().fg(theme.warning).add_modifier(Modifier::BOLD)), + Span::styled( + "Permission required", + Style::default() + .fg(theme.warning) + .add_modifier(Modifier::BOLD), + ), ]), Line::from(""), ]; @@ -250,8 +251,7 @@ fn render_permission_main( Span::styled(detail, Style::default()), ])); - let paragraph = Paragraph::new(lines) - .wrap(Wrap { trim: true }); + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }); frame.render_widget(paragraph, inner); // Button bar @@ -266,12 +266,7 @@ fn render_permission_main( } /// Render the "Confirm Always" stage -fn render_confirm_always( - frame: &mut Frame, - prompt: &PermissionPrompt, - theme: &Theme, - area: Rect, -) { +fn render_confirm_always(frame: &mut Frame, prompt: &PermissionPrompt, theme: &Theme, area: Rect) { let overlay_height = 6u16.min(area.height.saturating_sub(2)); let overlay_area = Rect { x: area.x, @@ -284,10 +279,7 @@ fn render_confirm_always( let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(2), - Constraint::Length(2), - ]) + .constraints([Constraint::Min(2), Constraint::Length(2)]) .split(overlay_area); let content_block = Block::default() @@ -301,11 +293,19 @@ fn render_confirm_always( let lines = vec![ Line::from(vec![ Span::styled("\u{25b3} ", theme.style(StyleKind::Warning)), - Span::styled("Always allow", Style::default().fg(theme.warning).add_modifier(Modifier::BOLD)), + Span::styled( + "Always allow", + Style::default() + .fg(theme.warning) + .add_modifier(Modifier::BOLD), + ), ]), Line::from(""), Line::from(Span::styled( - format!("This will auto-approve '{}' tool calls for this session.", prompt.tool_name), + format!( + "This will auto-approve '{}' tool calls for this session.", + prompt.tool_name + ), theme.style(StyleKind::Muted), )), ]; @@ -324,12 +324,7 @@ fn render_confirm_always( } /// Render the "Reject with reason" stage -fn render_reject_reason( - frame: &mut Frame, - prompt: &PermissionPrompt, - theme: &Theme, - area: Rect, -) { +fn render_reject_reason(frame: &mut Frame, prompt: &PermissionPrompt, theme: &Theme, area: Rect) { let overlay_height = 7u16.min(area.height.saturating_sub(2)); let overlay_area = Rect { x: area.x, @@ -342,10 +337,7 @@ fn render_reject_reason( let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(3), - Constraint::Length(2), - ]) + .constraints([Constraint::Min(3), Constraint::Length(2)]) .split(overlay_area); let content_block = Block::default() @@ -365,7 +357,12 @@ fn render_reject_reason( let lines = vec![ Line::from(vec![ Span::styled("\u{25b3} ", theme.style(StyleKind::Error)), - Span::styled("Reject permission", Style::default().fg(theme.error).add_modifier(Modifier::BOLD)), + Span::styled( + "Reject permission", + Style::default() + .fg(theme.error) + .add_modifier(Modifier::BOLD), + ), ]), Line::from(""), Line::from(Span::styled( @@ -386,8 +383,7 @@ fn render_reject_reason( frame.render_widget(paragraph, inner); // Bottom hint bar - let hint_block = Block::default() - .style(Style::default().bg(theme.background_element)); + let hint_block = Block::default().style(Style::default().bg(theme.background_element)); frame.render_widget(hint_block, chunks[1]); let hint = Paragraph::new(Line::from(vec![ @@ -410,8 +406,7 @@ fn render_button_bar( selected: usize, hint_text: &str, ) { - let bar_block = Block::default() - .style(Style::default().bg(theme.background_element)); + let bar_block = Block::default().style(Style::default().bg(theme.background_element)); frame.render_widget(bar_block, area); // Build button spans @@ -448,8 +443,7 @@ fn render_button_bar( } let line = Line::from(spans); - let paragraph = Paragraph::new(line) - .style(Style::default().bg(theme.background_element)); + let paragraph = Paragraph::new(line).style(Style::default().bg(theme.background_element)); frame.render_widget(paragraph, area); } @@ -457,42 +451,53 @@ fn render_button_bar( fn build_tool_detail(prompt: &PermissionPrompt) -> String { match prompt.tool_name.as_str() { "Bash" | "bash_tool" | "run_terminal_cmd" => { - let cmd = prompt.params.get("command") + let cmd = prompt + .params + .get("command") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - let desc = prompt.params.get("description") - .and_then(|v| v.as_str()); + let desc = prompt.params.get("description").and_then(|v| v.as_str()); match desc { Some(d) => format!("{}\n$ {}", d, cmd), None => format!("$ {}", cmd), } } "Edit" | "search_replace" => { - let path = prompt.params.get("file_path") + let path = prompt + .params + .get("file_path") .or_else(|| prompt.params.get("target_file")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); format!("Edit {}", path) } "Write" | "write_file" | "write_file_tool" => { - let path = prompt.params.get("file_path") + let path = prompt + .params + .get("file_path") .or_else(|| prompt.params.get("target_file")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); format!("Write {}", path) } "Delete" => { - let path = prompt.params.get("file_path") + let path = prompt + .params + .get("file_path") .or_else(|| prompt.params.get("path")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); format!("Delete {}", path) } "Task" => { - let desc = prompt.params.get("description") + let desc = prompt + .params + .get("description") .and_then(|v| v.as_str()) .unwrap_or("Task"); - let subagent = prompt.params.get("subagent_type") + let subagent = prompt + .params + .get("subagent_type") .and_then(|v| v.as_str()) .unwrap_or("unknown"); format!("{} Task: {}", subagent, desc) @@ -512,7 +517,15 @@ fn build_tool_detail(prompt: &PermissionPrompt) -> String { /// Extract the first meaningful string parameter from JSON fn extract_first_param(params: &serde_json::Value) -> String { if let Some(obj) = params.as_object() { - let priority = ["command", "path", "file_path", "query", "pattern", "url", "description"]; + let priority = [ + "command", + "path", + "file_path", + "query", + "pattern", + "url", + "description", + ]; for key in &priority { if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) { return v.to_string(); diff --git a/src/apps/cli/src/ui/question.rs b/src/apps/cli/src/ui/question.rs index dd9ed34c0d..75aa660a6a 100644 --- a/src/apps/cli/src/ui/question.rs +++ b/src/apps/cli/src/ui/question.rs @@ -7,7 +7,6 @@ /// - Multiple questions: Tab/Shift+Tab to switch, Confirm page at the end /// - Custom "Other" input: type your own answer /// - Number shortcuts: 1-9 to quick-pick - use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, @@ -17,7 +16,7 @@ use ratatui::{ Frame, }; -use super::theme::{Theme, StyleKind}; +use super::theme::{StyleKind, Theme}; // ============ Data Types ============ @@ -72,21 +71,35 @@ impl QuestionPrompt { let mut questions = Vec::new(); for q in questions_val { - let question = q.get("question").and_then(|v| v.as_str()) - .unwrap_or("").to_string(); - let header = q.get("header").and_then(|v| v.as_str()) - .unwrap_or("").to_string(); - let multi_select = q.get("multiSelect").and_then(|v| v.as_bool()) + let question = q + .get("question") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let header = q + .get("header") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let multi_select = q + .get("multiSelect") + .and_then(|v| v.as_bool()) .or_else(|| q.get("multi_select").and_then(|v| v.as_bool())) .unwrap_or(false); let mut options = Vec::new(); if let Some(opts) = q.get("options").and_then(|v| v.as_array()) { for opt in opts { - let label = opt.get("label").and_then(|v| v.as_str()) - .unwrap_or("").to_string(); - let description = opt.get("description").and_then(|v| v.as_str()) - .unwrap_or("").to_string(); + let label = opt + .get("label") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let description = opt + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); options.push(QuestionOption { label, description }); } } @@ -164,19 +177,25 @@ impl QuestionPrompt { let q = &self.questions[i]; // Replace "Other" with actual custom input let custom = &self.custom_inputs[i]; - let processed: Vec = answer_list.iter().map(|a| { - if a == "Other" && !custom.is_empty() { - custom.clone() - } else { - a.clone() - } - }).collect(); + let processed: Vec = answer_list + .iter() + .map(|a| { + if a == "Other" && !custom.is_empty() { + custom.clone() + } else { + a.clone() + } + }) + .collect(); if q.multi_select { map.insert( i.to_string(), serde_json::Value::Array( - processed.into_iter().map(serde_json::Value::String).collect(), + processed + .into_iter() + .map(serde_json::Value::String) + .collect(), ), ); } else { @@ -272,12 +291,8 @@ impl QuestionPrompt { /// Handle keys on the confirm/review page fn handle_confirm_key(&mut self, key: KeyEvent) -> QuestionAction { match (key.code, key.modifiers) { - (KeyCode::Enter, _) => { - QuestionAction::Submit(self.build_answers_payload()) - } - (KeyCode::Esc, _) => { - QuestionAction::Reject - } + (KeyCode::Enter, _) => QuestionAction::Submit(self.build_answers_payload()), + (KeyCode::Esc, _) => QuestionAction::Reject, // Navigate back to questions (KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => { self.current_tab = self.questions.len().saturating_sub(1); @@ -353,9 +368,7 @@ impl QuestionPrompt { } // Select / toggle - (KeyCode::Enter, _) => { - self.select_current_option() - } + (KeyCode::Enter, _) => self.select_current_option(), // Number shortcuts (1-9) (KeyCode::Char(c), KeyModifiers::NONE) if c.is_ascii_digit() && c != '0' => { @@ -432,12 +445,7 @@ pub fn render_question_overlay( } /// Render a single question page with options -fn render_question_page( - frame: &mut Frame, - prompt: &QuestionPrompt, - theme: &Theme, - area: Rect, -) { +fn render_question_page(frame: &mut Frame, prompt: &QuestionPrompt, theme: &Theme, area: Rect) { let q = match prompt.current_question() { Some(q) => q, None => return, @@ -445,7 +453,9 @@ fn render_question_page( // Calculate overlay height: header(1) + question(2) + options + other + hint(2) + padding let options_count = q.options.len() + 1; // +1 for "Other" - let description_lines: usize = q.options.iter() + let description_lines: usize = q + .options + .iter() .map(|o| if o.description.is_empty() { 0 } else { 1 }) .sum(); let tab_line = if prompt.tab_count() > 1 { 2 } else { 0 }; @@ -501,9 +511,11 @@ fn render_question_page( } else { tab_spans.push(Span::styled( format!(" {} ", qd.header), - Style::default().fg( - if is_answered { theme.success } else { theme.muted } - ), + Style::default().fg(if is_answered { + theme.success + } else { + theme.muted + }), )); } } @@ -526,7 +538,11 @@ fn render_question_page( } // Question text - let multi_hint = if q.multi_select { " (select all that apply)" } else { "" }; + let multi_hint = if q.multi_select { + " (select all that apply)" + } else { + "" + }; lines.push(Line::from(Span::styled( format!("{}{}", q.question, multi_hint), Style::default().add_modifier(Modifier::BOLD), @@ -553,9 +569,17 @@ fn render_question_page( }; let marker = if q.multi_select { - if is_picked { "[\u{2713}]" } else { "[ ]" } + if is_picked { + "[\u{2713}]" + } else { + "[ ]" + } } else { - if is_picked { "(\u{2022})" } else { "( )" } + if is_picked { + "(\u{2022})" + } else { + "( )" + } }; lines.push(Line::from(vec![ @@ -592,15 +616,27 @@ fn render_question_page( }; let other_marker = if q.multi_select { - if is_other_picked { "[\u{2713}]" } else { "[ ]" } + if is_other_picked { + "[\u{2713}]" + } else { + "[ ]" + } } else { - if is_other_picked { "(\u{2022})" } else { "( )" } + if is_other_picked { + "(\u{2022})" + } else { + "( )" + } }; lines.push(Line::from(vec![ Span::styled( format!("{}. ", other_idx + 1), - if is_other_active { theme.style(StyleKind::Primary) } else { theme.style(StyleKind::Muted) }, + if is_other_active { + theme.style(StyleKind::Primary) + } else { + theme.style(StyleKind::Muted) + }, ), Span::styled(format!("{} ", other_marker), other_style), Span::styled("Type your own answer", other_style), @@ -639,15 +675,11 @@ fn render_question_page( } /// Render the confirm/review page (multi-question) -fn render_confirm_page( - frame: &mut Frame, - prompt: &QuestionPrompt, - theme: &Theme, - area: Rect, -) { +fn render_confirm_page(frame: &mut Frame, prompt: &QuestionPrompt, theme: &Theme, area: Rect) { let content_height = 3 + prompt.questions.len(); // title + blank + questions + padding let tab_line = 2; - let overlay_height = ((content_height + tab_line) as u16 + 4).min(area.height.saturating_sub(2)); + let overlay_height = + ((content_height + tab_line) as u16 + 4).min(area.height.saturating_sub(2)); let overlay_area = Rect { x: area.x, @@ -660,10 +692,7 @@ fn render_confirm_page( let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(3), - Constraint::Length(2), - ]) + .constraints([Constraint::Min(3), Constraint::Length(2)]) .split(overlay_area); let content_block = Block::default() @@ -685,7 +714,11 @@ fn render_confirm_page( let is_answered = !prompt.answers[i].is_empty(); tab_spans.push(Span::styled( format!(" {} ", qd.header), - Style::default().fg(if is_answered { theme.success } else { theme.muted }), + Style::default().fg(if is_answered { + theme.success + } else { + theme.muted + }), )); } tab_spans.push(Span::raw(" ")); @@ -711,13 +744,16 @@ fn render_confirm_page( let answer_list = &prompt.answers[i]; let custom = &prompt.custom_inputs[i]; - let display_answers: Vec = answer_list.iter().map(|a| { - if a == "Other" && !custom.is_empty() { - custom.clone() - } else { - a.clone() - } - }).collect(); + let display_answers: Vec = answer_list + .iter() + .map(|a| { + if a == "Other" && !custom.is_empty() { + custom.clone() + } else { + a.clone() + } + }) + .collect(); let answered = !display_answers.is_empty(); let value_text = if answered { @@ -730,7 +766,11 @@ fn render_confirm_page( Span::styled(format!("{}: ", q.header), theme.style(StyleKind::Muted)), Span::styled( value_text, - if answered { Style::default() } else { theme.style(StyleKind::Error) }, + if answered { + Style::default() + } else { + theme.style(StyleKind::Error) + }, ), ])); } @@ -739,8 +779,7 @@ fn render_confirm_page( frame.render_widget(paragraph, inner); // Hint bar - let hint_block = Block::default() - .style(Style::default().bg(theme.background_element)); + let hint_block = Block::default().style(Style::default().bg(theme.background_element)); frame.render_widget(hint_block, chunks[1]); let hint = Paragraph::new(Line::from(vec![ @@ -757,14 +796,8 @@ fn render_confirm_page( } /// Render the hint bar for question pages -fn render_question_hint_bar( - frame: &mut Frame, - area: Rect, - theme: &Theme, - prompt: &QuestionPrompt, -) { - let hint_block = Block::default() - .style(Style::default().bg(theme.background_element)); +fn render_question_hint_bar(frame: &mut Frame, area: Rect, theme: &Theme, prompt: &QuestionPrompt) { + let hint_block = Block::default().style(Style::default().bg(theme.background_element)); frame.render_widget(hint_block, area); let mut spans = vec![Span::raw(" ")]; @@ -800,7 +833,6 @@ fn render_question_hint_bar( } let line = Line::from(spans); - let paragraph = Paragraph::new(line) - .style(Style::default().bg(theme.background_element)); + let paragraph = Paragraph::new(line).style(Style::default().bg(theme.background_element)); frame.render_widget(paragraph, area); } diff --git a/src/apps/cli/src/ui/session_selector.rs b/src/apps/cli/src/ui/session_selector.rs index 18be67b6f4..b4f69b3fe6 100644 --- a/src/apps/cli/src/ui/session_selector.rs +++ b/src/apps/cli/src/ui/session_selector.rs @@ -3,7 +3,6 @@ /// Overlay popup that displays all available sessions /// and allows the user to select one to switch to. /// Supports switching and deleting current-project sessions. - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, @@ -307,7 +306,10 @@ impl SessionSelectorState { spans.push(Span::styled(format!(" [{}]", short_ws), workspace_style)); } - spans.push(Span::styled(format!(" {}", session.last_activity), time_style)); + spans.push(Span::styled( + format!(" {}", session.last_activity), + time_style, + )); let line = Line::from(spans); ListItem::new(line) diff --git a/src/apps/cli/src/ui/skill_selector.rs b/src/apps/cli/src/ui/skill_selector.rs index 5d1bcba992..ac97bc2b37 100644 --- a/src/apps/cli/src/ui/skill_selector.rs +++ b/src/apps/cli/src/ui/skill_selector.rs @@ -298,14 +298,26 @@ impl SkillSelectorState { match self.screen { SkillSelectorScreen::Menu => vec![ ListItem::new(Line::from(vec![ - Span::styled("List skills", theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD)), + Span::styled( + "List skills", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), Span::raw(" "), - Span::styled("Show skills available to the current mode", theme.style(StyleKind::Muted)), + Span::styled( + "Show skills available to the current mode", + theme.style(StyleKind::Muted), + ), ])), ListItem::new(Line::from(vec![ - Span::styled("Enable/disable skills", theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD)), + Span::styled( + "Enable/disable skills", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), Span::raw(" "), - Span::styled("Toggle all discovered skills for this mode", theme.style(StyleKind::Muted)), + Span::styled( + "Toggle all discovered skills for this mode", + theme.style(StyleKind::Muted), + ), ])), ], SkillSelectorScreen::List => self @@ -360,7 +372,10 @@ impl SkillSelectorState { spans.push(Span::styled(format!("[{}] ", level_marker), level_style)); spans.push(Span::styled(skill.name.clone(), name_style)); if !status.is_empty() { - spans.push(Span::styled(status.to_string(), theme.style(StyleKind::Muted))); + spans.push(Span::styled( + status.to_string(), + theme.style(StyleKind::Muted), + )); } spans.push(Span::raw(" ")); spans.push(Span::styled(skill.description.clone(), desc_style)); diff --git a/src/apps/cli/src/ui/string_utils.rs b/src/apps/cli/src/ui/string_utils.rs index 1802b02ce4..8f8548d4e7 100644 --- a/src/apps/cli/src/ui/string_utils.rs +++ b/src/apps/cli/src/ui/string_utils.rs @@ -1,24 +1,23 @@ /// String processing utilities - use unicode_width::UnicodeWidthChar; /// Safely truncate string to specified byte length pub fn truncate_str(s: &str, max_bytes: usize) -> String { let first_line = s.lines().next().unwrap_or(""); - + if first_line.len() <= max_bytes { return first_line.to_string(); } - + let mut boundary = max_bytes; while boundary > 0 && !first_line.is_char_boundary(boundary) { boundary -= 1; } - + if boundary == 0 { return String::new(); } - + format!("{}...", &first_line[..boundary]) } @@ -118,4 +117,3 @@ pub fn wrap_to_display_width(s: &str, max_width: usize) -> Vec { lines } - diff --git a/src/apps/cli/src/ui/syntax_highlight.rs b/src/apps/cli/src/ui/syntax_highlight.rs index 62ba2ad1de..96b252a81f 100644 --- a/src/apps/cli/src/ui/syntax_highlight.rs +++ b/src/apps/cli/src/ui/syntax_highlight.rs @@ -2,7 +2,6 @@ /// /// Uses `syntect` for syntax analysis and `syntect-tui` to convert /// highlighted output into ratatui `Span`s. - use once_cell::sync::Lazy; use ratatui::{ style::Style, @@ -10,7 +9,7 @@ use ratatui::{ }; use syntect::{ easy::HighlightLines, - highlighting::{ThemeSet, Style as SyntectStyle}, + highlighting::{Style as SyntectStyle, ThemeSet}, parsing::SyntaxSet, util::LinesWithEndings, }; @@ -152,7 +151,10 @@ pub fn highlight_code_with_line_numbers<'a>( for (i, line_str) in LinesWithEndings::from(content).enumerate() { let line_num = i + 1; let mut spans = vec![ - Span::styled(format!("{:>width$}", line_num, width = num_width), line_num_style), + Span::styled( + format!("{:>width$}", line_num, width = num_width), + line_num_style, + ), Span::styled(" \u{2502} ", separator_style), // │ ]; diff --git a/src/apps/cli/src/ui/theme.rs b/src/apps/cli/src/ui/theme.rs index a15645ca90..044e2c6130 100644 --- a/src/apps/cli/src/ui/theme.rs +++ b/src/apps/cli/src/ui/theme.rs @@ -1,5 +1,4 @@ /// Theme and style definitions - use std::collections::{HashMap, HashSet}; use std::io::IsTerminal; use std::path::Path; @@ -95,7 +94,9 @@ fn terminal_supports_truecolor() -> bool { return false; } - let term = std::env::var("TERM").unwrap_or_default().to_ascii_lowercase(); + let term = std::env::var("TERM") + .unwrap_or_default() + .to_ascii_lowercase(); if term.is_empty() || term == "dumb" { return false; } @@ -107,7 +108,9 @@ fn terminal_supports_truecolor() -> bool { return false; } - let colorterm = std::env::var("COLORTERM").unwrap_or_default().to_ascii_lowercase(); + let colorterm = std::env::var("COLORTERM") + .unwrap_or_default() + .to_ascii_lowercase(); if colorterm.contains("truecolor") || colorterm.contains("24bit") { return true; } @@ -134,7 +137,9 @@ fn terminal_supports_truecolor() -> bool { pub fn resolve_appearance(preference: &str) -> Appearance { match preference.trim().to_ascii_lowercase().as_str() { "light" => Appearance::Light, - "auto" => detect_terminal_appearance(Duration::from_millis(250)).unwrap_or(Appearance::Dark), + "auto" => { + detect_terminal_appearance(Duration::from_millis(250)).unwrap_or(Appearance::Dark) + } _ => Appearance::Dark, } } @@ -208,7 +213,11 @@ fn detect_terminal_appearance(timeout: Duration) -> Option { let (r, g, b) = parse_osc_color(color)?; let lum = (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) / 255.0; - Some(if lum > 0.5 { Appearance::Light } else { Appearance::Dark }) + Some(if lum > 0.5 { + Appearance::Light + } else { + Appearance::Dark + }) } #[cfg(not(unix))] @@ -255,14 +264,14 @@ fn parse_osc_color(s: &str) -> Option<(u8, u8, u8)> { impl Theme { pub fn dark() -> Self { Self { - primary: Color::Rgb(59, 130, 246), // blue - success: Color::Rgb(34, 197, 94), // green - warning: Color::Rgb(251, 191, 36), // yellow - error: Color::Rgb(239, 68, 68), // red - info: Color::Rgb(147, 197, 253), // light blue - muted: Color::Rgb(156, 163, 175), // gray - background: Color::Rgb(17, 24, 39), // dark gray background - border: Color::Rgb(55, 65, 81), // border gray + primary: Color::Rgb(59, 130, 246), // blue + success: Color::Rgb(34, 197, 94), // green + warning: Color::Rgb(251, 191, 36), // yellow + error: Color::Rgb(239, 68, 68), // red + info: Color::Rgb(147, 197, 253), // light blue + muted: Color::Rgb(156, 163, 175), // gray + background: Color::Rgb(17, 24, 39), // dark gray background + border: Color::Rgb(55, 65, 81), // border gray background_panel: Color::Rgb(30, 38, 55), background_element: Color::Rgb(40, 50, 70), @@ -467,8 +476,12 @@ impl Theme { muted: resolved.muted.unwrap_or(fallback.muted), background: resolved.background.unwrap_or(fallback.background), border: resolved.border.unwrap_or(fallback.border), - background_panel: resolved.background_panel.unwrap_or(fallback.background_panel), - background_element: resolved.background_element.unwrap_or(fallback.background_element), + background_panel: resolved + .background_panel + .unwrap_or(fallback.background_panel), + background_element: resolved + .background_element + .unwrap_or(fallback.background_element), input_background: resolved.input_background.unwrap_or( resolved .background_element @@ -480,11 +493,17 @@ impl Theme { diff_removed_bg: resolved.diff_removed_bg.unwrap_or(fallback.diff_removed_bg), block_bg: resolved.block_bg.unwrap_or(fallback.block_bg), block_bg_hover: resolved.block_bg_hover.unwrap_or(fallback.block_bg_hover), - block_border_active: resolved.block_border_active.unwrap_or(fallback.block_border_active), + block_border_active: resolved + .block_border_active + .unwrap_or(fallback.block_border_active), inline_icon: resolved.inline_icon.unwrap_or(fallback.inline_icon), command_text: resolved.command_text.unwrap_or(fallback.command_text), - diff_hunk_header: resolved.diff_hunk_header.unwrap_or(fallback.diff_hunk_header), - diff_line_number: resolved.diff_line_number.unwrap_or(fallback.diff_line_number), + diff_hunk_header: resolved + .diff_hunk_header + .unwrap_or(fallback.diff_hunk_header), + diff_line_number: resolved + .diff_line_number + .unwrap_or(fallback.diff_line_number), }) } @@ -500,8 +519,12 @@ impl Theme { .fg(self.primary) .add_modifier(Modifier::BOLD), StyleKind::Border => Style::default().fg(self.border), - StyleKind::DiffAdded => Style::default().fg(self.diff_added_fg).bg(self.diff_added_bg), - StyleKind::DiffRemoved => Style::default().fg(self.diff_removed_fg).bg(self.diff_removed_bg), + StyleKind::DiffAdded => Style::default() + .fg(self.diff_added_fg) + .bg(self.diff_added_bg), + StyleKind::DiffRemoved => Style::default() + .fg(self.diff_removed_fg) + .bg(self.diff_removed_bg), StyleKind::BackgroundPanel => Style::default().bg(self.background_panel), StyleKind::BackgroundElement => Style::default().bg(self.background_element), StyleKind::BlockBackground => Style::default().bg(self.block_bg), @@ -522,9 +545,12 @@ impl Theme { fn readable_foreground_for(background: Color) -> Color { match background { Color::Reset => Color::Reset, - Color::Black | Color::Red | Color::Green | Color::Blue | Color::Magenta | Color::DarkGray => { - Color::White - } + Color::Black + | Color::Red + | Color::Green + | Color::Blue + | Color::Magenta + | Color::DarkGray => Color::White, Color::Yellow | Color::Gray | Color::LightRed @@ -538,7 +564,11 @@ fn readable_foreground_for(background: Color) -> Color { Color::Indexed(idx) => readable_foreground_for(idx_to_ansi16(idx)), Color::Rgb(r, g, b) => { let lum = relative_luminance(r, g, b); - if lum > 0.36 { Color::Black } else { Color::White } + if lum > 0.36 { + Color::Black + } else { + Color::White + } } } } @@ -671,30 +701,30 @@ pub enum StyleKind { pub fn tool_icon(tool_name: &str) -> &'static str { match tool_name { "Bash" | "bash_tool" | "run_terminal_cmd" => "$", - "Read" | "read_file" | "read_file_tool" => "\u{2192}", // → + "Read" | "read_file" | "read_file_tool" => "\u{2192}", // → "Write" | "write_file" | "write_file_tool" => "\u{2190}", // ← - "Edit" | "search_replace" => "\u{2190}", // ← - "Delete" => "\u{00d7}", // × - "Grep" | "grep" => "\u{2731}", // ✱ - "Glob" | "codebase_search" => "\u{2731}", // ✱ - "LS" | "list_dir" | "ls" => "\u{2192}", // → + "Edit" | "search_replace" => "\u{2190}", // ← + "Delete" => "\u{00d7}", // × + "Grep" | "grep" => "\u{2731}", // ✱ + "Glob" | "codebase_search" => "\u{2731}", // ✱ + "LS" | "list_dir" | "ls" => "\u{2192}", // → "WebFetch" => "%", - "WebSearch" => "\u{25c8}", // ◈ + "WebSearch" => "\u{25c8}", // ◈ "Task" => "#", "HmosCompilation" => "\u{2692}", - "TodoWrite" => "\u{2699}", // ⚙ - "Skill" => "\u{2192}", // → - "Git" => "\u{2387}", // ⎇ + "TodoWrite" => "\u{2699}", // ⚙ + "Skill" => "\u{2192}", // → + "Git" => "\u{2387}", // ⎇ "AskUserQuestion" => "?", - "CreatePlan" => "\u{25b6}", // ▶ - "ReadLints" => "\u{25b3}", // △ - "GetFileDiff" => "\u{00b1}", // ± - "IdeControl" => "\u{2318}", // ⌘ - "MermaidInteractive" => "\u{25c7}", // ◇ - "ContextCompression" => "\u{21af}", // ↯ - "AnalyzeImage" => "\u{25a3}", // ▣ - _ if tool_name.starts_with("mcp_") => "\u{2261}", // ≡ - _ => "\u{00b7}", // · + "CreatePlan" => "\u{25b6}", // ▶ + "ReadLints" => "\u{25b3}", // △ + "GetFileDiff" => "\u{00b1}", // ± + "IdeControl" => "\u{2318}", // ⌘ + "MermaidInteractive" => "\u{25c7}", // ◇ + "ContextCompression" => "\u{21af}", // ↯ + "AnalyzeImage" => "\u{25a3}", // ▣ + _ if tool_name.starts_with("mcp_") => "\u{2261}", // ≡ + _ => "\u{00b7}", // · } } @@ -729,7 +759,7 @@ pub fn load_opencode_theme_json(path: &Path) -> anyhow::Result> = Lazy::new(|| { - fn parse(id: &'static str, raw: &'static str) -> ( &'static str, OpencodeThemeJson) { + fn parse(id: &'static str, raw: &'static str) -> (&'static str, OpencodeThemeJson) { let json = serde_json::from_str::(raw) .unwrap_or_else(|e| panic!("Failed to parse built-in theme {}: {}", id, e)); (id, json) @@ -782,7 +812,10 @@ static BUILTIN_OPENCODE_THEMES: Lazy> = }); pub fn builtin_theme_ids() -> Vec { - let mut ids: Vec = BUILTIN_OPENCODE_THEMES.keys().map(|k| (*k).to_string()).collect(); + let mut ids: Vec = BUILTIN_OPENCODE_THEMES + .keys() + .map(|k| (*k).to_string()) + .collect(); ids.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase())); ids } @@ -817,8 +850,15 @@ struct ResolvedTokens { diff_line_number: Option, } -fn resolve_opencode_theme(json: &OpencodeThemeJson, appearance: Appearance) -> anyhow::Result { - let mode = if appearance.is_light() { "light" } else { "dark" }; +fn resolve_opencode_theme( + json: &OpencodeThemeJson, + appearance: Appearance, +) -> anyhow::Result { + let mode = if appearance.is_light() { + "light" + } else { + "dark" + }; let defs = json.defs.clone().unwrap_or_default(); let mut tokens = ResolvedTokens::default(); diff --git a/src/apps/cli/src/ui/theme_selector.rs b/src/apps/cli/src/ui/theme_selector.rs index 17ec32f1e6..eca9c589b3 100644 --- a/src/apps/cli/src/ui/theme_selector.rs +++ b/src/apps/cli/src/ui/theme_selector.rs @@ -1,7 +1,6 @@ /// Theme selector popup for choosing a UI theme /// /// Overlay popup that displays all available themes and allows the user to select one. - use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, diff --git a/src/apps/cli/src/ui/tool_cards.rs b/src/apps/cli/src/ui/tool_cards.rs index 7d2f32678b..86b32c8bd7 100644 --- a/src/apps/cli/src/ui/tool_cards.rs +++ b/src/apps/cli/src/ui/tool_cards.rs @@ -4,7 +4,6 @@ /// - InlineTool: single-line for simple/exploratory tools (Read, Grep, Glob, LS, etc.) /// - BlockTool: multi-line with left border for complex tools (Bash, Edit, Write, Task, etc.) /// - Phase-aware: same tool can switch from Inline (pending) to Block (has output) - use std::collections::HashMap; use ratatui::{ @@ -13,11 +12,11 @@ use ratatui::{ widgets::ListItem, }; -use crate::chat_state::{ToolDisplayState, ToolDisplayStatus}; use super::diff_render::{self, DiffViewMode}; +use super::string_utils::{strip_ansi_codes, truncate_str, wrap_to_display_width}; use super::syntax_highlight::{self, HighlightTheme}; -use super::theme::{Theme, StyleKind, tool_icon}; -use super::string_utils::{truncate_str, strip_ansi_codes, wrap_to_display_width}; +use super::theme::{tool_icon, StyleKind, Theme}; +use crate::chat_state::{ToolDisplayState, ToolDisplayStatus}; // ============ Tool Card Render Cache ============ @@ -76,15 +75,17 @@ enum ToolDisplayMode { fn tool_display_mode(tool_name: &str, tool_state: &ToolDisplayState) -> ToolDisplayMode { match normalize_tool_name(tool_name) { // Always inline tools - "Read" | "Grep" | "Glob" | "LS" | "WebSearch" | "WebFetch" - | "Skill" | "ReadLints" | "Git" | "GetFileDiff" - | "IdeControl" | "MermaidInteractive" | "ContextCompression" + "Read" | "Grep" | "Glob" | "LS" | "WebSearch" | "WebFetch" | "Skill" | "ReadLints" + | "Git" | "GetFileDiff" | "IdeControl" | "MermaidInteractive" | "ContextCompression" | "AnalyzeImage" => ToolDisplayMode::Inline, // Phase-aware: Inline when pending, Block when has output/result "Bash" => { if tool_state.result.is_some() - || matches!(tool_state.status, ToolDisplayStatus::Running | ToolDisplayStatus::Streaming) + || matches!( + tool_state.status, + ToolDisplayStatus::Running | ToolDisplayStatus::Streaming + ) { ToolDisplayMode::Block } else { @@ -94,7 +95,9 @@ fn tool_display_mode(tool_name: &str, tool_state: &ToolDisplayState) -> ToolDisp "HmosCompilation" => { if matches!( tool_state.status, - ToolDisplayStatus::Running | ToolDisplayStatus::Streaming | ToolDisplayStatus::Failed + ToolDisplayStatus::Running + | ToolDisplayStatus::Streaming + | ToolDisplayStatus::Failed ) || tool_state.result.is_some() { ToolDisplayMode::Block @@ -171,9 +174,7 @@ pub fn render_tool_card( focused, width: available_width, }; - let cached = TOOL_CARD_CACHE.with(|cache| { - cache.borrow().get(&key).cloned() - }); + let cached = TOOL_CARD_CACHE.with(|cache| cache.borrow().get(&key).cloned()); if let Some(rendered) = cached { return rendered; } @@ -195,7 +196,14 @@ pub fn render_tool_card( } // Non-terminal tools: render without caching - render_tool_card_inner(tool_state, theme, expanded, focused, spinner_frame, available_width) + render_tool_card_inner( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ) } /// Internal render function (no caching) @@ -282,7 +290,10 @@ fn render_inline_dispatch( let icon = tool_icon(&tool_state.tool_name); let is_complete = matches!( tool_state.status, - ToolDisplayStatus::Success | ToolDisplayStatus::Failed | ToolDisplayStatus::Rejected | ToolDisplayStatus::Cancelled + ToolDisplayStatus::Success + | ToolDisplayStatus::Failed + | ToolDisplayStatus::Rejected + | ToolDisplayStatus::Cancelled ); let is_error = matches!(tool_state.status, ToolDisplayStatus::Failed); let is_rejected = matches!(tool_state.status, ToolDisplayStatus::Rejected); @@ -294,7 +305,10 @@ fn render_inline_dispatch( return ToolCardRenderOutput { items: vec![ListItem::new(Line::from(vec![ Span::raw(" ".to_string()), - Span::styled(format!("{} ", spinner_frame), theme.style(StyleKind::Primary)), + Span::styled( + format!("{} ", spinner_frame), + theme.style(StyleKind::Primary), + ), Span::styled(pending_text.clone(), theme.style(StyleKind::Muted)), ]))], plain_lines: vec![format!(" {} {}", spinner_frame, pending_text)], @@ -314,7 +328,9 @@ fn render_inline_dispatch( let content_style = if is_error { theme.style(StyleKind::Error) } else if is_rejected { - theme.style(StyleKind::Error).add_modifier(Modifier::CROSSED_OUT) + theme + .style(StyleKind::Error) + .add_modifier(Modifier::CROSSED_OUT) } else if is_confirmation { theme.style(StyleKind::Warning) } else { @@ -409,7 +425,11 @@ fn inline_pending_text(canonical: &str, tool_state: &ToolDisplayState) -> String if tool_state.tool_name.starts_with("mcp_") { // Parse mcp_{server}_{tool} to show a cleaner name let parts: Vec<&str> = tool_state.tool_name.splitn(3, '_').collect(); - let tool = if parts.len() >= 3 { parts[2] } else { &tool_state.tool_name }; + let tool = if parts.len() >= 3 { + parts[2] + } else { + &tool_state.tool_name + }; if let Some(ref msg) = tool_state.progress_message { msg.clone() } else { @@ -428,7 +448,10 @@ fn inline_pending_text(canonical: &str, tool_state: &ToolDisplayState) -> String fn inline_complete_text(canonical: &str, tool_state: &ToolDisplayState) -> String { match canonical { "Read" => { - let path = param_str(&tool_state.parameters, &["file_path", "target_file", "path"]); + let path = param_str( + &tool_state.parameters, + &["file_path", "target_file", "path"], + ); format!("Read {}", path) } "Grep" => { @@ -452,7 +475,10 @@ fn inline_complete_text(canonical: &str, tool_state: &ToolDisplayState) -> Strin text } "Glob" => { - let pattern = param_str(&tool_state.parameters, &["glob_pattern", "pattern", "query"]); + let pattern = param_str( + &tool_state.parameters, + &["glob_pattern", "pattern", "query"], + ); let count = tool_state .metadata .as_ref() @@ -521,7 +547,10 @@ fn inline_complete_text(canonical: &str, tool_state: &ToolDisplayState) -> Strin format!("Analyze Image {}", path) } "HmosCompilation" => { - let path = param_str(&tool_state.parameters, &["project_abs_path", "project_path"]); + let path = param_str( + &tool_state.parameters, + &["project_abs_path", "project_path"], + ); if path.is_empty() { "HarmonyOS Compile".to_string() } else { @@ -569,16 +598,57 @@ fn render_block_dispatch( available_width: u16, ) -> ToolCardRenderOutput { match canonical { - "Bash" => render_bash_block(tool_state, theme, expanded, focused, spinner_frame, available_width), - "Edit" => render_edit_block(tool_state, theme, expanded, focused, spinner_frame, available_width), - "Write" => render_write_block(tool_state, theme, expanded, focused, spinner_frame, available_width), + "Bash" => render_bash_block( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ), + "Edit" => render_edit_block( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ), + "Write" => render_write_block( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ), "Delete" => render_delete_block(tool_state, theme, focused, spinner_frame, available_width), "Task" => render_task_block(tool_state, theme, focused, spinner_frame, available_width), - "TodoWrite" => render_todo_block(tool_state, theme, focused, spinner_frame, available_width), - "AskUserQuestion" => render_question_block(tool_state, theme, focused, spinner_frame, available_width), - "CreatePlan" => render_plan_block(tool_state, theme, focused, spinner_frame, available_width), - "HmosCompilation" => render_hmos_compilation_block(tool_state, theme, expanded, focused, spinner_frame, available_width), - _ => render_generic_block(tool_state, theme, expanded, focused, spinner_frame, available_width), + "TodoWrite" => { + render_todo_block(tool_state, theme, focused, spinner_frame, available_width) + } + "AskUserQuestion" => { + render_question_block(tool_state, theme, focused, spinner_frame, available_width) + } + "CreatePlan" => { + render_plan_block(tool_state, theme, focused, spinner_frame, available_width) + } + "HmosCompilation" => render_hmos_compilation_block( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ), + _ => render_generic_block( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ), } } @@ -631,16 +701,19 @@ fn render_hmos_compilation_block( ToolDisplayStatus::Running | ToolDisplayStatus::Streaming ); - let project_path = param_str_opt(&tool_state.parameters, &["project_abs_path", "project_path"]) - .or_else(|| { - tool_state - .metadata - .as_ref() - .and_then(|m| m.get("project_path")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }) - .unwrap_or_default(); + let project_path = param_str_opt( + &tool_state.parameters, + &["project_abs_path", "project_path"], + ) + .or_else(|| { + tool_state + .metadata + .as_ref() + .and_then(|m| m.get("project_path")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_default(); let product = param_str_opt(&tool_state.parameters, &["product"]) .or_else(|| { @@ -663,7 +736,17 @@ fn render_hmos_compilation_block( }) .unwrap_or_else(|| "debug".to_string()); - let (success, exit_code, execution_time_ms, deveco_home, stderr, stdout, error_kind, error_message, help) = tool_state + let ( + success, + exit_code, + execution_time_ms, + deveco_home, + stderr, + stdout, + error_kind, + error_message, + help, + ) = tool_state .metadata .as_ref() .and_then(|m| m.as_object()) @@ -693,7 +776,10 @@ fn render_hmos_compilation_block( .get("error_message") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let help = obj.get("help").and_then(|v| v.as_str()).map(|s| s.to_string()); + let help = obj + .get("help") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); ( success, exit_code, @@ -706,7 +792,17 @@ fn render_hmos_compilation_block( help, ) }) - .unwrap_or((None, None, None, None, String::new(), String::new(), None, None, None)); + .unwrap_or(( + None, + None, + None, + None, + String::new(), + String::new(), + None, + None, + None, + )); let mut title = "HarmonyOS Compile".to_string(); if !project_path.is_empty() { @@ -878,7 +974,10 @@ fn render_hmos_compilation_block( } if wrapped.len() > max { content_lines.push(Line::from(Span::styled( - format!("\u{25bc} {} more lines (Tab/Click to expand)", wrapped.len() - max), + format!( + "\u{25bc} {} more lines (Tab/Click to expand)", + wrapped.len() - max + ), theme.style(StyleKind::Muted), ))); } @@ -957,7 +1056,9 @@ fn render_bash_block( let mut content_lines = vec![Line::from(cmd_spans)]; // Extract output: prefer metadata.output (structured), fallback to result (display summary) - let output_text = tool_state.metadata.as_ref() + let output_text = tool_state + .metadata + .as_ref() .and_then(|m| m.get("output")) .and_then(|v| v.as_str()) .map(|s| s.to_string()) @@ -982,7 +1083,10 @@ fn render_bash_block( if output_lines.len() > 10 && !expanded { content_lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more lines, Ctrl+O to expand)", output_lines.len() - 10), + format!( + "\u{2026} ({} more lines, Ctrl+O to expand)", + output_lines.len() - 10 + ), theme.style(StyleKind::Muted), ))); } else if expanded && output_lines.len() > 10 { @@ -1000,7 +1104,17 @@ fn render_bash_block( None }; - assemble_block(&title, content_lines, theme, is_running, error, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title, + content_lines, + theme, + is_running, + error, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render an Edit tool as a block (file path + diff preview) @@ -1012,13 +1126,22 @@ fn render_edit_block( spinner_frame: &str, available_width: u16, ) -> ToolCardRenderOutput { - let file_path = param_str(&tool_state.parameters, &["file_path", "target_file", "path"]); + let file_path = param_str( + &tool_state.parameters, + &["file_path", "target_file", "path"], + ); let mut content_lines = Vec::new(); // Try to show diff from old_string/new_string parameters - let old_str = tool_state.parameters.get("old_string").and_then(|v| v.as_str()); - let new_str = tool_state.parameters.get("new_string").and_then(|v| v.as_str()); + let old_str = tool_state + .parameters + .get("old_string") + .and_then(|v| v.as_str()); + let new_str = tool_state + .parameters + .get("new_string") + .and_then(|v| v.as_str()); // Compute stats for title let (additions, deletions) = match (old_str, new_str) { @@ -1037,14 +1160,8 @@ fn render_edit_block( let max = if expanded { usize::MAX } else { 8 }; // Use the block's available width minus border overhead (~8 chars) let diff_width = available_width.saturating_sub(8); - let diff_lines = diff_render::render_diff( - old, - new, - theme, - max, - DiffViewMode::Auto, - diff_width, - ); + let diff_lines = + diff_render::render_diff(old, new, theme, max, DiffViewMode::Auto, diff_width); content_lines.extend(diff_lines); let total_changes = additions + deletions; @@ -1080,7 +1197,17 @@ fn render_edit_block( None }; - assemble_block(&title, content_lines, theme, false, error, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title, + content_lines, + theme, + false, + error, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render a Write tool as a block (file path + syntax-highlighted content preview) @@ -1092,12 +1219,17 @@ fn render_write_block( spinner_frame: &str, available_width: u16, ) -> ToolCardRenderOutput { - let file_path = param_str(&tool_state.parameters, &["file_path", "target_file", "path"]); + let file_path = param_str( + &tool_state.parameters, + &["file_path", "target_file", "path"], + ); let mut content_lines = Vec::new(); // Show content preview with syntax highlighting and line numbers - if let Some(content) = tool_state.parameters.get("contents") + if let Some(content) = tool_state + .parameters + .get("contents") .or_else(|| tool_state.parameters.get("content")) .and_then(|v| v.as_str()) { @@ -1121,7 +1253,10 @@ fn render_write_block( if total_lines > 8 && !expanded { content_lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more lines, Ctrl+O to expand)", total_lines - 8), + format!( + "\u{2026} ({} more lines, Ctrl+O to expand)", + total_lines - 8 + ), theme.style(StyleKind::Muted), ))); } else if expanded && total_lines > 8 { @@ -1152,7 +1287,17 @@ fn render_write_block( None }; - return assemble_block(&title, content_lines, theme, false, error, focused, tool_state, spinner_frame, available_width); + return assemble_block( + &title, + content_lines, + theme, + false, + error, + focused, + tool_state, + spinner_frame, + available_width, + ); } // Fallback: no content available @@ -1176,7 +1321,17 @@ fn render_write_block( None }; - assemble_block(&title, content_lines, theme, false, error, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title, + content_lines, + theme, + false, + error, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render a Delete tool as a block @@ -1187,7 +1342,10 @@ fn render_delete_block( spinner_frame: &str, available_width: u16, ) -> ToolCardRenderOutput { - let file_path = param_str(&tool_state.parameters, &["file_path", "target_file", "path"]); + let file_path = param_str( + &tool_state.parameters, + &["file_path", "target_file", "path"], + ); let title = format!("Delete {}", file_path); let mut content_lines = Vec::new(); @@ -1209,7 +1367,17 @@ fn render_delete_block( None }; - assemble_block(&title, content_lines, theme, false, error, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title, + content_lines, + theme, + false, + error, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render a Task tool as a block (sub-agent type + description + real-time progress) @@ -1242,16 +1410,18 @@ fn render_task_block( description.clone() }; - let mut content_lines = vec![ - Line::from(Span::styled(desc_text, theme.style(StyleKind::Muted))), - ]; + let mut content_lines = vec![Line::from(Span::styled( + desc_text, + theme.style(StyleKind::Muted), + ))]; // Show real-time subagent progress (current tool being executed) if is_running { if let Some(ref progress) = tool_state.subagent_progress { if let Some(ref tool_name) = progress.current_tool_name { let progress_text = if let Some(ref title) = progress.current_tool_title { - format!("\u{2514} {} {}", capitalize_first(tool_name), title) // └ + format!("\u{2514} {} {}", capitalize_first(tool_name), title) + // └ } else { format!("\u{2514} {}", capitalize_first(tool_name)) // └ }; @@ -1265,7 +1435,9 @@ fn render_task_block( // Show final result when completed if let Some(ref result) = tool_state.result { - let max_width = block_content_max_width(available_width).saturating_sub(2).max(1); + let max_width = block_content_max_width(available_width) + .saturating_sub(2) + .max(1); for line in wrap_display_lines(result, max_width) { content_lines.push(Line::from(Span::styled( format!("\u{2514} {}", line), // └ @@ -1274,7 +1446,17 @@ fn render_task_block( } } - assemble_block(&title, content_lines, theme, is_running, None, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title, + content_lines, + theme, + is_running, + None, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render a TodoWrite tool as a block (todo list with upgraded icons) @@ -1287,16 +1469,25 @@ fn render_todo_block( ) -> ToolCardRenderOutput { let mut content_lines = Vec::new(); - if let Some(todos) = tool_state.parameters.get("todos").and_then(|v| v.as_array()) { + if let Some(todos) = tool_state + .parameters + .get("todos") + .and_then(|v| v.as_array()) + { for todo in todos { - let status = todo.get("status").and_then(|v| v.as_str()).unwrap_or("pending"); + let status = todo + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("pending"); let content = todo.get("content").and_then(|v| v.as_str()).unwrap_or(""); let (marker, marker_style, content_style) = match status { "completed" => ( "\u{2713}", // ✓ theme.style(StyleKind::Success), - theme.style(StyleKind::Muted).add_modifier(Modifier::CROSSED_OUT), + theme + .style(StyleKind::Muted) + .add_modifier(Modifier::CROSSED_OUT), ), "in_progress" => ( "\u{25cf}", // ● @@ -1306,7 +1497,9 @@ fn render_todo_block( "cancelled" => ( "\u{2014}", // — theme.style(StyleKind::Muted), - theme.style(StyleKind::Muted).add_modifier(Modifier::CROSSED_OUT), + theme + .style(StyleKind::Muted) + .add_modifier(Modifier::CROSSED_OUT), ), _ => ( "\u{25cb}", // ○ @@ -1323,10 +1516,23 @@ fn render_todo_block( } if content_lines.is_empty() { - content_lines.push(Line::from(Span::styled("Updating todos...", theme.style(StyleKind::Muted)))); + content_lines.push(Line::from(Span::styled( + "Updating todos...", + theme.style(StyleKind::Muted), + ))); } - assemble_block("Todos", content_lines, theme, false, None, focused, tool_state, spinner_frame, available_width) + assemble_block( + "Todos", + content_lines, + theme, + false, + None, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render an AskUserQuestion tool as a block @@ -1349,7 +1555,8 @@ fn render_question_block( let answer_text = match val { serde_json::Value::String(s) => s.clone(), serde_json::Value::Array(arr) => { - let items: Vec = arr.iter() + let items: Vec = arr + .iter() .filter_map(|v| v.as_str().map(String::from)) .collect(); items.join(", ") @@ -1369,7 +1576,10 @@ fn render_question_block( } for line in wrapped_answers.iter().skip(1) { content_lines.push(Line::from(vec![ - Span::styled(" ".repeat(key_prefix.len()), theme.style(StyleKind::Muted)), + Span::styled( + " ".repeat(key_prefix.len()), + theme.style(StyleKind::Muted), + ), Span::styled(line.clone(), theme.style(StyleKind::Success)), ])); } @@ -1397,7 +1607,11 @@ fn render_question_block( ))); } } else if tool_state.status == ToolDisplayStatus::Running { - if let Some(questions) = tool_state.parameters.get("questions").and_then(|v| v.as_array()) { + if let Some(questions) = tool_state + .parameters + .get("questions") + .and_then(|v| v.as_array()) + { for q in questions { let question_text = q.get("question").and_then(|v| v.as_str()).unwrap_or("?"); content_lines.push(Line::from(Span::styled( @@ -1411,12 +1625,21 @@ fn render_question_block( theme.style(StyleKind::Warning), ))); } else { - if let Some(questions) = tool_state.parameters.get("questions").and_then(|v| v.as_array()) { + if let Some(questions) = tool_state + .parameters + .get("questions") + .and_then(|v| v.as_array()) + { for q in questions { - let prompt = q.get("question").and_then(|v| v.as_str()) + let prompt = q + .get("question") + .and_then(|v| v.as_str()) .or_else(|| q.get("prompt").and_then(|v| v.as_str())) .unwrap_or("?"); - content_lines.push(Line::from(Span::styled(prompt.to_string(), theme.style(StyleKind::Info)))); + content_lines.push(Line::from(Span::styled( + prompt.to_string(), + theme.style(StyleKind::Info), + ))); if let Some(options) = q.get("options").and_then(|v| v.as_array()) { for opt in options { @@ -1432,11 +1655,24 @@ fn render_question_block( } if content_lines.is_empty() { - content_lines.push(Line::from(Span::styled("Asking questions...", theme.style(StyleKind::Muted)))); + content_lines.push(Line::from(Span::styled( + "Asking questions...", + theme.style(StyleKind::Muted), + ))); } } - assemble_block("Questions", content_lines, theme, false, None, focused, tool_state, spinner_frame, available_width) + assemble_block( + "Questions", + content_lines, + theme, + false, + None, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render a CreatePlan tool as a block @@ -1452,9 +1688,14 @@ fn render_plan_block( let title_text = param_str_opt(&tool_state.parameters, &["title", "name"]) .unwrap_or_else(|| "Plan".to_string()); - if let Some(steps) = tool_state.parameters.get("steps").and_then(|v| v.as_array()) { + if let Some(steps) = tool_state + .parameters + .get("steps") + .and_then(|v| v.as_array()) + { for (i, step) in steps.iter().enumerate() { - let desc = step.as_str() + let desc = step + .as_str() .or_else(|| step.get("description").and_then(|v| v.as_str())) .unwrap_or("..."); content_lines.push(Line::from(vec![ @@ -1467,15 +1708,31 @@ fn render_plan_block( if let Some(ref result) = tool_state.result { let max_width = block_content_max_width(available_width); for line in wrap_display_lines(result, max_width) { - content_lines.push(Line::from(Span::styled(line, theme.style(StyleKind::Success)))); + content_lines.push(Line::from(Span::styled( + line, + theme.style(StyleKind::Success), + ))); } } if content_lines.is_empty() { - content_lines.push(Line::from(Span::styled("Creating plan...", theme.style(StyleKind::Muted)))); + content_lines.push(Line::from(Span::styled( + "Creating plan...", + theme.style(StyleKind::Muted), + ))); } - assemble_block(&title_text, content_lines, theme, false, None, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title_text, + content_lines, + theme, + false, + None, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render a generic block tool (fallback for unknown block tools) @@ -1492,12 +1749,18 @@ fn render_generic_block( let summary = extract_key_params(&tool_state.parameters); if !summary.is_empty() { - content_lines.push(Line::from(Span::styled(summary, theme.style(StyleKind::Info)))); + content_lines.push(Line::from(Span::styled( + summary, + theme.style(StyleKind::Info), + ))); } if let Some(ref msg) = tool_state.progress_message { for line in wrap_display_lines(msg, block_content_max_width(available_width)) { - content_lines.push(Line::from(Span::styled(line, theme.style(StyleKind::Muted)))); + content_lines.push(Line::from(Span::styled( + line, + theme.style(StyleKind::Muted), + ))); } } @@ -1509,7 +1772,10 @@ fn render_generic_block( } if lines.len() > max { content_lines.push(Line::from(Span::styled( - format!("\u{25bc} {} more lines (Tab/Click to expand)", lines.len() - max), + format!( + "\u{25bc} {} more lines (Tab/Click to expand)", + lines.len() - max + ), theme.style(StyleKind::Muted), ))); } @@ -1525,7 +1791,17 @@ fn render_generic_block( None }; - assemble_block(&title, content_lines, theme, is_running, error, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title, + content_lines, + theme, + is_running, + error, + focused, + tool_state, + spinner_frame, + available_width, + ) } // ============ Block Assembly ============ @@ -1571,16 +1847,20 @@ fn assemble_block( theme.style(StyleKind::BlockBackground) }; - let (status_icon, status_style) = status_icon_and_style(&tool_state.status, theme, spinner_frame); + let (status_icon, status_style) = + status_icon_and_style(&tool_state.status, theme, spinner_frame); // Duration text - let duration_text = tool_state.duration_ms.map(|ms| { - if ms < 1000 { - format!("{}ms", ms) - } else { - format!("{:.1}s", ms as f64 / 1000.0) - } - }).unwrap_or_default(); + let duration_text = tool_state + .duration_ms + .map(|ms| { + if ms < 1000 { + format!("{}ms", ms) + } else { + format!("{:.1}s", ms as f64 / 1000.0) + } + }) + .unwrap_or_default(); // Box dimensions: // Layout: " ╭─...─╮" => 2 (left margin) + 1 (corner) + inner_width (horizontal lines) + 1 (corner) @@ -1593,37 +1873,39 @@ fn assemble_block( // Helper: build a padded line inside the box. // Returns: " │" + content_spans + padding + "│" // Content is expected to be pre-wrapped by callers; this layer should not truncate. - let build_box_line = |content_spans: Vec>, - bs: Style, - bgs: Style| - -> (ListItem<'static>, String) { - let used_width: usize = content_spans - .iter() - .map(|span| unicode_display_width(span.content.as_ref())) - .sum(); - let pad = inner_w.saturating_sub(used_width); - let content_plain = content_spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(); - - let mut spans = Vec::with_capacity(content_spans.len() + 4); - spans.push(Span::styled(" \u{2502}".to_string(), bs)); // " │" - spans.extend(content_spans); - if pad > 0 { - spans.push(Span::styled(" ".repeat(pad), bgs)); - } - spans.push(Span::styled("\u{2502}".to_string(), bs)); // "│" - let plain = format!(" │{}{}│", content_plain, " ".repeat(pad)); - - (ListItem::new(Line::from(spans)).style(bgs), plain) - }; + let build_box_line = + |content_spans: Vec>, bs: Style, bgs: Style| -> (ListItem<'static>, String) { + let used_width: usize = content_spans + .iter() + .map(|span| unicode_display_width(span.content.as_ref())) + .sum(); + let pad = inner_w.saturating_sub(used_width); + let content_plain = content_spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + let mut spans = Vec::with_capacity(content_spans.len() + 4); + spans.push(Span::styled(" \u{2502}".to_string(), bs)); // " │" + spans.extend(content_spans); + if pad > 0 { + spans.push(Span::styled(" ".repeat(pad), bgs)); + } + spans.push(Span::styled("\u{2502}".to_string(), bs)); // "│" + let plain = format!(" │{}{}│", content_plain, " ".repeat(pad)); + + (ListItem::new(Line::from(spans)).style(bgs), plain) + }; // ── Top border: " ╭─────...─────╮" let horiz_len = if inner_w > 0 { inner_w } else { 1 }; let top_line = format!(" \u{256D}{}\u{256E}", "\u{2500}".repeat(horiz_len)); items.push( - ListItem::new(Line::from(vec![Span::styled(top_line.clone(), border_style)])).style(bg_style), + ListItem::new(Line::from(vec![Span::styled( + top_line.clone(), + border_style, + )])) + .style(bg_style), ); plain_lines.push(top_line); @@ -1682,7 +1964,11 @@ fn assemble_block( // ── Bottom border: " ╰─────...─────╯" let bottom_line = format!(" \u{2570}{}\u{256F}", "\u{2500}".repeat(horiz_len)); items.push( - ListItem::new(Line::from(vec![Span::styled(bottom_line.clone(), border_style)])).style(bg_style), + ListItem::new(Line::from(vec![Span::styled( + bottom_line.clone(), + border_style, + )])) + .style(bg_style), ); plain_lines.push(bottom_line); @@ -1707,8 +1993,8 @@ fn status_icon_and_style( (spinner_frame.to_string(), theme.style(StyleKind::Primary)) } ToolDisplayStatus::Success => ("\u{2713}".to_string(), theme.style(StyleKind::Success)), // ✓ - ToolDisplayStatus::Failed => ("\u{2717}".to_string(), theme.style(StyleKind::Error)), // ✗ - ToolDisplayStatus::Queued => ("\u{2016}".to_string(), theme.style(StyleKind::Muted)), // ‖ + ToolDisplayStatus::Failed => ("\u{2717}".to_string(), theme.style(StyleKind::Error)), // ✗ + ToolDisplayStatus::Queued => ("\u{2016}".to_string(), theme.style(StyleKind::Muted)), // ‖ ToolDisplayStatus::Waiting => ("\u{2026}".to_string(), theme.style(StyleKind::Warning)), // … ToolDisplayStatus::EarlyDetected | ToolDisplayStatus::ParamsPartial => { (spinner_frame.to_string(), theme.style(StyleKind::Muted)) @@ -1746,7 +2032,16 @@ fn param_str_opt(params: &serde_json::Value, keys: &[&str]) -> Option { /// Extract a key parameter summary from JSON params fn extract_key_params(params: &serde_json::Value) -> String { if let Some(obj) = params.as_object() { - let priority_keys = ["path", "file_path", "target_file", "query", "pattern", "command", "message", "url"]; + let priority_keys = [ + "path", + "file_path", + "target_file", + "query", + "pattern", + "command", + "message", + "url", + ]; for key in &priority_keys { if let Some(value) = obj.get(*key) { diff --git a/src/apps/cli/src/ui/widgets.rs b/src/apps/cli/src/ui/widgets.rs index b2fa999a4a..446ec18ff8 100644 --- a/src/apps/cli/src/ui/widgets.rs +++ b/src/apps/cli/src/ui/widgets.rs @@ -1,5 +1,4 @@ /// Custom TUI widgets - use ratatui::{ layout::Rect, style::{Color, Style}, @@ -33,7 +32,12 @@ impl Spinner { pub fn render_info_popup(frame: &mut Frame, area: Rect, message: &str, accent: Color) { let lines: Vec = message .lines() - .map(|l| Line::from(Span::styled(l.to_string(), Style::default().fg(Color::White)))) + .map(|l| { + Line::from(Span::styled( + l.to_string(), + Style::default().fg(Color::White), + )) + }) .collect(); let line_count = lines.len() as u16; @@ -44,7 +48,9 @@ pub fn render_info_popup(frame: &mut Frame, area: Rect, message: &str, accent: C .unwrap_or(20); // +2 for border, +2 for padding; +1 for hint line below popup - let popup_width = (max_line_width + 4).min(area.width.saturating_sub(4)).max(30); + let popup_width = (max_line_width + 4) + .min(area.width.saturating_sub(4)) + .max(30); let popup_height = (line_count + 2).min(area.height.saturating_sub(3)); let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 5856688936..227c2282df 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -1810,24 +1810,22 @@ pub async fn cleanup_invalid_workspaces( } async fn prune_unrecoverable_remote_workspaces(state: &State<'_, AppState>) -> usize { - let saved_connection_ids: std::collections::HashSet = match state - .get_ssh_manager_async() - .await - { - Ok(manager) => manager - .get_saved_connections() - .await - .into_iter() - .map(|connection| connection.id) - .collect(), - Err(error) => { - warn!( - "Skipping remote workspace cleanup because SSH manager is unavailable: {}", - error - ); - return 0; - } - }; + let saved_connection_ids: std::collections::HashSet = + match state.get_ssh_manager_async().await { + Ok(manager) => manager + .get_saved_connections() + .await + .into_iter() + .map(|connection| connection.id) + .collect(), + Err(error) => { + warn!( + "Skipping remote workspace cleanup because SSH manager is unavailable: {}", + error + ); + return 0; + } + }; let workspaces = state.workspace_service.list_workspace_infos().await; let mut removed = 0usize; @@ -1860,7 +1858,11 @@ async fn prune_unrecoverable_remote_workspaces(state: &State<'_, AppState>) -> u .await; } - match state.workspace_service.remove_workspace(&workspace.id).await { + match state + .workspace_service + .remove_workspace(&workspace.id) + .await + { Ok(()) => { removed += 1; info!( @@ -1873,8 +1875,7 @@ async fn prune_unrecoverable_remote_workspaces(state: &State<'_, AppState>) -> u Err(error) => { warn!( "Failed to remove unrecoverable remote workspace: workspace_id={}, error={}", - workspace.id, - error + workspace.id, error ); } } diff --git a/src/apps/desktop/src/api/subagent_api.rs b/src/apps/desktop/src/api/subagent_api.rs index 08e82744e3..425b12e7d5 100644 --- a/src/apps/desktop/src/api/subagent_api.rs +++ b/src/apps/desktop/src/api/subagent_api.rs @@ -357,9 +357,7 @@ pub async fn create_subagent( kind, ); subagent.review = review; - subagent - .save_to_file(None) - .map_err(|e| e.to_string())?; + subagent.save_to_file(None).map_err(|e| e.to_string())?; let custom_config = CustomSubagentConfig { model: subagent.model.clone(), diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index e602ccfbcd..389c6ec731 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -428,12 +428,12 @@ pub async fn toggle_main_window_fullscreen( return Err("Main window not found".to_string()); }; - let current_fullscreen = window.is_fullscreen().map_err(|error| { - format!("Failed to read main window fullscreen state: {}", error) - })?; - let current_maximized = window.is_maximized().map_err(|error| { - format!("Failed to read main window maximize state: {}", error) - })?; + let current_fullscreen = window + .is_fullscreen() + .map_err(|error| format!("Failed to read main window fullscreen state: {}", error))?; + let current_maximized = window + .is_maximized() + .map_err(|error| format!("Failed to read main window maximize state: {}", error))?; let restore_maximized_after_fullscreen = *main_window_fullscreen_restore_maximized() .lock() .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())?; @@ -459,11 +459,7 @@ pub async fn toggle_main_window_fullscreen( .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())? = transition.next_restore_maximized_after_fullscreen; - return Ok(read_main_window_fullscreen_response( - &window, - true, - false, - )); + return Ok(read_main_window_fullscreen_response(&window, true, false)); } window diff --git a/src/apps/desktop/src/logging.rs b/src/apps/desktop/src/logging.rs index 695b66624a..b2e31b3bdc 100644 --- a/src/apps/desktop/src/logging.rs +++ b/src/apps/desktop/src/logging.rs @@ -5,12 +5,12 @@ use chrono::Local; use serde::Serialize; use std::path::PathBuf; use std::sync::{ - OnceLock, atomic::{AtomicU8, Ordering}, + OnceLock, }; use std::thread; -use tauri::{Runtime, plugin::TauriPlugin}; -use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy, fern}; +use tauri::{plugin::TauriPlugin, Runtime}; +use tauri_plugin_log::{fern, RotationStrategy, Target, TargetKind, TimezoneStrategy}; const SESSION_DIR_PATTERN: &str = r"^\d{8}T\d{6}$"; const MAX_LOG_SESSIONS: usize = 10; diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index 93edeede4c..e3c9be926d 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -572,29 +572,30 @@ impl AcpClientService { connection_for_task.sessions.clear(); }); - let (cx, agent_capabilities) = - match tokio::time::timeout(CLIENT_STARTUP_TIMEOUT, cx_rx).await { - Ok(Ok(result)) => result, - Ok(Err(_)) => { - connect_task.abort(); - self.cleanup_failed_startup(connection_id).await; - return Err(BitFunError::service(format!( - "ACP client '{}' exited before initialization completed", - client_id - ))); - } - Err(_) => { - warn!( + let (cx, agent_capabilities) = match tokio::time::timeout(CLIENT_STARTUP_TIMEOUT, cx_rx) + .await + { + Ok(Ok(result)) => result, + Ok(Err(_)) => { + connect_task.abort(); + self.cleanup_failed_startup(connection_id).await; + return Err(BitFunError::service(format!( + "ACP client '{}' exited before initialization completed", + client_id + ))); + } + Err(_) => { + warn!( "ACP client startup timed out during initialize: id={} connection_id={} timeout_secs={}", client_id, connection_id, CLIENT_STARTUP_TIMEOUT_SECS ); - connect_task.abort(); - self.cleanup_failed_startup(connection_id).await; - return Err(startup_timeout_error(client_id, "initialize")); - } - }; + connect_task.abort(); + self.cleanup_failed_startup(connection_id).await; + return Err(startup_timeout_error(client_id, "initialize")); + } + }; *connection.connection.write().await = Some(cx); *connection.agent_capabilities.write().await = Some(agent_capabilities); *connection.status.write().await = AcpClientStatus::Running; @@ -1307,10 +1308,7 @@ impl AcpClientService { Err(_) => { warn!( "ACP client startup timed out: id={} connection_id={} phase={} timeout_secs={}", - client.client_id, - client.id, - phase, - CLIENT_STARTUP_TIMEOUT_SECS + client.client_id, client.id, phase, CLIENT_STARTUP_TIMEOUT_SECS ); self.cleanup_failed_startup(&client.id).await; Err(agent_client_protocol::util::internal_error( @@ -1979,9 +1977,7 @@ fn startup_timeout_error_message(client_id: &str, phase: &str) -> String { } fn is_startup_timeout_error(error: &BitFunError) -> bool { - error - .to_string() - .contains(STARTUP_TIMEOUT_ERROR_PREFIX) + error.to_string().contains(STARTUP_TIMEOUT_ERROR_PREFIX) } fn select_permission_by_kind( diff --git a/src/crates/ai-adapters/src/lib.rs b/src/crates/ai-adapters/src/lib.rs index f12f390814..73894edef6 100644 --- a/src/crates/ai-adapters/src/lib.rs +++ b/src/crates/ai-adapters/src/lib.rs @@ -10,7 +10,7 @@ pub mod types; pub use client::{AIClient, StreamOptions, StreamResponse}; pub use stream::{UnifiedResponse, UnifiedTokenUsage, UnifiedToolCall}; pub use types::{ - AIConfig, ConnectionTestMessageCode, ConnectionTestResult, GeminiResponse, GeminiUsage, - Message, ProxyConfig, ReasoningMode, RemoteModelInfo, ToolCall, ToolDefinition, - ToolImageAttachment, resolve_request_url, + resolve_request_url, AIConfig, ConnectionTestMessageCode, ConnectionTestResult, GeminiResponse, + GeminiUsage, Message, ProxyConfig, ReasoningMode, RemoteModelInfo, ToolCall, ToolDefinition, + ToolImageAttachment, }; diff --git a/src/crates/ai-adapters/src/providers/shared.rs b/src/crates/ai-adapters/src/providers/shared.rs index 8e9ea1277a..5dc05de751 100644 --- a/src/crates/ai-adapters/src/providers/shared.rs +++ b/src/crates/ai-adapters/src/providers/shared.rs @@ -1,7 +1,7 @@ -use crate::client::AIClient; use crate::client::utils::{ build_request_body_subset, is_trim_custom_request_body_mode, merge_json_value, }; +use crate::client::AIClient; use reqwest::RequestBuilder; pub(crate) fn apply_header_policy( diff --git a/src/crates/ai-adapters/src/stream/stream_handler/anthropic.rs b/src/crates/ai-adapters/src/stream/stream_handler/anthropic.rs index b50c9af9fe..eb6498cff9 100644 --- a/src/crates/ai-adapters/src/stream/stream_handler/anthropic.rs +++ b/src/crates/ai-adapters/src/stream/stream_handler/anthropic.rs @@ -1,12 +1,12 @@ use super::inline_think::InlineThinkParser; use super::stream_stats::StreamStats; -use super::{TimedStreamItem, next_stream_item}; +use super::{next_stream_item, TimedStreamItem}; use crate::stream::types::anthropic::{ AnthropicSSEError, ContentBlock, ContentBlockDelta, ContentBlockStart, MessageDelta, MessageStart, Usage, }; use crate::stream::types::unified::UnifiedResponse; -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; use eventsource_stream::Eventsource; use log::{error, trace}; use reqwest::Response; diff --git a/src/crates/core/src/agentic/agents/citation_renumber.rs b/src/crates/core/src/agentic/agents/citation_renumber.rs index c31feb0b90..6306385ea9 100644 --- a/src/crates/core/src/agentic/agents/citation_renumber.rs +++ b/src/crates/core/src/agentic/agents/citation_renumber.rs @@ -393,16 +393,11 @@ async fn write_display_map_sidecar( "citation_count": order.len(), "entries": entries, }); - let serialized = serde_json::to_string_pretty(&body).map_err(|e| { - BitFunError::tool(format!("serialize display_map.json failed: {}", e)) - })?; - fs::write(&map_path, serialized).await.map_err(|e| { - BitFunError::tool(format!( - "write {} failed: {}", - map_path.display(), - e - )) - })?; + let serialized = serde_json::to_string_pretty(&body) + .map_err(|e| BitFunError::tool(format!("serialize display_map.json failed: {}", e)))?; + fs::write(&map_path, serialized) + .await + .map_err(|e| BitFunError::tool(format!("write {} failed: {}", map_path.display(), e)))?; Ok(map_path) } @@ -579,7 +574,10 @@ cit_003 | claim c | url=u3 | status=accepted assert!(data_rows[1].contains("[2] cit_001")); // cit_002 must not appear anywhere in the rendered Index. - assert!(!out.contains("cit_002"), "REJECTED cit_002 leaked into index"); + assert!( + !out.contains("cit_002"), + "REJECTED cit_002 leaked into index" + ); assert!(!out.contains("[REJECTED]"), "no REJECTED tag should remain"); } diff --git a/src/crates/core/src/agentic/agents/definitions/modes/mod.rs b/src/crates/core/src/agentic/agents/definitions/modes/mod.rs index 237d8f795e..831af452d2 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/mod.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/mod.rs @@ -1,15 +1,15 @@ mod agentic; mod claw; mod cowork; -mod deep_research; mod debug; +mod deep_research; mod plan; mod team; pub use agentic::AgenticMode; pub use claw::ClawMode; pub use cowork::CoworkMode; -pub use deep_research::DeepResearchMode; pub use debug::DebugMode; +pub use deep_research::DeepResearchMode; pub use plan::PlanMode; pub use team::TeamMode; diff --git a/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs b/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs index d806fa4758..8f79bc0198 100644 --- a/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs +++ b/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs @@ -1,9 +1,9 @@ +use crate::agentic::agents::AgentToolPolicyOverrides; use crate::agentic::deep_review_policy::{ REVIEWER_ARCHITECTURE_AGENT_TYPE, REVIEWER_BUSINESS_LOGIC_AGENT_TYPE, REVIEWER_FRONTEND_AGENT_TYPE, REVIEWER_PERFORMANCE_AGENT_TYPE, REVIEWER_SECURITY_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, }; -use crate::agentic::agents::AgentToolPolicyOverrides; use crate::agentic::tools::framework::ToolExposure; use crate::define_readonly_subagent_with_overrides; diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index 10c8c7055b..fb39b70043 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -13,11 +13,8 @@ pub(crate) mod citation_renumber; use crate::agentic::tools::framework::ToolExposure; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use indexmap::IndexMap; pub use definitions::custom::{CustomSubagent, CustomSubagentKind}; -pub use definitions::hidden::{ - CodeReviewAgent, DeepReviewAgent, GenerateDocAgent, InitAgent, -}; +pub use definitions::hidden::{CodeReviewAgent, DeepReviewAgent, GenerateDocAgent, InitAgent}; pub use definitions::modes::{ AgenticMode, ClawMode, CoworkMode, DebugMode, DeepResearchMode, PlanMode, TeamMode, }; @@ -25,18 +22,16 @@ pub use definitions::review::{ ArchitectureReviewerAgent, BusinessLogicReviewerAgent, FrontendReviewerAgent, PerformanceReviewerAgent, ReviewFixerAgent, ReviewJudgeAgent, SecurityReviewerAgent, }; +pub use definitions::shared::ReadonlySubagent; pub use definitions::subagents::{ ComputerUseMode, ExploreAgent, FileFinderAgent, ResearchSpecialistAgent, }; -pub use definitions::shared::ReadonlySubagent; +use indexmap::IndexMap; pub use prompt_builder::{ PromptBuilder, PromptBuilderContext, RemoteExecutionHints, RequestContextPolicy, RequestContextSection, }; -pub use registry::{get_agent_registry, AgentRegistry, CustomSubagentDetail}; -pub use registry::catalog::{ - builtin_agent_specs, BuiltinAgentSpec, -}; +pub use registry::catalog::{builtin_agent_specs, BuiltinAgentSpec}; pub use registry::types::{ AgentCategory, AgentInfo, AgentToolPolicy, CustomSubagentConfig, SubAgentSource, SubagentListScope, SubagentQueryContext, SubagentStateReason, @@ -44,6 +39,7 @@ pub use registry::types::{ pub use registry::visibility::{ BuiltinSubagentExposure, SubagentVisibilityPolicy, SubagentVisibilitySummary, }; +pub use registry::{get_agent_registry, AgentRegistry, CustomSubagentDetail}; use std::any::Any; // Include embedded prompts generated at compile time diff --git a/src/crates/core/src/agentic/agents/registry/availability.rs b/src/crates/core/src/agentic/agents/registry/availability.rs index 07fd41f03f..93d13c41bd 100644 --- a/src/crates/core/src/agentic/agents/registry/availability.rs +++ b/src/crates/core/src/agentic/agents/registry/availability.rs @@ -66,12 +66,15 @@ pub fn subagent_override_for_parent( parent_agent_type: Option<&str>, subagent_key: &str, ) -> Option { - override_for_parent(overrides, parent_agent_type).and_then(|parent| parent.get(subagent_key).copied()) + override_for_parent(overrides, parent_agent_type) + .and_then(|parent| parent.get(subagent_key).copied()) } pub fn resolve_default_enabled(entry: &AgentEntry, parent_agent_type: Option<&str>) -> bool { match entry.subagent_source { - Some(SubAgentSource::Builtin) => entry.visibility_policy.can_access_from_parent(parent_agent_type), + Some(SubAgentSource::Builtin) => entry + .visibility_policy + .can_access_from_parent(parent_agent_type), Some(SubAgentSource::Project) | Some(SubAgentSource::User) => true, None => true, } @@ -96,7 +99,11 @@ pub fn resolve_override_layers( }, Some(SubAgentSource::Builtin) | Some(SubAgentSource::User) => ResolvedOverrideLayers { project_override: None, - user_override: subagent_override_for_parent(user_overrides, parent_agent_type, &subagent_key), + user_override: subagent_override_for_parent( + user_overrides, + parent_agent_type, + &subagent_key, + ), }, None => ResolvedOverrideLayers::default(), } @@ -109,7 +116,8 @@ pub fn resolve_availability( user_overrides: &AgentSubagentOverrideConfig, ) -> ResolvedSubagentAvailability { let default_enabled = resolve_default_enabled(entry, parent_agent_type); - let layers = resolve_override_layers(entry, parent_agent_type, project_overrides, user_overrides); + let layers = + resolve_override_layers(entry, parent_agent_type, project_overrides, user_overrides); if let Some(project_override) = layers.project_override { return ResolvedSubagentAvailability { @@ -203,7 +211,11 @@ mod tests { } } - fn overrides(parent: &str, subagent_key: &str, state: AgentSubagentOverrideState) -> AgentSubagentOverrideConfig { + fn overrides( + parent: &str, + subagent_key: &str, + state: AgentSubagentOverrideState, + ) -> AgentSubagentOverrideConfig { let mut parent_overrides = HashMap::new(); parent_overrides.insert(subagent_key.to_string(), state); @@ -215,16 +227,24 @@ mod tests { #[test] fn builtin_and_user_subagents_only_use_global_overrides() { let builtin_entry = make_entry(SubAgentSource::Builtin, "Explore"); - let builtin_key = subagent_key_for(builtin_entry.subagent_source, builtin_entry.agent.as_ref()) - .expect("builtin key"); + let builtin_key = + subagent_key_for(builtin_entry.subagent_source, builtin_entry.agent.as_ref()) + .expect("builtin key"); let builtin_layers = resolve_override_layers( &builtin_entry, Some("agentic"), - Some(&overrides("agentic", &builtin_key, AgentSubagentOverrideState::Disabled)), + Some(&overrides( + "agentic", + &builtin_key, + AgentSubagentOverrideState::Disabled, + )), &overrides("agentic", &builtin_key, AgentSubagentOverrideState::Enabled), ); assert_eq!(builtin_layers.project_override, None); - assert_eq!(builtin_layers.user_override, Some(AgentSubagentOverrideState::Enabled)); + assert_eq!( + builtin_layers.user_override, + Some(AgentSubagentOverrideState::Enabled) + ); let user_entry = make_entry(SubAgentSource::User, "UserScout"); let user_key = subagent_key_for(user_entry.subagent_source, user_entry.agent.as_ref()) @@ -232,26 +252,40 @@ mod tests { let user_layers = resolve_override_layers( &user_entry, Some("agentic"), - Some(&overrides("agentic", &user_key, AgentSubagentOverrideState::Disabled)), + Some(&overrides( + "agentic", + &user_key, + AgentSubagentOverrideState::Disabled, + )), &overrides("agentic", &user_key, AgentSubagentOverrideState::Enabled), ); assert_eq!(user_layers.project_override, None); - assert_eq!(user_layers.user_override, Some(AgentSubagentOverrideState::Enabled)); + assert_eq!( + user_layers.user_override, + Some(AgentSubagentOverrideState::Enabled) + ); } #[test] fn project_subagents_only_use_project_overrides() { let entry = make_entry(SubAgentSource::Project, "ProjectScout"); - let key = subagent_key_for(entry.subagent_source, entry.agent.as_ref()) - .expect("project key"); + let key = + subagent_key_for(entry.subagent_source, entry.agent.as_ref()).expect("project key"); let layers = resolve_override_layers( &entry, Some("agentic"), - Some(&overrides("agentic", &key, AgentSubagentOverrideState::Disabled)), + Some(&overrides( + "agentic", + &key, + AgentSubagentOverrideState::Disabled, + )), &overrides("agentic", &key, AgentSubagentOverrideState::Enabled), ); - assert_eq!(layers.project_override, Some(AgentSubagentOverrideState::Disabled)); + assert_eq!( + layers.project_override, + Some(AgentSubagentOverrideState::Disabled) + ); assert_eq!(layers.user_override, None); } } diff --git a/src/crates/core/src/agentic/agents/registry/builtin.rs b/src/crates/core/src/agentic/agents/registry/builtin.rs index 401e34567a..e431d8b6c2 100644 --- a/src/crates/core/src/agentic/agents/registry/builtin.rs +++ b/src/crates/core/src/agentic/agents/registry/builtin.rs @@ -1,10 +1,8 @@ use super::types::AgentEntry; use super::visibility::SubagentVisibilityPolicy; use super::AgentRegistry; -use crate::agentic::agents::{ - Agent, AgentCategory, SubAgentSource, -}; use crate::agentic::agents::registry::catalog::builtin_agent_specs; +use crate::agentic::agents::{Agent, AgentCategory, SubAgentSource}; use log::error; use std::collections::HashMap; use std::sync::Arc; diff --git a/src/crates/core/src/agentic/agents/registry/catalog.rs b/src/crates/core/src/agentic/agents/registry/catalog.rs index 120ebdc3b0..b9acf54853 100644 --- a/src/crates/core/src/agentic/agents/registry/catalog.rs +++ b/src/crates/core/src/agentic/agents/registry/catalog.rs @@ -2,9 +2,9 @@ use super::types::AgentCategory; use super::visibility::SubagentVisibilityPolicy; use crate::agentic::agents::{ Agent, AgenticMode, ArchitectureReviewerAgent, BusinessLogicReviewerAgent, ClawMode, - CodeReviewAgent, ComputerUseMode, CoworkMode, DebugMode, DeepResearchMode, - DeepReviewAgent, ExploreAgent, FileFinderAgent, FrontendReviewerAgent, GenerateDocAgent, - InitAgent, PerformanceReviewerAgent, PlanMode, ResearchSpecialistAgent, ReviewFixerAgent, + CodeReviewAgent, ComputerUseMode, CoworkMode, DebugMode, DeepResearchMode, DeepReviewAgent, + ExploreAgent, FileFinderAgent, FrontendReviewerAgent, GenerateDocAgent, InitAgent, + PerformanceReviewerAgent, PlanMode, ResearchSpecialistAgent, ReviewFixerAgent, ReviewJudgeAgent, SecurityReviewerAgent, TeamMode, }; use std::sync::Arc; diff --git a/src/crates/core/src/agentic/agents/registry/custom.rs b/src/crates/core/src/agentic/agents/registry/custom.rs index 9f7a56d98f..08d1c9f64b 100644 --- a/src/crates/core/src/agentic/agents/registry/custom.rs +++ b/src/crates/core/src/agentic/agents/registry/custom.rs @@ -1,16 +1,15 @@ -use super::types::AgentEntry; -use super::custom_loader::CustomSubagentLoader; use super::availability::{prune_override_config, resolve_default_enabled, set_override_state}; +use super::custom_loader::CustomSubagentLoader; use super::support::{ - get_subagent_overrides, load_project_subagent_overrides_local, save_project_subagent_overrides_local, -}; -use super::{CustomSubagentDetail, AgentRegistry}; -use crate::agentic::agents::{ - Agent, AgentCategory, CustomSubagentConfig, SubAgentSource, + get_subagent_overrides, load_project_subagent_overrides_local, + save_project_subagent_overrides_local, }; +use super::types::AgentEntry; +use super::{AgentRegistry, CustomSubagentDetail}; use crate::agentic::agents::definitions::custom::{CustomSubagent, CustomSubagentKind}; use crate::agentic::agents::registry::types::subagent_key_for; use crate::agentic::agents::registry::visibility::SubagentVisibilityPolicy; +use crate::agentic::agents::{Agent, AgentCategory, CustomSubagentConfig, SubAgentSource}; use crate::agentic::tools::{get_all_registered_tool_names, get_readonly_registered_tool_names}; use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::AgentSubagentOverrideState; @@ -590,9 +589,10 @@ impl AgentRegistry { ))); } - let subagent_key = subagent_key_for(entry.subagent_source, entry.agent.as_ref()).ok_or_else( - || BitFunError::agent(format!("Failed to resolve subagent key for '{}'", agent_id)), - )?; + let subagent_key = subagent_key_for(entry.subagent_source, entry.agent.as_ref()) + .ok_or_else(|| { + BitFunError::agent(format!("Failed to resolve subagent key for '{}'", agent_id)) + })?; let default_enabled = resolve_default_enabled(&entry, Some(parent_agent_type)); let state = if enabled { AgentSubagentOverrideState::Enabled @@ -608,11 +608,17 @@ impl AgentRegistry { agent_id )) })?; - let mut project_overrides = load_project_subagent_overrides_local(workspace_root).await?; + let mut project_overrides = + load_project_subagent_overrides_local(workspace_root).await?; if enabled == default_enabled { prune_override_config(&mut project_overrides, parent_agent_type, &subagent_key); } else { - set_override_state(&mut project_overrides, parent_agent_type, &subagent_key, state); + set_override_state( + &mut project_overrides, + parent_agent_type, + &subagent_key, + state, + ); } save_project_subagent_overrides_local(workspace_root, &project_overrides).await?; Ok(()) @@ -623,7 +629,12 @@ impl AgentRegistry { if enabled == default_enabled { prune_override_config(&mut user_overrides, parent_agent_type, &subagent_key); } else { - set_override_state(&mut user_overrides, parent_agent_type, &subagent_key, state); + set_override_state( + &mut user_overrides, + parent_agent_type, + &subagent_key, + state, + ); } config_service .set_config("ai.agent_subagent_overrides", &user_overrides) diff --git a/src/crates/core/src/agentic/agents/registry/custom_loader.rs b/src/crates/core/src/agentic/agents/registry/custom_loader.rs index c70ee16908..f6d310c6bd 100644 --- a/src/crates/core/src/agentic/agents/registry/custom_loader.rs +++ b/src/crates/core/src/agentic/agents/registry/custom_loader.rs @@ -76,7 +76,10 @@ impl CustomSubagentLoader { /// Agents with the same name are prioritized by path order: earlier paths have higher priority, later ones won't override already loaded agents with the same name. pub fn load_custom_subagents(workspace_root: &Path) -> Vec { let mut candidates = Vec::new(); - for (root_priority, entry) in Self::get_possible_paths(workspace_root).into_iter().enumerate() { + for (root_priority, entry) in Self::get_possible_paths(workspace_root) + .into_iter() + .enumerate() + { for md_path in Self::list_md_files(&entry.path) { let path_str = md_path.to_string_lossy(); match CustomSubagent::from_file(path_str.as_ref(), entry.kind) { @@ -99,7 +102,12 @@ impl CustomSubagentLoader { candidates.sort_by(|a, b| { a.root_priority .cmp(&b.root_priority) - .then_with(|| a.agent.id().to_lowercase().cmp(&b.agent.id().to_lowercase())) + .then_with(|| { + a.agent + .id() + .to_lowercase() + .cmp(&b.agent.id().to_lowercase()) + }) .then_with(|| a.agent.id().cmp(b.agent.id())) .then_with(|| a.path.cmp(&b.path)) }); diff --git a/src/crates/core/src/agentic/agents/registry/mod.rs b/src/crates/core/src/agentic/agents/registry/mod.rs index e43d109396..89af200bb0 100644 --- a/src/crates/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/core/src/agentic/agents/registry/mod.rs @@ -1,4 +1,3 @@ -pub mod types; mod availability; mod builtin; pub mod catalog; @@ -7,17 +6,14 @@ mod custom_loader; mod query; mod resolution; mod support; -pub mod visibility; #[cfg(test)] mod tests; +pub mod types; +pub mod visibility; -use super::{ - Agent, -}; use self::types::AgentEntry; -use self::types::{ - AgentCategory, SubAgentSource, -}; +use self::types::{AgentCategory, SubAgentSource}; +use super::Agent; use log::{debug, warn}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; diff --git a/src/crates/core/src/agentic/agents/registry/query.rs b/src/crates/core/src/agentic/agents/registry/query.rs index d6347ac659..cf486353ae 100644 --- a/src/crates/core/src/agentic/agents/registry/query.rs +++ b/src/crates/core/src/agentic/agents/registry/query.rs @@ -65,7 +65,8 @@ impl AgentRegistry { let allowed_tool_set: HashSet<&str> = allowed_tools.iter().map(String::as_str).collect(); let mut exposure_overrides = entry.agent.tool_exposure_overrides().clone(); - exposure_overrides.retain(|tool_name, _| allowed_tool_set.contains(tool_name.as_str())); + exposure_overrides + .retain(|tool_name, _| allowed_tool_set.contains(tool_name.as_str())); AgentToolPolicy { allowed_tools, @@ -77,7 +78,8 @@ impl AgentRegistry { let allowed_tool_set: HashSet<&str> = allowed_tools.iter().map(String::as_str).collect(); let mut exposure_overrides = entry.agent.tool_exposure_overrides().clone(); - exposure_overrides.retain(|tool_name, _| allowed_tool_set.contains(tool_name.as_str())); + exposure_overrides + .retain(|tool_name, _| allowed_tool_set.contains(tool_name.as_str())); AgentToolPolicy { allowed_tools, @@ -184,7 +186,9 @@ impl AgentRegistry { } match query.list_scope { - SubagentListScope::RegistryManagement => entry.visibility_policy.show_in_global_registry, + SubagentListScope::RegistryManagement => { + entry.visibility_policy.show_in_global_registry + } SubagentListScope::TaskVisible => { entry.visibility_policy.show_in_global_registry || entry @@ -306,7 +310,8 @@ impl AgentRegistry { }; if let Some(workspace_root) = query.workspace_root { - let is_project_cache_loaded = self.read_project_subagents().contains_key(workspace_root); + let is_project_cache_loaded = + self.read_project_subagents().contains_key(workspace_root); if !is_project_cache_loaded { self.load_custom_subagents(workspace_root).await; } diff --git a/src/crates/core/src/agentic/agents/registry/support.rs b/src/crates/core/src/agentic/agents/registry/support.rs index 23eaeb5217..ff755326ed 100644 --- a/src/crates/core/src/agentic/agents/registry/support.rs +++ b/src/crates/core/src/agentic/agents/registry/support.rs @@ -1,5 +1,5 @@ -use crate::service::config::global::GlobalConfigManager; use crate::infrastructure::get_path_manager_arc; +use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{AgentSubagentOverrideConfig, ModeConfig}; use crate::util::errors::{BitFunError, BitFunResult}; use serde_json::{Map, Value}; @@ -73,7 +73,10 @@ pub(super) fn merge_dynamic_mcp_tools( continue; } - if configured_tools.iter().any(|existing| existing == tool_name) { + if configured_tools + .iter() + .any(|existing| existing == tool_name) + { continue; } diff --git a/src/crates/core/src/agentic/agents/registry/tests.rs b/src/crates/core/src/agentic/agents/registry/tests.rs index e0bb0d448e..7608bf11ab 100644 --- a/src/crates/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/core/src/agentic/agents/registry/tests.rs @@ -1,7 +1,7 @@ use super::support::merge_dynamic_mcp_tools; use super::AgentRegistry; -use crate::agentic::agents::registry::builtin::default_model_id_for_builtin_agent; use crate::agentic::agents::definitions::custom::{CustomSubagent, CustomSubagentKind}; +use crate::agentic::agents::registry::builtin::default_model_id_for_builtin_agent; use crate::agentic::agents::registry::types::{ AgentCategory, AgentEntry, CustomSubagentConfig, SubAgentSource, SubagentListScope, SubagentQueryContext, @@ -185,7 +185,9 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { }) .await; assert!(agentic_visible.iter().any(|agent| agent.id == "Explore")); - assert!(!agentic_visible.iter().any(|agent| agent.id == "ReviewSecurity")); + assert!(!agentic_visible + .iter() + .any(|agent| agent.id == "ReviewSecurity")); assert!(!agentic_visible .iter() .any(|agent| agent.id == "ResearchSpecialist")); @@ -298,8 +300,14 @@ async fn prompt_stability_task_visible_subagents_are_sorted_deterministically() ); let mut project_entries = HashMap::new(); - project_entries.insert("zProject".to_string(), test_project_entry("zProject", "fast")); - project_entries.insert("AProject".to_string(), test_project_entry("AProject", "fast")); + project_entries.insert( + "zProject".to_string(), + test_project_entry("zProject", "fast"), + ); + project_entries.insert( + "AProject".to_string(), + test_project_entry("AProject", "fast"), + ); registry .write_project_subagents() .insert(workspace.clone(), project_entries); @@ -408,14 +416,26 @@ async fn parent_subagent_overrides_follow_source_scopes() { let builtin_override_key = "builtin::builtin::Explore".to_string(); let mut project_parent_map = HashMap::new(); - project_parent_map.insert(project_override_key.clone(), AgentSubagentOverrideState::Disabled); - project_parent_map.insert(user_override_key.clone(), AgentSubagentOverrideState::Disabled); - project_parent_map.insert(builtin_override_key.clone(), AgentSubagentOverrideState::Disabled); + project_parent_map.insert( + project_override_key.clone(), + AgentSubagentOverrideState::Disabled, + ); + project_parent_map.insert( + user_override_key.clone(), + AgentSubagentOverrideState::Disabled, + ); + project_parent_map.insert( + builtin_override_key.clone(), + AgentSubagentOverrideState::Disabled, + ); let mut project_overrides = HashMap::new(); project_overrides.insert("agentic".to_string(), project_parent_map); let mut user_parent_map = HashMap::new(); - user_parent_map.insert(project_override_key.clone(), AgentSubagentOverrideState::Enabled); + user_parent_map.insert( + project_override_key.clone(), + AgentSubagentOverrideState::Enabled, + ); user_parent_map.insert(user_override_key, AgentSubagentOverrideState::Disabled); user_parent_map.insert(builtin_override_key, AgentSubagentOverrideState::Disabled); let mut user_overrides = HashMap::new(); @@ -435,13 +455,37 @@ async fn parent_subagent_overrides_follow_source_scopes() { .expect("project entry"); ( - resolve_availability(&explore, builtin_query.parent_agent_type, Some(&project_overrides), &user_overrides), - resolve_availability(&user, builtin_query.parent_agent_type, Some(&project_overrides), &user_overrides), - resolve_availability(&project, builtin_query.parent_agent_type, Some(&project_overrides), &user_overrides), + resolve_availability( + &explore, + builtin_query.parent_agent_type, + Some(&project_overrides), + &user_overrides, + ), + resolve_availability( + &user, + builtin_query.parent_agent_type, + Some(&project_overrides), + &user_overrides, + ), + resolve_availability( + &project, + builtin_query.parent_agent_type, + Some(&project_overrides), + &user_overrides, + ), ) }; - assert_eq!(visible.0.override_state, Some(AgentSubagentOverrideState::Disabled)); - assert_eq!(visible.1.override_state, Some(AgentSubagentOverrideState::Disabled)); - assert_eq!(visible.2.override_state, Some(AgentSubagentOverrideState::Disabled)); + assert_eq!( + visible.0.override_state, + Some(AgentSubagentOverrideState::Disabled) + ); + assert_eq!( + visible.1.override_state, + Some(AgentSubagentOverrideState::Disabled) + ); + assert_eq!( + visible.2.override_state, + Some(AgentSubagentOverrideState::Disabled) + ); } diff --git a/src/crates/core/src/agentic/agents/registry/types.rs b/src/crates/core/src/agentic/agents/registry/types.rs index 3d0dc2b13e..7bb1bcc7ec 100644 --- a/src/crates/core/src/agentic/agents/registry/types.rs +++ b/src/crates/core/src/agentic/agents/registry/types.rs @@ -1,13 +1,13 @@ use crate::agentic::agents::definitions::custom::{CustomSubagent, CustomSubagentKind}; +use crate::agentic::agents::registry::visibility::{ + SubagentVisibilityPolicy, SubagentVisibilitySummary, +}; +use crate::agentic::agents::{Agent, AgentToolPolicyOverrides}; use crate::agentic::deep_review_policy::{ REVIEWER_ARCHITECTURE_AGENT_TYPE, REVIEWER_BUSINESS_LOGIC_AGENT_TYPE, REVIEWER_FRONTEND_AGENT_TYPE, REVIEWER_PERFORMANCE_AGENT_TYPE, REVIEWER_SECURITY_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, }; -use crate::agentic::agents::{Agent, AgentToolPolicyOverrides}; -use crate::agentic::agents::registry::visibility::{ - SubagentVisibilityPolicy, SubagentVisibilitySummary, -}; use crate::service::config::types::AgentSubagentOverrideState; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -128,10 +128,7 @@ fn default_true() -> bool { true } -pub fn subagent_key_for( - source: Option, - agent: &dyn Agent, -) -> Option { +pub fn subagent_key_for(source: Option, agent: &dyn Agent) -> Option { let source = source?; let slot = match source { SubAgentSource::Builtin => "builtin", @@ -164,7 +161,10 @@ impl AgentInfo { let default_tools = agent.default_tools(); // get model from custom_config; path by downcast - let model = entry.custom_config.as_ref().map(|config| config.model.clone()); + let model = entry + .custom_config + .as_ref() + .map(|config| config.model.clone()); // get path by downcast to CustomSubagent (only custom subagent has path) let path = agent @@ -173,7 +173,8 @@ impl AgentInfo { .map(|c| c.path.clone()); AgentInfo { - key: subagent_key_for(entry.subagent_source, agent).unwrap_or_else(|| agent.id().to_string()), + key: subagent_key_for(entry.subagent_source, agent) + .unwrap_or_else(|| agent.id().to_string()), id: agent.id().to_string(), name: agent.name().to_string(), description: agent.description().to_string(), diff --git a/src/crates/core/src/agentic/agents/registry/visibility.rs b/src/crates/core/src/agentic/agents/registry/visibility.rs index 2632649827..ff32324d4e 100644 --- a/src/crates/core/src/agentic/agents/registry/visibility.rs +++ b/src/crates/core/src/agentic/agents/registry/visibility.rs @@ -108,8 +108,10 @@ impl SubagentVisibilityPolicy { match self.exposure { BuiltinSubagentExposure::Public => true, - BuiltinSubagentExposure::Restricted | BuiltinSubagentExposure::Hidden => normalized_parent - .is_some_and(|parent| self.allowed_parent_agent_ids.contains(parent)), + BuiltinSubagentExposure::Restricted | BuiltinSubagentExposure::Hidden => { + normalized_parent + .is_some_and(|parent| self.allowed_parent_agent_ids.contains(parent)) + } } } } diff --git a/src/crates/core/src/agentic/deep_review/execution_policy.rs b/src/crates/core/src/agentic/deep_review/execution_policy.rs index 3ddeb3a9bf..87b9371271 100644 --- a/src/crates/core/src/agentic/deep_review/execution_policy.rs +++ b/src/crates/core/src/agentic/deep_review/execution_policy.rs @@ -328,16 +328,19 @@ impl DeepReviewExecutionPolicy { fn apply_strategy_runtime_budget(&mut self) { let budget = strategy_runtime_budget(self.strategy_level); - self.reviewer_timeout_seconds = - strategy_bounded_timeout(self.reviewer_timeout_seconds, budget.reviewer_timeout_seconds); + self.reviewer_timeout_seconds = strategy_bounded_timeout( + self.reviewer_timeout_seconds, + budget.reviewer_timeout_seconds, + ); self.judge_timeout_seconds = strategy_bounded_timeout(self.judge_timeout_seconds, budget.judge_timeout_seconds); self.reviewer_file_split_threshold = strategy_bounded_split_threshold( self.reviewer_file_split_threshold, budget.reviewer_file_split_threshold, ); - self.max_same_role_instances = - self.max_same_role_instances.min(budget.max_same_role_instances); + self.max_same_role_instances = self + .max_same_role_instances + .min(budget.max_same_role_instances); } /// Returns true when the file count exceeds the split threshold and @@ -436,7 +439,10 @@ fn strategy_bounded_timeout(configured_timeout_seconds: u64, strategy_timeout_se configured_timeout_seconds.min(strategy_timeout_seconds) } -fn strategy_bounded_split_threshold(configured_threshold: usize, strategy_threshold: usize) -> usize { +fn strategy_bounded_split_threshold( + configured_threshold: usize, + strategy_threshold: usize, +) -> usize { if configured_threshold == 0 || strategy_threshold == 0 { return 0; } diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index c76362afff..e43b0b8701 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -19,7 +19,9 @@ use crate::agentic::image_analysis::{ ImageLimits, }; use crate::agentic::session::{CompressionTailPolicy, ContextCompressor, SessionManager}; -use crate::agentic::tools::{resolve_tool_manifest, ResolvedToolManifest, SubagentParentInfo, ToolRuntimeRestrictions}; +use crate::agentic::tools::{ + resolve_tool_manifest, ResolvedToolManifest, SubagentParentInfo, ToolRuntimeRestrictions, +}; use crate::agentic::util::build_remote_workspace_layout_preview; use crate::agentic::{WorkspaceBackend, WorkspaceBinding}; use crate::infrastructure::ai::get_global_ai_client_factory; @@ -516,8 +518,7 @@ impl ExecutionEngine { continue; } - let Some(tool_name) = result.get("tool_name").and_then(|v| v.as_str()) - else { + let Some(tool_name) = result.get("tool_name").and_then(|v| v.as_str()) else { continue; }; diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 5035a02ec7..a490256c0f 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -4,15 +4,15 @@ use super::stream_processor::{StreamProcessOptions, StreamProcessor, StreamResult}; use super::types::{FinishReason, RoundContext, RoundResult}; -use crate::agentic::MessageContent; use crate::agentic::core::{Message, ToolCall}; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData}; -use crate::agentic::tools::ToolPathOperation; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::framework::{ToolPathResolution, ToolUseContext}; use crate::agentic::tools::implementations::file_write_tool::FileWriteTool; use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolExecutionOptions, ToolPipeline}; use crate::agentic::tools::registry::get_global_tool_registry; +use crate::agentic::tools::ToolPathOperation; +use crate::agentic::MessageContent; use crate::infrastructure::ai::AIClient; use crate::service::config::GlobalConfigManager; use crate::util::elapsed_ms_u64; @@ -1577,12 +1577,12 @@ fn detect_placeholder_patterns(content: &str) -> Option<&'static str> { #[cfg(test)] mod tests { - use super::{RoundExecutor, StreamProcessor, extract_bitfun_contents}; - use crate::agentic::WorkspaceBinding; + use super::{extract_bitfun_contents, RoundExecutor, StreamProcessor}; use crate::agentic::core::ToolCall; use crate::agentic::events::{EventQueue, EventQueueConfig}; use crate::agentic::execution::types::RoundContext; use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::WorkspaceBinding; use dashmap::DashMap; use std::collections::HashMap; use std::path::PathBuf; @@ -1666,12 +1666,10 @@ mod tests { let _ = std::fs::remove_dir_all(&root); - assert!( - error - .as_deref() - .unwrap_or_default() - .contains("already exists") - ); + assert!(error + .as_deref() + .unwrap_or_default() + .contains("already exists")); } #[tokio::test] diff --git a/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs index d558c6b89e..e457a8dd9e 100644 --- a/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -1222,8 +1222,7 @@ impl Tool for ControlHubTool { } fn short_description(&self) -> String { - "Control browser, terminal, and desktop helper domains through one tool." - .to_string() + "Control browser, terminal, and desktop helper domains through one tool.".to_string() } fn default_exposure(&self) -> ToolExposure { diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index 1cb522d718..720d0e66c9 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -1,10 +1,10 @@ -use crate::agentic::tools::ToolPathOperation; use crate::agentic::tools::framework::{ Tool, ToolPathResolution, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::path::Path; use tokio::fs; @@ -114,9 +114,9 @@ impl FileWriteTool { #[cfg(test)] mod tests { use super::FileWriteTool; - use crate::agentic::WorkspaceBinding; - use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::WorkspaceBinding; use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; @@ -204,12 +204,10 @@ mod tests { assert_eq!(data["success"], true); assert_eq!(data["bytes_written"], 0); assert_eq!(data["status"], "already_exists_same_content"); - assert!( - result_for_assistant - .as_deref() - .unwrap_or_default() - .contains("do not call Write for this path again") - ); + assert!(result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("do not call Write for this path again")); } #[tokio::test] diff --git a/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs b/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs index f3a6ce7139..ce2a912532 100644 --- a/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs @@ -4,8 +4,8 @@ use crate::agentic::agents::get_agent_registry; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; -use crate::agentic::tools::resolve_visible_tools; use crate::agentic::tools::registry::get_global_tool_registry; +use crate::agentic::tools::resolve_visible_tools; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use log::debug; @@ -30,13 +30,19 @@ impl GetToolSpecTool { format!( r#"Read usage instructions for additional tools. -You have access to an additional tools listed below. +You have access to the additional tools listed below. These tools are collapsed: +their names may appear in the tool list, but you must not call them directly +until you have loaded their definition with GetToolSpec. {} -Before using one of these tools, first call GetToolSpec with its exact tool name to read its full description and input schema. +Before using one of these tools, first call GetToolSpec with its exact tool name +to read its full description and input schema. If a direct call to a collapsed +tool fails with a message like "Tool 'Git' is collapsed", make the next tool +call `GetToolSpec` with `{{"tool_name":"Git"}}`, then retry the real tool after +reading the returned schema. After reading the returned definition, call the real tool directly using its own name. @@ -74,7 +80,7 @@ Example: if let Some(context) = context { if let Ok(collapsed_tools) = self.get_contextual_collapsed_tools(context).await { for tool in collapsed_tools { - entries.push(format!("- {}", tool.name())); + entries.push(format!("- {}: {}", tool.name(), tool.short_description())); } } } else { @@ -136,7 +142,9 @@ Example: .description_with_context(Some(context)) .await .unwrap_or_else(|_| format!("Tool: {}", tool.name())); - let input_schema = tool.input_schema_for_model_with_context(Some(context)).await; + let input_schema = tool + .input_schema_for_model_with_context(Some(context)) + .await; Ok(json!({ "tool_name": tool_name, @@ -181,7 +189,7 @@ impl Tool for GetToolSpecTool { "properties": { "tool_name": { "type": "string", - "description": "The exact tool name to read details for." + "description": "Exact collapsed tool name to load, using the tool's canonical casing from the catalog (for example, \"Git\"). Do not pass a command such as \"git status\" or an operation such as \"status\" here." } } }) @@ -358,10 +366,7 @@ mod tests { .await; assert!(description.contains(&format!("- {}: Concise catalog entry.", tool_name))); - assert!(!description.contains(&format!( - "- {}: Verbose description first line.", - tool_name - ))); + assert!(!description.contains(&format!("- {}: Verbose description first line.", tool_name))); } #[tokio::test] diff --git a/src/crates/core/src/agentic/tools/implementations/git_tool.rs b/src/crates/core/src/agentic/tools/implementations/git_tool.rs index b100f7d1ce..ee305639cf 100644 --- a/src/crates/core/src/agentic/tools/implementations/git_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/git_tool.rs @@ -727,6 +727,8 @@ impl Tool for GitTool { This tool provides a safe and convenient way to execute Git commands. It supports common Git operations like status, diff, log, add, commit, branch, checkout, pull, push, and more. +If this tool was collapsed earlier in the conversation, only call it after `GetToolSpec` has returned this definition. A failed direct call that says "Tool 'Git' is collapsed" means the next tool call should be `GetToolSpec` with `{"tool_name":"Git"}`; after that, retry `Git` with the schema below. + ## Supported Operations - **status**: Show working tree status @@ -832,7 +834,7 @@ When creating commits, use this format for the commit message: } fn short_description(&self) -> String { - "Inspect and operate on the Git repository.".to_string() + "Inspect and operate on the Git repository; load with GetToolSpec before first use when collapsed.".to_string() } fn default_exposure(&self) -> ToolExposure { @@ -845,12 +847,12 @@ When creating commits, use this format for the commit message: "properties": { "operation": { "type": "string", - "description": "Required Git operation/subcommand to perform (e.g., status, diff, log, add, commit, branch, checkout, pull, push). Do not omit this and do not place the subcommand in args.", + "description": "Required Git subcommand to perform. Use the bare subcommand only, such as \"status\", \"diff\", \"log\", \"add\", or \"commit\". Do not send a raw command like \"git status\". Do not omit this field, and do not place the subcommand in args.", "enum": ALLOWED_OPERATIONS }, "args": { "type": "string", - "description": "Only additional arguments for the selected Git operation (e.g., file paths, flags, options). Do not include the operation/subcommand itself here." + "description": "Only additional arguments for the selected operation: flags, refs, commit messages, or file paths. Examples: \"--staged\", \"--oneline -10\", \"-m \\\"message\\\"\", or \"-- src/file.rs\". Do not include \"git\" or repeat the operation/subcommand here." }, "working_directory": { "type": "string", @@ -1175,7 +1177,7 @@ mod tests { assert!(schema["properties"]["args"]["description"] .as_str() .unwrap() - .contains("Do not include the operation")); + .contains("Do not include \"git\" or repeat the operation")); let validation = tool .validate_input(&json!({"args": "--since=\"2026-05-02\" --oneline"}), None) diff --git a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs index 4d44ee8005..9d6f24f4b4 100644 --- a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs @@ -1,19 +1,19 @@ use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::service::search::{ - ContentSearchOutputMode, ContentSearchRequest, WorkspaceSearchHit, WorkspaceSearchLine, get_global_workspace_search_service, remote_workspace_search_service_for_path, - workspace_search_feature_enabled, workspace_search_runtime_available, + workspace_search_feature_enabled, workspace_search_runtime_available, ContentSearchOutputMode, + ContentSearchRequest, WorkspaceSearchHit, WorkspaceSearchLine, }; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::collections::HashSet; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use std::time::Instant; use tool_runtime::search::grep_search::{ - GrepOptions, GrepSearchResult, OutputMode, ProgressCallback, grep_search, + grep_search, GrepOptions, GrepSearchResult, OutputMode, ProgressCallback, }; const DEFAULT_HEAD_LIMIT: usize = 250; @@ -551,8 +551,8 @@ fn apply_offset_and_limit(items: &mut Vec, offset: usize, head_limit: Op #[cfg(test)] mod tests { use super::{ - DEFAULT_HEAD_LIMIT, GrepTool, render_workspace_search_content_lines, - render_workspace_search_result_lines, + render_workspace_search_content_lines, render_workspace_search_result_lines, GrepTool, + DEFAULT_HEAD_LIMIT, }; use crate::infrastructure::{FileSearchOutcome, FileSearchResult, SearchMatchType}; use crate::service::search::{ diff --git a/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs b/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs index b7d239df5f..56d07829de 100644 --- a/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs +++ b/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs @@ -594,8 +594,7 @@ impl Tool for GetMCPPromptTool { } fn short_description(&self) -> String { - "Fetch and render a named MCP prompt template from a connected MCP server." - .to_string() + "Fetch and render a named MCP prompt template from a connected MCP server.".to_string() } fn default_exposure(&self) -> ToolExposure { diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index 6436bcb831..498dd7f61c 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -21,6 +21,7 @@ pub mod file_read_tool; pub mod file_write_tool; pub mod generative_ui_tool; pub mod get_file_diff_tool; +pub mod get_tool_spec_tool; pub mod git_tool; pub mod glob_tool; pub mod grep_tool; @@ -37,7 +38,6 @@ pub mod skills; pub mod task_tool; pub mod terminal_control_tool; pub mod todo_write_tool; -pub mod get_tool_spec_tool; pub mod util; pub mod web_tools; @@ -57,6 +57,7 @@ pub use file_read_tool::FileReadTool; pub use file_write_tool::FileWriteTool; pub use generative_ui_tool::GenerativeUITool; pub use get_file_diff_tool::GetFileDiffTool; +pub use get_tool_spec_tool::GetToolSpecTool; pub use git_tool::GitTool; pub use glob_tool::GlobTool; pub use grep_tool::GrepTool; @@ -74,5 +75,4 @@ pub use skill_tool::SkillTool; pub use task_tool::TaskTool; pub use terminal_control_tool::TerminalControlTool; pub use todo_write_tool::TodoWriteTool; -pub use get_tool_spec_tool::GetToolSpecTool; pub use web_tools::{WebFetchTool, WebSearchTool}; diff --git a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs index b25200ef76..274d835d16 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs @@ -181,8 +181,7 @@ When overriding an existing session's agent_type, only switching between "agenti } fn short_description(&self) -> String { - "Send a message to another agent session and receive the result asynchronously." - .to_string() + "Send a message to another agent session and receive the result asynchronously.".to_string() } fn default_exposure(&self) -> ToolExposure { diff --git a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs index ac50fb27e3..f928700190 100644 --- a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs @@ -269,10 +269,8 @@ impl Default for SkillTool { #[cfg(test)] mod tests { use super::SkillTool; - use crate::agentic::tools::implementations::skills::{ - registry::SkillRegistry, SkillLocation, - }; use crate::agentic::tools::framework::{Tool, ToolResult}; + use crate::agentic::tools::implementations::skills::{registry::SkillRegistry, SkillLocation}; use crate::agentic::workspace::{ WorkspaceCommandOptions, WorkspaceCommandResult, WorkspaceDirEntry, WorkspaceFileSystem, WorkspaceServices, WorkspaceShell, @@ -457,18 +455,18 @@ Use the remote project skill. async fn read_file_text(&self, path: &str) -> anyhow::Result { match path { - "/remote/project/.bitfun/skills/z-last/SKILL.md" => Ok( - "---\nname: z-last\ndescription: last\n---\n\nz\n".to_string(), - ), - "/remote/project/.bitfun/skills/a-first/SKILL.md" => Ok( - "---\nname: A-First\ndescription: first\n---\n\na\n".to_string(), - ), - "/remote/project/.bitfun/skills/dup-one/SKILL.md" => Ok( - "---\nname: dup\ndescription: dup one\n---\n\none\n".to_string(), - ), - "/remote/project/.bitfun/skills/dup-two/SKILL.md" => Ok( - "---\nname: dup\ndescription: dup two\n---\n\ntwo\n".to_string(), - ), + "/remote/project/.bitfun/skills/z-last/SKILL.md" => { + Ok("---\nname: z-last\ndescription: last\n---\n\nz\n".to_string()) + } + "/remote/project/.bitfun/skills/a-first/SKILL.md" => { + Ok("---\nname: A-First\ndescription: first\n---\n\na\n".to_string()) + } + "/remote/project/.bitfun/skills/dup-one/SKILL.md" => { + Ok("---\nname: dup\ndescription: dup one\n---\n\none\n".to_string()) + } + "/remote/project/.bitfun/skills/dup-two/SKILL.md" => { + Ok("---\nname: dup\ndescription: dup two\n---\n\ntwo\n".to_string()) + } _ => anyhow::bail!("not found: {}", path), } } diff --git a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs index ff9f445e19..b1d811a008 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs @@ -182,7 +182,9 @@ fn sort_resolved_skill_candidates(mut resolved: Vec) -> Vec) -> Vec { +fn sort_skill_candidates_for_resolution( + mut candidates: Vec, +) -> Vec { candidates.sort_by(|a, b| { skill_candidate_precedence(a) .cmp(&skill_candidate_precedence(b)) @@ -206,7 +208,8 @@ fn resolve_visible_skills(candidates: Vec) -> Vec { for candidate in sort_skill_candidates_for_resolution(candidates) { match by_name.get(&candidate.info.name) { Some(existing) - if skill_candidate_precedence(existing) <= skill_candidate_precedence(&candidate) => {} + if skill_candidate_precedence(existing) + <= skill_candidate_precedence(&candidate) => {} _ => { by_name.insert(candidate.info.name.clone(), candidate); } diff --git a/src/crates/core/src/agentic/tools/manifest_resolver.rs b/src/crates/core/src/agentic/tools/manifest_resolver.rs index cd5f364dc1..6c73752709 100644 --- a/src/crates/core/src/agentic/tools/manifest_resolver.rs +++ b/src/crates/core/src/agentic/tools/manifest_resolver.rs @@ -132,8 +132,10 @@ pub async fn resolve_tool_manifest( for tool in &visible_tools.collapsed_tools { let description = format!( - "{} [This tool is collapsed. Call `GetToolSpec` first with {{\"tool_name\":\"{}\"}} before using it.]", + "{} [This tool is collapsed. Do not call `{}` directly yet. First call `GetToolSpec` with {{\"tool_name\":\"{}\"}} to load its full description and input schema, then retry `{}` using the returned schema.]", tool.short_description(), + tool.name(), + tool.name(), tool.name() ); @@ -142,8 +144,17 @@ pub async fn resolve_tool_manifest( description, parameters: json!({ "type": "object", - "properties": {}, - "additionalProperties": true + "additionalProperties": false, + "properties": { + "tool_name": { + "type": "string", + "description": format!( + "Do not supply {} arguments here while the tool is collapsed. Use GetToolSpec with {{\"tool_name\":\"{}\"}} first.", + tool.name(), + tool.name() + ) + } + } }), }); } @@ -255,9 +266,13 @@ mod tests { .iter() .find(|tool| tool.name == "WebFetch") .expect("WebFetch stub should exist"); - assert!(stub.description.contains("Call `GetToolSpec` first")); + assert!(stub.description.contains("First call `GetToolSpec`")); assert_eq!(stub.parameters["type"], json!("object")); - assert_eq!(stub.parameters["additionalProperties"], json!(true)); + assert_eq!(stub.parameters["additionalProperties"], json!(false)); + assert!(stub.parameters["properties"]["tool_name"]["description"] + .as_str() + .unwrap() + .contains("{\"tool_name\":\"WebFetch\"}")); } #[tokio::test] @@ -309,13 +324,18 @@ mod tests { .expect("collapsed WebFetch stub"); assert!(web_fetch .description - .contains("Call `GetToolSpec` first with {\"tool_name\":\"WebFetch\"}")); + .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}")); assert_eq!( web_fetch.parameters, json!({ "type": "object", - "properties": {}, - "additionalProperties": true + "additionalProperties": false, + "properties": { + "tool_name": { + "type": "string", + "description": "Do not supply WebFetch arguments here while the tool is collapsed. Use GetToolSpec with {\"tool_name\":\"WebFetch\"} first." + } + } }) ); } diff --git a/src/crates/core/src/agentic/tools/mod.rs b/src/crates/core/src/agentic/tools/mod.rs index b00ef750c3..7b568b62f2 100644 --- a/src/crates/core/src/agentic/tools/mod.rs +++ b/src/crates/core/src/agentic/tools/mod.rs @@ -20,7 +20,9 @@ pub use bitfun_agent_tools::input_validator; pub use framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; pub use image_context::{ImageContextData, ImageContextProvider, ImageContextProviderRef}; pub use input_validator::InputValidator; -pub use manifest_resolver::{resolve_tool_manifest, resolve_visible_tools, ResolvedToolManifest, ResolvedVisibleTools}; +pub use manifest_resolver::{ + resolve_tool_manifest, resolve_visible_tools, ResolvedToolManifest, ResolvedVisibleTools, +}; pub use pipeline::*; pub use registry::{ create_tool_registry, get_all_registered_tool_names, get_all_registered_tools, get_all_tools, diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index c8bfc2e518..2782986687 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -239,7 +239,6 @@ impl ToolRegistry { ); self.inner.get_all_tools() } - } #[async_trait::async_trait] diff --git a/src/crates/core/src/miniapp/builtin/mod.rs b/src/crates/core/src/miniapp/builtin/mod.rs index 7fe8a50fdd..b7e44d7fa8 100644 --- a/src/crates/core/src/miniapp/builtin/mod.rs +++ b/src/crates/core/src/miniapp/builtin/mod.rs @@ -392,7 +392,9 @@ mod tests { assert_eq!(meta["permissions"]["notifications"]["system"], true); assert!(meta["permissions"]["fs"]["read"] .as_array() - .is_some_and(|read| read.iter().any(|value| value.as_str() == Some("{workspace}")))); + .is_some_and(|read| read + .iter() + .any(|value| value.as_str() == Some("{workspace}")))); assert!(meta["permissions"]["shell"]["allow"] .as_array() .is_some_and(|allow| allow.iter().any(|value| value.as_str() == Some("gh")))); @@ -457,7 +459,9 @@ mod tests { assert!(app.css.contains("pr-listen-switch")); assert!(app.css.contains("pr-token-details")); assert!(app.css.contains("pr-text-btn")); - assert!(!app.ui_js.contains("value=\"${esc(state.volatile.sessionTokens")); + assert!(!app + .ui_js + .contains("value=\"${esc(state.volatile.sessionTokens")); assert!(!app.css.contains("background: #0f1114")); assert!(!app.css.contains("background: rgba(23, 25, 28")); } @@ -473,7 +477,9 @@ mod tests { assert!(app.ui_js.contains("function restoreReviewWorkspaceScroll")); assert!(app.ui_js.contains("function render(options = {})")); assert!(app.ui_js.contains("options.preserveReviewWorkspaceScroll")); - assert!(app.ui_js.contains("render({ preserveReviewWorkspaceScroll: true })")); + assert!(app + .ui_js + .contains("render({ preserveReviewWorkspaceScroll: true })")); } #[test] @@ -484,9 +490,13 @@ mod tests { .expect("PR Review must be delivered as a built-in MiniApp"); assert!(app.ui_js.contains("function buildReviewOperations")); - assert!(app.ui_js.contains("Do not create a general summary comment")); + assert!(app + .ui_js + .contains("Do not create a general summary comment")); assert!(app.ui_js.contains("summaryComment only when")); - assert!(!app.ui_js.contains("id: `summary-${snapshot.headSha || Date.now()}`")); + assert!(!app + .ui_js + .contains("id: `summary-${snapshot.headSha || Date.now()}`")); assert!(app.ui_js.contains("function renderDraftStateChip")); assert!(app.ui_js.contains("pr-overview-fold")); assert!(app.css.contains(".pr-chip.is-draft")); diff --git a/src/crates/core/src/service/config/manager.rs b/src/crates/core/src/service/config/manager.rs index cbba31b90f..7575f2784b 100644 --- a/src/crates/core/src/service/config/manager.rs +++ b/src/crates/core/src/service/config/manager.rs @@ -4,7 +4,7 @@ use super::providers::ConfigProviderRegistry; use super::types::*; -use crate::infrastructure::{PathManager, try_get_path_manager_arc}; +use crate::infrastructure::{try_get_path_manager_arc, PathManager}; use crate::util::errors::*; use log::{debug, info, warn}; diff --git a/src/crates/core/src/service/config/mode_config_canonicalizer.rs b/src/crates/core/src/service/config/mode_config_canonicalizer.rs index 9fe774dafd..582f33442a 100644 --- a/src/crates/core/src/service/config/mode_config_canonicalizer.rs +++ b/src/crates/core/src/service/config/mode_config_canonicalizer.rs @@ -491,13 +491,8 @@ mod tests { #[test] fn canonicalize_mode_config_treats_null_as_missing() { - let canonical = canonicalize_mode_config( - "Claw", - Some(&Value::Null), - &[], - &HashSet::new(), - ) - .expect("null mode config should be ignored"); + let canonical = canonicalize_mode_config("Claw", Some(&Value::Null), &[], &HashSet::new()) + .expect("null mode config should be ignored"); assert!(canonical.is_none()); } diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index 88e5f6ff55..8396c920f0 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -7,7 +7,9 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -fn deserialize_mode_configs<'de, D>(deserializer: D) -> Result, D::Error> +fn deserialize_mode_configs<'de, D>( + deserializer: D, +) -> Result, D::Error> where D: serde::Deserializer<'de>, { @@ -167,7 +169,10 @@ pub struct AIExperienceConfig { /// Where to show the Agent companion: "input" or "desktop". pub agent_companion_display_mode: String, /// Optional Petdex-compatible companion package selected by the user. - #[serde(default = "default_agent_companion_pet", skip_serializing_if = "Option::is_none")] + #[serde( + default = "default_agent_companion_pet", + skip_serializing_if = "Option::is_none" + )] pub agent_companion_pet: Option, /// Whether to enable flashgrep-backed accelerated workspace search. pub enable_workspace_search: bool, @@ -1999,10 +2004,7 @@ mod tests { assert!(!review_team.auto_fix_enabled); assert_eq!(review_team.strategy_level, "normal"); assert!(review_team.member_strategy_overrides.is_empty()); - assert_eq!( - config.review_team_rate_limit_status, - serde_json::json!({}) - ); + assert_eq!(config.review_team_rate_limit_status, serde_json::json!({})); assert!(config.review_team_project_strategy_overrides.is_empty()); } @@ -2180,8 +2182,8 @@ mod tests { Some(&"quick".to_string()) ); - let serialized = serde_json::to_value(&config) - .expect("review team auxiliary config should serialize"); + let serialized = + serde_json::to_value(&config).expect("review team auxiliary config should serialize"); assert!(serialized["review_teams"]["rate_limit_status"].is_null()); assert_eq!( serialized["review_team_project_strategy_overrides"]["d:/workspace/repo"], diff --git a/src/crates/core/src/service/remote_ssh/manager.rs b/src/crates/core/src/service/remote_ssh/manager.rs index 4b7a744a27..e42bd6c968 100644 --- a/src/crates/core/src/service/remote_ssh/manager.rs +++ b/src/crates/core/src/service/remote_ssh/manager.rs @@ -13,8 +13,8 @@ use russh::client::{DisconnectReason, Handle, Handler, Msg}; use russh::Sig; use russh_keys::key::PublicKey; use russh_keys::PublicKeyBase64; -use russh_sftp::client::SftpSession; use russh_sftp::client::fs::ReadDir; +use russh_sftp::client::SftpSession; #[cfg(feature = "ssh_config")] use ssh_config::SSHConfig; use std::collections::HashMap; diff --git a/src/crates/core/src/service/remote_ssh/password_vault.rs b/src/crates/core/src/service/remote_ssh/password_vault.rs index 9257fcd6c3..14b2b30bb6 100644 --- a/src/crates/core/src/service/remote_ssh/password_vault.rs +++ b/src/crates/core/src/service/remote_ssh/password_vault.rs @@ -230,13 +230,11 @@ mod tests { vault.load("ssh-root@example.com").await.unwrap().as_deref(), Some("secret") ); - assert!( - vault - .load("ssh-root@example.com:22") - .await - .unwrap() - .is_none() - ); + assert!(vault + .load("ssh-root@example.com:22") + .await + .unwrap() + .is_none()); let _ = tokio::fs::remove_dir_all(&dir).await; } diff --git a/src/crates/core/src/service/search/flashgrep/client.rs b/src/crates/core/src/service/search/flashgrep/client.rs index 0bdde563b4..913cf0ab91 100644 --- a/src/crates/core/src/service/search/flashgrep/client.rs +++ b/src/crates/core/src/service/search/flashgrep/client.rs @@ -19,7 +19,7 @@ use tokio::{ use super::{ error::{AppError, Result}, - log_flashgrep_stderr_line, FLASHGREP_LOG_TARGET, + log_flashgrep_stderr_line, protocol::{ ClientCapabilities, ClientInfo, GlobParams, InitializeParams, RepoRef, Request, Response, SearchParams, TaskRef, @@ -30,6 +30,7 @@ use super::{ GlobOutcome, GlobRequest, OpenRepoParams, RepoStatus, SearchOutcome, SearchRequest, TaskStatus, }, + FLASHGREP_LOG_TARGET, }; const CLIENT_NAME: &str = "bitfun-workspace-search"; diff --git a/src/crates/core/src/service/search/flashgrep/mod.rs b/src/crates/core/src/service/search/flashgrep/mod.rs index 35638032bf..a52446ae71 100644 --- a/src/crates/core/src/service/search/flashgrep/mod.rs +++ b/src/crates/core/src/service/search/flashgrep/mod.rs @@ -49,7 +49,7 @@ pub(crate) use protocol::{ RepoRef, Request, Response, SearchHit, SearchLine, SearchParams, TaskRef, }; pub(crate) use repo_session::FlashgrepRepoSession; -pub(crate) use rpc_client::{ProtocolClient, drain_content_length_messages}; +pub(crate) use rpc_client::{drain_content_length_messages, ProtocolClient}; pub(crate) use types::{ ConsistencyMode, DirtyFileStats, FileCount, GlobOutcome, GlobRequest, OpenRepoParams, PathScope, QuerySpec, RefreshPolicyConfig, RepoConfig, RepoPhase, RepoStatus, SearchBackend, diff --git a/src/crates/core/src/service/search/service.rs b/src/crates/core/src/service/search/service.rs index 26b7ae558b..e8b8ad9ef3 100644 --- a/src/crates/core/src/service/search/service.rs +++ b/src/crates/core/src/service/search/service.rs @@ -2,17 +2,17 @@ use crate::infrastructure::{FileSearchOutcome, FileSearchResult, SearchMatchType use crate::service::bootstrap::ensure_workspace_gitignore_ignores_bitfun; use crate::service::config::{get_global_config_service, types::WorkspaceConfig}; use crate::service::search::flashgrep::{ - ConsistencyMode, FLASHGREP_LOG_TARGET, FlashgrepRepoSession, GlobRequest, ManagedClient, - OpenRepoParams, PathScope, QuerySpec, RefreshPolicyConfig, RepoConfig, RepoSession, - SearchRequest, SearchResults, + ConsistencyMode, FlashgrepRepoSession, GlobRequest, ManagedClient, OpenRepoParams, PathScope, + QuerySpec, RefreshPolicyConfig, RepoConfig, RepoSession, SearchRequest, SearchResults, + FLASHGREP_LOG_TARGET, }; use crate::util::errors::{BitFunError, BitFunResult}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::{ - Arc, LazyLock, Mutex as StdMutex, Weak, atomic::{AtomicU64, Ordering}, + Arc, LazyLock, Mutex as StdMutex, Weak, }; use std::time::{Duration, Instant}; use tokio::sync::{Mutex, RwLock}; diff --git a/src/crates/services-integrations/src/mcp/auth.rs b/src/crates/services-integrations/src/mcp/auth.rs index aafc447734..62eca8c244 100644 --- a/src/crates/services-integrations/src/mcp/auth.rs +++ b/src/crates/services-integrations/src/mcp/auth.rs @@ -9,7 +9,7 @@ use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; use anyhow::{Context, Result}; use async_trait::async_trait; -use base64::{Engine, engine::general_purpose::STANDARD as B64}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; use rand::RngCore; use rmcp::transport::auth::{AuthorizationManager, CredentialStore, OAuthState, StoredCredentials}; use serde::{Deserialize, Serialize}; diff --git a/src/web-ui/src/app/scenes/session/SessionScene.scss b/src/web-ui/src/app/scenes/session/SessionScene.scss index 12c5b920d2..b10c4eaf96 100644 --- a/src/web-ui/src/app/scenes/session/SessionScene.scss +++ b/src/web-ui/src/app/scenes/session/SessionScene.scss @@ -129,7 +129,7 @@ top: 50%; transform: translate(-50%, -50%); color: rgba(88, 166, 255, 0.9); - font-size: 13px; + font-size: var(--flowchat-font-size-sm); font-weight: 600; pointer-events: none; z-index: 101; diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.scss b/src/web-ui/src/component-library/components/Markdown/Markdown.scss index 7f668c5c4f..d0dae20577 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.scss +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.scss @@ -1,32 +1,61 @@ /* Markdown renderer styles */ + .markdown-renderer { - --markdown-font-mono: "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace; - --markdown-block-gap: 0.65rem; + --markdown-font-sans: var(--font-family-sans); + --markdown-font-mono: var(--font-family-mono, "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace); + --markdown-font-heading: var(--markdown-font-sans); + --markdown-font-size: var(--flowchat-font-size-base, var(--font-size-base, 14px)); + --markdown-font-size-sm: var(--flowchat-font-size-sm, var(--font-size-sm, 13px)); + --markdown-code-font-size: var(--flowchat-code-font-size, var(--markdown-font-size-sm)); + --markdown-line-height: var(--flowchat-text-line-height, 1.58); + --markdown-support-line-height: var(--flowchat-support-line-height, 1.45); + --markdown-code-line-height: var(--flowchat-code-line-height, 1.52); + --markdown-block-gap: var(--flowchat-markdown-block-gap, 0.58em); + --markdown-paragraph-gap: var(--flowchat-markdown-paragraph-gap, 0.28em); + --markdown-list-item-gap: var(--flowchat-markdown-list-item-gap, 0.28em); + --markdown-list-nested-gap: var(--flowchat-markdown-list-nested-gap, 0.22em); + --markdown-heading-gap-before: var(--flowchat-markdown-heading-gap-before, 0.86em); + --markdown-heading-gap-after: var(--flowchat-markdown-heading-gap-after, 0.36em); + --markdown-code-block-gap: var(--flowchat-markdown-code-block-gap, 0.72em); + --markdown-code-block-pad-y: var(--flowchat-markdown-code-block-pad-y, 0.78rem); + --markdown-code-block-pad-x: var(--flowchat-markdown-code-block-pad-x, 0.86rem); + --markdown-table-cell-pad-y: var(--flowchat-markdown-table-cell-pad-y, 0.52rem); + --markdown-table-cell-pad-x: var(--flowchat-markdown-table-cell-pad-x, 0.72rem); --markdown-code-bg-elevated: color-mix(in srgb, var(--color-bg-primary) 92%, #ffffff 8%); + --markdown-inline-code-bg: rgba(255, 255, 255, 0.045); + --markdown-inline-code-border: rgba(255, 255, 255, 0.08); + --markdown-pre-bg: var(--color-bg-primary); + --markdown-pre-border: var(--border-color, rgba(255, 255, 255, 0.09)); + --markdown-blockquote-bg: rgba(255, 255, 255, 0.025); + --markdown-blockquote-border: rgba(255, 255, 255, 0.16); + --markdown-table-border: var(--flowchat-md-table-border, rgba(255, 255, 255, 0.12)); + --markdown-table-surface: var(--flowchat-md-table-surface, transparent); + --markdown-table-header-bg: var(--flowchat-md-table-th-bg, rgba(255, 255, 255, 0.03)); + --markdown-table-row-stripe: var(--flowchat-md-table-row-stripe, rgba(255, 255, 255, 0.02)); + --markdown-table-row-hover: var(--flowchat-md-table-hover, rgba(255, 255, 255, 0.04)); --markdown-link-color: var(--flowchat-link-color, #60a5fa); --markdown-link-hover-color: var(--flowchat-link-hover-color, #93c5fd); - color: var(--color-text-primary); - line-height: var(--line-height-relaxed); - font-family: var(--font-family-sans); - font-size: 0.9rem; - word-wrap: break-word; - max-width: 100%; width: 100%; + max-width: 100%; margin: 0; padding: 0; - animation: fadeIn var(--motion-normal) ease-out; + color: var(--color-text-primary); + font-family: var(--markdown-font-sans); + font-size: var(--markdown-font-size); + line-height: var(--markdown-line-height); + letter-spacing: 0; text-align: left; + word-wrap: break-word; cursor: text; user-select: text; + animation: fadeIn var(--motion-normal, 0.2s) ease-out; } - -.markdown-renderer code { - display: inline; +.markdown-renderer > * { + margin-block: 0; } - .markdown-renderer > *:first-child { margin-top: 0 !important; } @@ -35,288 +64,283 @@ margin-bottom: 0 !important; } - .markdown-renderer > * + * { margin-top: var(--markdown-block-gap); } - -.markdown-renderer ul + *, -.markdown-renderer ol + * { - margin-top: 0.5rem; -} - - -.markdown-renderer p + ul, -.markdown-renderer p + ol { - margin-top: 0.25rem; -} - - -.markdown-renderer p + p { - margin-top: 0.5rem; -} - - -.markdown-renderer li + li { - margin-top: 0.05rem; -} - - -.markdown-renderer li > * { - margin-top: 0; - margin-bottom: 0.05rem; -} - -.markdown-renderer li > *:last-child { - margin-bottom: 0; -} - - .markdown-renderer p { - margin-top: 0; - margin-bottom: 0.65rem; display: block; - line-height: 1.62; - font-size: 0.9rem; color: var(--color-text-primary); + font-size: inherit; + line-height: inherit; } -.markdown-renderer p code { - display: inline !important; +.markdown-renderer p + p { + margin-top: var(--markdown-paragraph-gap); } - .markdown-renderer h1, .markdown-renderer h2, .markdown-renderer h3, .markdown-renderer h4, .markdown-renderer h5, .markdown-renderer h6 { - font-family: var(--font-family-sans); + margin: var(--markdown-heading-gap-before) 0 var(--markdown-heading-gap-after); + padding: 0; + border: 0; color: var(--color-text-primary); - margin-top: 1.5rem; - margin-bottom: 0.75rem; - font-weight: var(--font-weight-semibold); - line-height: var(--line-height-tight); - letter-spacing: 0.015em; + font-family: var(--markdown-font-heading); + font-weight: var(--font-weight-semibold, 600); + letter-spacing: 0; + line-height: var(--markdown-support-line-height); } - .markdown-renderer h1 { - font-size: 1.5rem; - font-weight: 600; - text-align: left; - margin: 1.5rem 0 1rem 0; - padding: 0 0 0.6rem 0; - color: var(--color-text-primary); - border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.12)); + font-size: calc(var(--markdown-font-size) + 2px); } - .markdown-renderer h2 { - font-size: 1.2rem; - font-weight: 600; - text-align: left; - margin: 1.25rem 0 0.75rem 0; - padding-bottom: 0.4rem; - color: var(--color-text-primary); - border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1)); + font-size: calc(var(--markdown-font-size) + 1px); } - .markdown-renderer h3 { - font-size: 1.05rem; - font-weight: 600; - margin: 1rem 0 0.6rem 0; - color: var(--color-text-primary); + font-size: var(--markdown-font-size); } - -.markdown-renderer h4 { - font-size: 0.95rem; - font-weight: 600; - margin: 0.85rem 0 0.5rem 0; - color: var(--color-text-secondary); +.markdown-renderer h4, +.markdown-renderer h5, +.markdown-renderer h6 { + font-size: var(--markdown-font-size); } - +.markdown-renderer h4, .markdown-renderer h5 { - font-size: 0.9rem; - font-weight: 600; color: var(--color-text-secondary); - margin: 0.75rem 0 0.5rem 0; } - .markdown-renderer h6 { - font-size: 0.85rem; - font-weight: 600; - font-style: normal; color: var(--color-text-muted); - margin: 0.6rem 0 0.4rem 0; - opacity: 0.85; } +.markdown-renderer p + h1, +.markdown-renderer p + h2, +.markdown-renderer p + h3, +.markdown-renderer p + h4, +.markdown-renderer p + h5, +.markdown-renderer p + h6, +.markdown-renderer ul + h1, +.markdown-renderer ul + h2, +.markdown-renderer ul + h3, +.markdown-renderer ol + h1, +.markdown-renderer ol + h2, +.markdown-renderer ol + h3 { + margin-top: calc(var(--markdown-heading-gap-before) * 1.12); +} .markdown-renderer ul, .markdown-renderer ol { - margin-top: 0; - margin-bottom: 0.5rem; - padding-left: 1.5rem; - line-height: 1.5; - font-size: 0.9rem; + margin: 0; + padding-left: 1.35rem; + color: var(--color-text-primary); + font-size: inherit; + line-height: inherit; } .markdown-renderer li { + margin: 0; + color: inherit; + font-size: inherit; + line-height: inherit; word-wrap: break-word; - margin-bottom: 0.1rem; - position: relative; - line-height: 1.5; - color: var(--color-text-primary); } -.markdown-renderer li:last-child { - margin-bottom: 0; +.markdown-renderer li + li { + margin-top: var(--markdown-list-item-gap); } - -.markdown-renderer li p { +.markdown-renderer li > * { margin-top: 0; - margin-bottom: 0.1rem; -} - -.markdown-renderer li p:last-child { margin-bottom: 0; } - -.markdown-renderer ul > li::marker { - color: #888888; - font-size: 1.2em; - font-weight: bold; +.markdown-renderer li > * + * { + margin-top: var(--markdown-list-nested-gap); } - -.markdown-renderer ol > li::marker { - color: #888888; - font-weight: bold; +.markdown-renderer li > p { + display: inline; } .markdown-renderer ul ul, .markdown-renderer ol ol, .markdown-renderer ul ol, .markdown-renderer ol ul { - margin-top: 0.1rem; + margin-top: var(--markdown-list-nested-gap); margin-bottom: 0; padding-left: 1.2rem; } +.markdown-renderer ul > li::marker, +.markdown-renderer ol > li::marker { + color: var(--color-text-muted); + font-size: 1em; + font-weight: 400; +} + +.markdown-renderer code { + display: inline; + font-family: var(--markdown-font-mono); +} .markdown-renderer .inline-code { - padding: 0.12em 0.38em; - margin: 0 0.04em; - font-size: 0.88em; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.07); + padding: 0.08em 0.28em; + margin: 0 0.03em; + border: 1px solid var(--markdown-inline-code-border); border-radius: 5px; + background: var(--markdown-inline-code-bg); + color: color-mix(in srgb, var(--color-text-primary) 88%, #a8b4c8 12%); font-family: var(--markdown-font-mono); + font-size: 0.88em; + font-weight: 500; + line-height: var(--markdown-support-line-height); white-space: nowrap; vertical-align: baseline; - line-height: 1.45; user-select: text !important; - color: color-mix(in srgb, var(--color-text-primary) 88%, #a8b4c8 12%); - font-weight: 500; transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; box-decoration-break: clone; -webkit-box-decoration-break: clone; } +.markdown-renderer .inline-code:hover { + background: rgba(255, 255, 255, 0.075); + border-color: rgba(255, 255, 255, 0.14); + color: color-mix(in srgb, var(--color-text-primary) 92%, #c5d0e2 8%); +} -.markdown-renderer pre code { - padding: 0; +.markdown-renderer pre { + margin: var(--markdown-code-block-gap) 0; + padding: var(--markdown-code-block-pad-y) var(--markdown-code-block-pad-x); + overflow: auto; + border: 1px solid var(--markdown-pre-border); + border-radius: var(--flowchat-card-radius, 8px); + background: var(--markdown-pre-bg); + box-shadow: none; + color: var(--color-text-secondary); + font-size: var(--markdown-code-font-size); + line-height: var(--markdown-code-line-height); + transition: border-color 0.2s ease; +} + +.markdown-renderer pre:hover { + border-color: color-mix(in srgb, var(--markdown-pre-border) 72%, var(--primary-color, #3b82f6) 28%); +} + +.markdown-renderer pre code, +.markdown-renderer pre > .inline-code { + display: block; margin: 0; - background: transparent; - border: none; + padding: 0; + overflow: visible; + border: 0; border-radius: 0; - font-size: inherit; - + background: transparent; + box-shadow: none; + color: inherit; + font-family: var(--markdown-font-mono); + font-size: inherit !important; font-weight: inherit; + line-height: inherit; + white-space: pre; + word-wrap: normal; + user-select: text !important; } +.markdown-renderer pre code:hover { + background: transparent; +} .markdown-renderer .code-block-wrapper { display: flex; flex-direction: column; - margin: 0.75rem 0; - border-radius: 8px; + margin: var(--markdown-code-block-gap) 0; + overflow: hidden; + border: 1px solid var(--markdown-pre-border); + border-radius: var(--flowchat-card-radius, 8px); background: var(--markdown-code-bg-elevated); - border: 1px solid var(--border-color, rgba(255, 255, 255, 0.09)); box-shadow: none; - overflow: hidden; - position: relative; transition: border-color 0.2s ease; } - .markdown-renderer .code-block-wrapper:hover { - border-color: color-mix(in srgb, var(--border-color, rgba(255, 255, 255, 0.09)) 70%, var(--primary-color, #3b82f6) 30%); - box-shadow: none; + border-color: color-mix(in srgb, var(--markdown-pre-border) 72%, var(--primary-color, #3b82f6) 28%); } - .markdown-renderer .code-block-toolbar { display: flex; align-items: center; justify-content: space-between; - gap: 0.5rem; - min-height: 2.25rem; - padding: 0.35rem 0.5rem 0.35rem 0.75rem; + gap: var(--flowchat-control-gap, 0.5rem); + min-height: 1.86rem; + padding: 0.26rem 0.45rem 0.26rem 0.62rem; flex-shrink: 0; + border-bottom: 1px solid var(--markdown-pre-border); background: color-mix(in srgb, var(--color-bg-primary) 94%, #ffffff 6%); - border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); } - .markdown-renderer .code-block-lang { - font-family: var(--font-family-sans); - font-size: 0.6875rem; + color: var(--color-text-muted); + font-family: var(--markdown-font-sans); + font-size: var(--flowchat-font-size-xxs, 10px); font-weight: 600; - letter-spacing: 0.06em; + letter-spacing: 0.04em; + line-height: var(--flowchat-compact-line-height, 1.32); text-transform: uppercase; - color: var(--color-text-muted); user-select: none; } - .markdown-renderer .code-block-body { min-width: 0; overflow-x: auto; } +.markdown-renderer .code-block-wrapper pre[class*="language-"], +.markdown-renderer .code-block-wrapper pre[style] { + margin: 0 !important; + padding: var(--markdown-code-block-pad-y) var(--markdown-code-block-pad-x) !important; + border: none !important; + border-radius: 0 !important; + background: var(--markdown-pre-bg) !important; + box-shadow: none !important; + font-size: var(--markdown-code-font-size) !important; + line-height: var(--markdown-code-line-height) !important; +} + +.markdown-renderer .code-block-wrapper pre code, +.markdown-renderer .code-block-wrapper code[style] { + background: var(--markdown-pre-bg) !important; + border: none !important; + font-family: var(--markdown-font-mono) !important; + font-size: var(--markdown-code-font-size) !important; + line-height: var(--markdown-code-line-height) !important; +} .markdown-renderer .copy-button { position: relative; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; flex-shrink: 0; - padding: 0.35rem; - background: transparent; + width: 28px; + height: 28px; + padding: 0.3rem; border: none; border-radius: 6px; + background: transparent; color: var(--color-text-muted); cursor: pointer; - transition: color 0.2s ease, background-color 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; opacity: 0.9; - z-index: 10; -} - - -.markdown-renderer .copy-button.copy-success { - color: #22c55e; + transition: color 0.2s ease, background-color 0.2s ease, opacity 0.2s ease; } .markdown-renderer .code-block-wrapper:hover .copy-button { @@ -332,147 +356,75 @@ color: #2563eb; } - -.markdown-renderer .code-block-wrapper pre[class*="language-"] { - margin: 0 !important; - border: none !important; - border-radius: 0 0 8px 8px !important; - padding: 1rem 1rem 1rem 0.75rem !important; - background: var(--color-bg-primary) !important; - box-shadow: none !important; - font-size: 0.875rem !important; -} - - -.markdown-renderer .code-block-wrapper pre code { - font-family: var(--markdown-font-mono, "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace) !important; - background: var(--color-bg-primary) !important; - font-size: 0.875rem !important; -} - - -.markdown-renderer .code-block-wrapper pre[style] { - background: var(--color-bg-primary) !important; - border: none !important; - box-shadow: none !important; - font-size: 0.875rem !important; -} - -.markdown-renderer .code-block-wrapper code[style] { - background: var(--color-bg-primary) !important; - border: none !important; - font-size: 0.875rem !important; -} - -.markdown-renderer .inline-code:hover { - background: rgba(255, 255, 255, 0.08); - border-color: rgba(255, 255, 255, 0.15); - color: #c5d0e2; -} - - -.markdown-renderer pre { - padding: 1.25rem; - overflow: auto; - font-size: 0.875rem; - line-height: 1.55; - background: var(--color-bg-primary); - border: 1px solid var(--border-color, rgba(255, 255, 255, 0.09)); - border-radius: 8px; - margin: 0.65rem 0; - box-shadow: none; - position: relative; - transition: border-color 0.2s ease; -} - - -.markdown-renderer pre:hover { - border-color: color-mix(in srgb, var(--border-color, rgba(255, 255, 255, 0.09)) 75%, var(--primary-color, #3b82f6) 25%); - box-shadow: none; -} - - -.markdown-renderer pre:has(.code-block-wrapper) { - padding: 0 !important; - background: transparent !important; - border: none !important; - border-radius: 0 !important; - margin: 0.75rem 0 !important; - box-shadow: none !important; - position: static !important; -} - -.markdown-renderer pre:has(.code-block-wrapper):before { - display: none !important; +.markdown-renderer .copy-button.copy-success { + color: #22c55e; } - +.markdown-renderer pre:has(.code-block-wrapper), .markdown-renderer pre:has(.mermaid-block) { + margin: var(--markdown-code-block-gap) 0 !important; padding: 0 !important; - background: transparent !important; border: none !important; border-radius: 0 !important; - margin: 0.5rem 0 !important; + background: transparent !important; box-shadow: none !important; position: static !important; } +.markdown-renderer pre:has(.code-block-wrapper):hover, .markdown-renderer pre:has(.mermaid-block):hover { - box-shadow: none !important; border: none !important; + box-shadow: none !important; } - -.markdown-renderer pre code, -.markdown-renderer pre > .inline-code { - display: block; - padding: 0; - margin: 0; - overflow: visible; - line-height: inherit; - word-wrap: normal; - background-color: transparent; - border: 0; - border-radius: 0; - white-space: pre; - font-family: var(--markdown-font-mono); - user-select: text !important; - box-shadow: none; - font-size: 0.875rem !important; - color: inherit; - font-weight: inherit; +.markdown-renderer blockquote, +.markdown-renderer .custom-blockquote { + margin: calc(var(--markdown-block-gap) * 1.2) 0; + padding: 0.58rem 0.72rem; + border-left: 3px solid var(--markdown-blockquote-border); + border-radius: 0 6px 6px 0; + background: var(--markdown-blockquote-bg); + color: var(--color-text-secondary); + font-style: normal; + transition: background-color 0.2s ease, border-color 0.2s ease; } -.markdown-renderer pre code:hover { - background: transparent; - transform: none; - box-shadow: none; +.markdown-renderer blockquote > :first-child, +.markdown-renderer .custom-blockquote > :first-child { + margin-top: 0; } +.markdown-renderer blockquote > :last-child, +.markdown-renderer .custom-blockquote > :last-child { + margin-bottom: 0; +} -.markdown-renderer blockquote, -.custom-blockquote { - font-style: italic; - margin: 1.5rem 0; - padding: 1rem 1.2rem; - position: relative; - background: rgba(255, 255, 255, 0.02); - border-left: 3px solid rgba(255, 255, 255, 0.15); - border-radius: 0 4px 4px 0; - color: rgba(255, 255, 255, 0.7); - transition: all 0.3s ease; +.markdown-renderer details { + margin: calc(var(--markdown-block-gap) * 1.2) 0; + padding: 0.62rem 0.72rem; + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); + border-radius: var(--flowchat-card-radius, 8px); + background: rgba(255, 255, 255, 0.03); } -.markdown-renderer blockquote > :first-child { - margin-top: 0; +.markdown-renderer summary { + color: var(--color-text-primary); + cursor: pointer; + font-weight: 600; + line-height: var(--markdown-support-line-height); } -.markdown-renderer blockquote > :last-child { - margin-bottom: 0; +.markdown-renderer details[open] > summary { + margin-bottom: var(--markdown-block-gap); } -.markdown-renderer blockquote p { - margin: 0.5rem 0; +.markdown-renderer hr { + height: 1px; + margin: calc(var(--markdown-block-gap) * 1.8) 0; + padding: 0; + border: 0; + border-radius: 1px; + background: color-mix(in srgb, var(--color-text-muted) 26%, transparent); } /* inline-block so multiple badges / linked images can sit on one row (shields, etc.) */ @@ -483,7 +435,7 @@ height: auto; margin: 0.35rem 0.25rem; vertical-align: middle; - border-radius: 8px; + border-radius: var(--flowchat-card-radius, 8px); } /* README-style centered badge rows: each badge is often its own

inside a div */ @@ -514,136 +466,94 @@ outline: 1px dashed rgba(239, 68, 68, 0.35); } -.markdown-renderer details { - margin: 1rem 0; - padding: 0.9rem 1rem; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; -} - -.markdown-renderer summary { - cursor: pointer; - font-weight: 600; - color: var(--color-text-primary); -} - -.markdown-renderer details[open] > summary { - margin-bottom: 0.75rem; -} - .markdown-renderer .katex-display { overflow-x: auto; overflow-y: hidden; padding: 0.25rem 0; } - -.table-wrapper { +.markdown-renderer .table-wrapper { + margin: calc(var(--markdown-block-gap) * 1.2) 0; overflow: auto; - margin: 1.5rem 0; - border-radius: var(--markdown-border-radius-large); - border: 1px solid var(--border-color); - box-shadow: var(--markdown-shadow-heavy); - background: var(--bg-primary); - animation: scaleIn var(--markdown-animation-duration) ease-out; + border: 1px solid var(--markdown-table-border); + border-radius: var(--flowchat-card-radius, 8px); + background: var(--markdown-table-surface); + box-shadow: var(--flowchat-md-table-shadow, none); } -.table-wrapper table { +.markdown-renderer .table-wrapper table { + width: 100%; border-spacing: 0; border-collapse: collapse; - width: 100%; - font-size: 0.85rem; + font-size: var(--markdown-font-size-sm); + line-height: var(--markdown-support-line-height); } -.table-wrapper th, -.table-wrapper td { - padding: 12px 16px; - border-bottom: 1px solid var(--border-color); +.markdown-renderer .table-wrapper th, +.markdown-renderer .table-wrapper td { + padding: var(--markdown-table-cell-pad-y) var(--markdown-table-cell-pad-x); + border-bottom: 1px solid var(--flowchat-md-table-cell-border, var(--markdown-table-border)); + color: var(--flowchat-md-table-td-fg, var(--color-text-primary)); text-align: left; - transition: var(--markdown-transition); -} - -.table-wrapper th { - font-weight: 600; - background: rgba(255, 255, 255, 0.03); - color: var(--color-text-primary); - font-family: var(--markdown-font-heading); - position: relative; -} - -.table-wrapper th:first-child { - border-radius: var(--markdown-border-radius-large) 0 0 0; -} - -.table-wrapper th:last-child { - border-radius: 0 var(--markdown-border-radius-large) 0 0; + vertical-align: top; } -.table-wrapper tr:last-child td:first-child { - border-radius: 0 0 0 var(--markdown-border-radius-large); +.markdown-renderer .table-wrapper th + th, +.markdown-renderer .table-wrapper td + td { + border-left: 1px solid var(--flowchat-md-table-col-divider, transparent); } -.table-wrapper tr:last-child td:last-child { - border-radius: 0 0 var(--markdown-border-radius-large) 0; +.markdown-renderer .table-wrapper th { + background: var(--markdown-table-header-bg); + color: var(--flowchat-md-table-th-fg, var(--color-text-primary)); + font-family: var(--markdown-font-heading); + font-weight: 600; } -.table-wrapper tr:last-child td { +.markdown-renderer .table-wrapper tr:last-child td { border-bottom: none; } -.table-wrapper tbody tr:nth-child(2n) { - background: rgba(255, 255, 255, 0.02); +.markdown-renderer .table-wrapper tbody tr { + background: var(--flowchat-md-table-row-base, transparent); } -.table-wrapper tbody tr:hover { - background: rgba(255, 255, 255, 0.04); +.markdown-renderer .table-wrapper tbody tr:nth-child(2n) { + background: var(--markdown-table-row-stripe); } -.table-wrapper td { - background: transparent; +.markdown-renderer .table-wrapper tbody tr:hover { + background: var(--markdown-table-row-hover); } - -.markdown-renderer a { - color: var(--markdown-link-color); - text-decoration: underline; - text-decoration-thickness: 1px; - text-underline-offset: 2px; -} - -.markdown-renderer a:hover { - color: var(--markdown-link-hover-color); - text-decoration-thickness: 1.5px; -} - - -.file-link, -.visualization-link, -.tab-link { - background: none; +.markdown-renderer a, +.markdown-renderer .file-link, +.markdown-renderer .visualization-link, +.markdown-renderer .tab-link { + margin: 0; + padding: 0; border: none; + background: none; color: var(--markdown-link-color); + cursor: pointer; + font: inherit; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px; - cursor: pointer; - font: inherit; - padding: 0; - margin: 0; } -.file-link:hover, -.visualization-link:hover, -.tab-link:hover { +.markdown-renderer a:hover, +.markdown-renderer .file-link:hover, +.markdown-renderer .visualization-link:hover, +.markdown-renderer .tab-link:hover { color: var(--markdown-link-hover-color); text-decoration-thickness: 1.5px; } .markdown-renderer a .inline-code, -.file-link .inline-code, -.visualization-link .inline-code, -.tab-link .inline-code { +.markdown-renderer .file-link .inline-code, +.markdown-renderer .visualization-link .inline-code, +.markdown-renderer .tab-link .inline-code { color: inherit; } @@ -654,109 +564,49 @@ text-align: left; font-family: var(--markdown-font-mono); font-size: 0.78rem; - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); } - .markdown-renderer strong { - font-weight: 650; color: var(--color-text-primary); -} - - -[data-theme-type="light"] .markdown-renderer strong { - color: #1a1a1a; - text-shadow: none; + font-weight: 650; } .markdown-renderer em { - font-style: italic; color: var(--color-text-secondary); + font-style: italic; } - .markdown-renderer del { text-decoration: line-through; } - -.markdown-renderer hr { - height: 1px; - padding: 0; - margin: 2rem 0; - background: rgba(255, 255, 255, 0.15); - border: 0; - border-radius: 1px; -} - - -[data-theme-type="light"] .markdown-renderer hr { - background: rgba(0, 0, 0, 0.15); -} - - -[data-theme-type="dark"] .markdown-renderer hr { - background: rgba(255, 255, 255, 0.18); -} - - -[data-theme-type="light"] .markdown-renderer h1 { - border-bottom-color: rgba(0, 0, 0, 0.12); -} - -[data-theme-type="light"] .markdown-renderer h2 { - border-bottom-color: rgba(0, 0, 0, 0.1); -} - - -[data-theme-type="dark"] .markdown-renderer h1 { - border-bottom-color: rgba(255, 255, 255, 0.12); -} - -[data-theme-type="dark"] .markdown-renderer h2 { - border-bottom-color: rgba(255, 255, 255, 0.1); +.markdown-renderer input[type="checkbox"] { + width: 1em; + height: 1em; + margin: 0 0.35em 0 0; + vertical-align: -0.12em; } - -@media (prefers-color-scheme: light) { - .markdown-renderer hr { - background: rgba(0, 0, 0, 0.15); - } - - .markdown-renderer h1 { - border-bottom-color: rgba(0, 0, 0, 0.12); - } - - .markdown-renderer h2 { - border-bottom-color: rgba(0, 0, 0, 0.1); - } -} - -@media (prefers-color-scheme: dark) { - .markdown-renderer hr { - background: rgba(255, 255, 255, 0.18); - } - - .markdown-renderer h1 { - border-bottom-color: rgba(255, 255, 255, 0.12); - } - - .markdown-renderer h2 { - border-bottom-color: rgba(255, 255, 255, 0.1); - } +.markdown-renderer * { + will-change: auto; } - :root[data-theme="light"] .markdown-renderer, :root[data-theme-type="light"] .markdown-renderer, [data-theme-type="light"] .markdown-renderer, .light .markdown-renderer { --markdown-link-color: #0969da; --markdown-link-hover-color: #0550ae; + --markdown-code-bg-elevated: #f6f8fa; + --markdown-inline-code-bg: rgba(0, 0, 0, 0.05); + --markdown-inline-code-border: rgba(0, 0, 0, 0.12); + --markdown-pre-bg: #f6f8fa; + --markdown-pre-border: rgba(15, 23, 42, 0.12); + --markdown-blockquote-bg: var(--flowchat-md-bq-bg, rgba(15, 23, 42, 0.07)); + --markdown-blockquote-border: var(--flowchat-md-bq-border, rgba(15, 23, 42, 0.24)); .inline-code { - background: rgba(0, 0, 0, 0.05); - border-color: rgba(0, 0, 0, 0.12); color: #24292f; } @@ -766,211 +616,44 @@ color: #1f2328; } - - .code-block-wrapper { - background: #f6f8fa; - border-color: rgba(0, 0, 0, 0.1); - } - - .code-block-wrapper:hover { - border-color: rgba(0, 0, 0, 0.15); - } - .code-block-toolbar { background: #eef1f6; border-bottom-color: rgba(15, 23, 42, 0.1); } - .code-block-lang { - color: #64748b; - } - - pre { - background: #f6f8fa; - border-color: rgba(0, 0, 0, 0.1); - } - - pre:hover { - border-color: rgba(0, 0, 0, 0.15); - } - - .copy-button { color: rgba(0, 0, 0, 0.5); } .copy-button:hover { - color: #0969da; background: rgba(15, 23, 42, 0.06); + color: #0969da; } - .copy-button.copy-success { color: #1a7f37; } - - .code-block-wrapper pre[class*="language-"] { - background: #f6f8fa !important; - } - - .code-block-wrapper pre code { - background: #f6f8fa !important; - } - - .code-block-wrapper pre[style] { - background: #f6f8fa !important; - } - - .code-block-wrapper code[style] { - background: #f6f8fa !important; - } - - .table-wrapper { - background: #ffffff; - border: 1px solid rgba(15, 23, 42, 0.12); - box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06); - } - - .table-wrapper th, - .table-wrapper td { - border-bottom-color: rgba(15, 23, 42, 0.1); - } - - .table-wrapper th + th, - .table-wrapper td + td { - border-left: 1px solid rgba(15, 23, 42, 0.08); - } - - .table-wrapper th { - background: #f3f6fb; - color: #0f172a; - } - - .table-wrapper td { - color: var(--color-text-primary, #1f2937); - } - - .table-wrapper tbody tr { - background: #ffffff; - } - - .table-wrapper tbody tr:nth-child(2n) { - background: #f8fafc; - } - - .table-wrapper tbody tr:hover { - background: #eef4ff; - } - blockquote, .custom-blockquote { - background: var(--flowchat-md-bq-bg, rgba(15, 23, 42, 0.07)); - border-left: 3px solid var(--flowchat-md-bq-border, rgba(15, 23, 42, 0.24)); color: var(--flowchat-md-bq-fg, var(--color-text-secondary, #475569)); } - - blockquote:hover, - .custom-blockquote:hover { - background: var(--flowchat-md-bq-hover-bg, rgba(15, 23, 42, 0.1)); - border-left-color: var(--flowchat-md-bq-hover-border, rgba(15, 23, 42, 0.34)); - } } - @media (prefers-color-scheme: dark) { - - .markdown-renderer .inline-code { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.1); - color: #a8b4c8; - } - - .markdown-renderer .inline-code:hover { - background: rgba(255, 255, 255, 0.08); - border-color: rgba(255, 255, 255, 0.15); - color: #c5d0e2; - } - - - .markdown-renderer .code-block-wrapper { - background: var(--color-bg-primary); - border-color: rgba(255, 255, 255, 0.05); - } - - .markdown-renderer pre { - background: var(--color-bg-primary); - border-color: rgba(255, 255, 255, 0.05); + .markdown-renderer { + --markdown-code-bg-elevated: color-mix(in srgb, var(--color-bg-primary) 94%, #ffffff 6%); + --markdown-pre-border: rgba(255, 255, 255, 0.09); } } - @keyframes fadeIn { from { opacity: 0; - transform: translateY(10px); + transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } - -@keyframes slideInLeft { - from { - opacity: 0; - transform: translateX(-20px); - } - to { - opacity: 1; - transform: translateX(0); - } -} - -@keyframes scaleIn { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@keyframes bounceIn { - 0% { - opacity: 0; - transform: scale(0.3); - } - 50% { - opacity: 1; - transform: scale(1.05); - } - 70% { - transform: scale(0.9); - } - 100% { - opacity: 1; - transform: scale(1); - } -} - - -.markdown-renderer blockquote:hover { - background: rgba(255, 255, 255, 0.03); - border-left-color: rgba(255, 255, 255, 0.2); -} - - -.markdown-renderer * { - will-change: auto; -} - -.markdown-renderer .inline-code:hover, -.markdown-renderer pre:hover, -.markdown-renderer blockquote:hover { - will-change: color, background; -} - - diff --git a/src/web-ui/src/component-library/components/Markdown/MermaidBlock.scss b/src/web-ui/src/component-library/components/Markdown/MermaidBlock.scss index ba60093cca..5aa820a1b6 100644 --- a/src/web-ui/src/component-library/components/Markdown/MermaidBlock.scss +++ b/src/web-ui/src/component-library/components/Markdown/MermaidBlock.scss @@ -3,7 +3,7 @@ * Styles for rendering Mermaid diagrams during streaming output */ .mermaid-block { - margin: 0.5rem 0; + margin: var(--flowchat-markdown-block-gap, 0.58em) 0; border-radius: 6px; overflow: hidden; background: var(--color-bg-primary, #121214); @@ -20,7 +20,7 @@ &--streaming { .mermaid-block__streaming { - padding: 0.5rem; + padding: var(--flowchat-code-block-pad-y, 0.55rem) var(--flowchat-code-block-pad-x, 0.75rem); } .streaming-cursor { @@ -40,12 +40,12 @@ .mermaid-block__hint { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.5rem; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: var(--flowchat-card-pad-y, 0.625rem) var(--flowchat-card-pad-x, 0.75rem); background: var(--color-warning-bg, rgba(251, 191, 36, 0.1)); border-top: 1px solid var(--color-warning-border, rgba(251, 191, 36, 0.2)); color: var(--color-warning, #fbbf24); - font-size: 0.8rem; + font-size: var(--flowchat-font-size-xs); svg { flex-shrink: 0; @@ -56,7 +56,7 @@ &--waiting, &--rendering { .mermaid-block__loading { - padding: 1rem 0.5rem; + padding: var(--flowchat-card-pad-y, 0.625rem) var(--flowchat-card-pad-x, 0.75rem); text-align: center; } @@ -64,10 +64,10 @@ display: flex; align-items: center; justify-content: center; - gap: 0.5rem; + gap: var(--flowchat-inline-gap, 0.35rem); color: var(--color-accent-500, #60a5fa); - font-size: 0.85rem; - margin-bottom: 0.5rem; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + margin-bottom: var(--flowchat-card-gap, 0.45rem); .spinning { animation: spin 1s linear infinite; @@ -84,7 +84,7 @@ position: relative; .mermaid-block__diagram { - padding: 0.75rem 0.5rem; + padding: var(--flowchat-card-pad-y, 0.625rem) var(--flowchat-card-pad-x, 0.75rem); background: transparent; overflow-x: auto; animation: fadeIn 0.4s ease; @@ -113,7 +113,7 @@ top: 4px; right: 4px; display: flex; - gap: 2px; + gap: 0.125rem; opacity: 0; transition: opacity 0.2s ease; } @@ -123,7 +123,7 @@ } .mermaid-block__source { - padding: 0.5rem; + padding: var(--flowchat-code-block-pad-y, 0.55rem) var(--flowchat-code-block-pad-x, 0.75rem); border-top: 1px solid var(--element-bg-base, rgba(255, 255, 255, 0.08)); background: transparent; @@ -143,12 +143,12 @@ .mermaid-block__error-message { display: flex; align-items: center; - gap: 0.5rem; - padding: 1rem; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: var(--flowchat-card-pad-y, 0.625rem) var(--flowchat-card-pad-x, 0.75rem); background: var(--color-error-bg, rgba(239, 68, 68, 0.1)); border-bottom: 1px solid var(--color-error-border, rgba(239, 68, 68, 0.2)); color: var(--color-error, #ef4444); - font-size: 0.875rem; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); svg { flex-shrink: 0; @@ -157,8 +157,8 @@ .mermaid-block__actions { display: flex; - gap: 0.5rem; - padding: 0.75rem 1rem; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: var(--flowchat-card-pad-y, 0.625rem) var(--flowchat-card-pad-x, 0.75rem); background: var(--color-bg-secondary, rgba(0, 0, 0, 0.4)); border-top: 1px solid var(--element-bg-base, rgba(255, 255, 255, 0.08)); } @@ -166,7 +166,7 @@ .mermaid-block__code-preview { - padding: 0.5rem; + padding: var(--flowchat-code-block-pad-y, 0.55rem) var(--flowchat-code-block-pad-x, 0.75rem); background: transparent; .mermaid-code { @@ -176,8 +176,8 @@ border: none; border-radius: 0; font-family: var(--font-family-mono, 'Consolas', 'Monaco', 'Courier New', monospace); - font-size: 0.8rem; - line-height: 1.5; + font-size: var(--flowchat-code-font-size, 0.92em); + line-height: var(--flowchat-code-line-height, 1.52); color: var(--color-text-primary, #e8e8e8); overflow-x: auto; white-space: pre; diff --git a/src/web-ui/src/component-library/components/Markdown/ReproductionStepsBlock.scss b/src/web-ui/src/component-library/components/Markdown/ReproductionStepsBlock.scss index 70bd8ffcab..a572f6b80b 100644 --- a/src/web-ui/src/component-library/components/Markdown/ReproductionStepsBlock.scss +++ b/src/web-ui/src/component-library/components/Markdown/ReproductionStepsBlock.scss @@ -7,8 +7,8 @@ .reproduction-steps-block { display: flex; flex-direction: column; - margin: 6px 0; - border-radius: var(--radius-md, 8px); + margin: var(--flowchat-card-gap, 0.42rem) 0; + border-radius: var(--flowchat-card-radius, 8px); border: 1px solid var(--color-warning, #f59e0b); background: var(--color-bg-flowchat); backdrop-filter: blur(10px); @@ -35,8 +35,8 @@ .reproduction-steps-header { display: flex; align-items: center; - gap: 8px; - padding: 10px 14px; + gap: var(--flowchat-control-gap, 0.5rem); + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); background: transparent; transition: background 0.15s ease; @@ -86,7 +86,7 @@ } .reproduction-steps-title { - font-size: 13px; + font-size: var(--flowchat-font-size-sm); font-weight: 600; color: var(--color-text-primary, #e5e7eb); flex: 1; @@ -96,21 +96,21 @@ .reproduction-steps-badge { display: inline-flex; align-items: center; - padding: 3px 8px; + padding: 0.18rem 0.5rem; background: var(--element-bg-subtle, rgba(255, 255, 255, 0.06)); color: var(--color-text-secondary, #9ca3af); border: 1px solid var(--border-base); - border-radius: var(--radius-sm, 4px); - font-size: 10px; + border-radius: var(--size-radius-sm, 6px); + font-size: var(--flowchat-font-size-xxs); font-weight: 600; text-transform: uppercase; - letter-spacing: 0.3px; + letter-spacing: 0.02em; flex-shrink: 0; } .reproduction-steps-content { - padding: 12px 14px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); } .reproduction-steps-list { @@ -119,21 +119,21 @@ list-style: none; display: flex; flex-direction: column; - gap: 6px; + gap: var(--flowchat-flow-item-gap, 0.42rem); } .reproduction-step-item { display: flex; align-items: flex-start; - gap: 10px; - padding: 8px 12px; + gap: var(--flowchat-control-gap, 0.5rem); + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); background: transparent; border: 1px solid var(--border-base); - border-radius: var(--radius-md, 6px); - font-size: 13px; + border-radius: var(--flowchat-card-radius, 8px); + font-size: var(--flowchat-font-size-sm); font-weight: 500; color: var(--color-text-primary, #e5e7eb); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); transition: all 0.15s ease; &:hover { @@ -154,7 +154,7 @@ background: rgba(245, 158, 11, 0.12); color: var(--color-warning, #f59e0b); border-radius: var(--radius-sm, 4px); - font-size: 11px; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; flex-shrink: 0; } @@ -173,37 +173,37 @@ .reproduction-steps-actions { - padding: 12px 14px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); display: flex; flex-direction: column; - gap: 10px; + gap: var(--flowchat-control-gap, 0.5rem); border-top: 1px solid var(--border-base); } .reproduction-steps-hint { display: flex; align-items: center; - gap: 8px; - padding: 8px 12px; + gap: var(--flowchat-control-gap, 0.5rem); + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); background: var(--element-bg-soft, rgba(59, 130, 246, 0.08)); border: 1px solid var(--border-subtle); - border-radius: var(--radius-md, 6px); - font-size: 12px; + border-radius: var(--flowchat-card-radius, 8px); + font-size: var(--flowchat-font-size-sm); color: var(--color-text-secondary, #9ca3af); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); } .reproduction-proceed-btn { display: flex; align-items: center; justify-content: center; - gap: 6px; - padding: 8px 16px; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--size-gap-4, 16px); background: var(--element-bg-base, rgba(255, 255, 255, 0.06)); color: var(--color-text-primary, #e5e7eb); border: 1px solid var(--border-base); - border-radius: var(--radius-md, 6px); - font-size: 13px; + border-radius: var(--flowchat-card-radius, 8px); + font-size: var(--flowchat-font-size-sm); font-weight: 600; cursor: pointer; transition: all 0.15s ease; @@ -241,10 +241,10 @@ .reproduction-steps-completed { display: flex; align-items: center; - gap: 8px; - padding: 10px 14px; + gap: var(--flowchat-control-gap, 0.5rem); + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); color: var(--color-text-secondary, #9ca3af); - font-size: 12px; + font-size: var(--flowchat-font-size-sm); font-weight: 500; border-top: 1px dashed var(--border-base); diff --git a/src/web-ui/src/component-library/styles/flowchat-markdown-code-vars.scss b/src/web-ui/src/component-library/styles/flowchat-markdown-code-vars.scss index f4164979c6..ef1c909d29 100644 --- a/src/web-ui/src/component-library/styles/flowchat-markdown-code-vars.scss +++ b/src/web-ui/src/component-library/styles/flowchat-markdown-code-vars.scss @@ -16,10 +16,10 @@ --flowchat-md-pre-bg: var(--color-bg-primary); --flowchat-md-pre-border: rgba(255, 255, 255, 0.1); --flowchat-md-pre-border-style: dashed; - --flowchat-md-pre-radius: 4px; + --flowchat-md-pre-radius: var(--flowchat-card-radius, 8px); --flowchat-md-pre-shadow: none; - --flowchat-md-pre-padding: 1.25rem; - --flowchat-md-pre-font-size: 0.85rem; + --flowchat-md-pre-padding: var(--flowchat-markdown-code-block-pad-y, 0.78rem) var(--flowchat-markdown-code-block-pad-x, 0.86rem); + --flowchat-md-pre-font-size: var(--flowchat-code-font-size, var(--flowchat-font-size-sm)); --flowchat-md-pre-hover-border: rgba(255, 255, 255, 0.15); --flowchat-md-pre-hover-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), @@ -29,10 +29,10 @@ --flowchat-md-bq-bg: rgba(255, 255, 255, 0.02); --flowchat-md-bq-border: rgba(255, 255, 255, 0.15); - --flowchat-md-bq-radius: 0 4px 4px 0; + --flowchat-md-bq-radius: 0 6px 6px 0; --flowchat-md-bq-fg: rgba(255, 255, 255, 0.7); - --flowchat-md-bq-padding: 1rem 1.2rem; - --flowchat-md-bq-margin: 1.5rem 0; + --flowchat-md-bq-padding: 0.58rem 0.72rem; + --flowchat-md-bq-margin: calc(var(--flowchat-markdown-block-gap, 0.58em) * 1.2) 0; } :root[data-theme-type="light"] { diff --git a/src/web-ui/src/component-library/styles/flowchat-markdown-table-vars.scss b/src/web-ui/src/component-library/styles/flowchat-markdown-table-vars.scss index 4c02340c31..f63802c82e 100644 --- a/src/web-ui/src/component-library/styles/flowchat-markdown-table-vars.scss +++ b/src/web-ui/src/component-library/styles/flowchat-markdown-table-vars.scss @@ -3,9 +3,9 @@ * Single source for `.markdown-renderer` and m-editor tables. */ :root { - --flowchat-md-table-radius: 12px; + --flowchat-md-table-radius: var(--flowchat-card-radius, 8px); --flowchat-md-table-border: rgba(255, 255, 255, 0.12); - --flowchat-md-table-shadow: 0 4px 20px rgba(0, 0, 0, 0.35); + --flowchat-md-table-shadow: none; --flowchat-md-table-surface: transparent; --flowchat-md-table-cell-border: rgba(255, 255, 255, 0.1); --flowchat-md-table-col-divider: transparent; diff --git a/src/web-ui/src/component-library/styles/tokens.scss b/src/web-ui/src/component-library/styles/tokens.scss index 0443cac7dd..5eb26430da 100644 --- a/src/web-ui/src/component-library/styles/tokens.scss +++ b/src/web-ui/src/component-library/styles/tokens.scss @@ -946,9 +946,37 @@ $badge-info-text: $color-info; --flowchat-font-size-3xl: var(--font-size-3xl); --flowchat-font-size-4xl: var(--font-size-4xl); --flowchat-font-size-5xl: var(--font-size-5xl); - --flowchat-flow-item-gap: 0.45rem; - --flowchat-turn-gap: 0.4rem; - --flowchat-text-line-height: 1.62; + --flowchat-text-line-height: 1.58; + --flowchat-support-line-height: 1.45; + --flowchat-compact-line-height: 1.32; + --flowchat-flow-item-gap: 0.42rem; + --flowchat-turn-gap: 0.46rem; + --flowchat-inline-gap: 0.35rem; + --flowchat-control-gap: 0.5rem; + --flowchat-content-inline-pad: 3rem; + --flowchat-content-inline-pad-mobile: 1.5rem; + --flowchat-user-message-pad-y: 0.32rem; + --flowchat-user-message-pad-x: 0.75rem; + --flowchat-card-gap: 0.42rem; + --flowchat-card-radius: 8px; + --flowchat-card-header-pad-y: 0.44rem; + --flowchat-card-header-pad-x: 0; + --flowchat-card-header-pad-right: 0.625rem; + --flowchat-card-expanded-pad-y: 0.5rem; + --flowchat-card-expanded-pad-x: 0.625rem; + --flowchat-code-font-size: var(--flowchat-font-size-sm); + --flowchat-code-line-height: 1.52; + --flowchat-markdown-block-gap: 0.58em; + --flowchat-markdown-paragraph-gap: 0.28em; + --flowchat-markdown-list-item-gap: 0.28em; + --flowchat-markdown-list-nested-gap: 0.22em; + --flowchat-markdown-heading-gap-before: 0.86em; + --flowchat-markdown-heading-gap-after: 0.36em; + --flowchat-markdown-code-block-gap: 0.72em; + --flowchat-markdown-code-block-pad-y: 0.78rem; + --flowchat-markdown-code-block-pad-x: 0.86rem; + --flowchat-markdown-table-cell-pad-y: 0.52rem; + --flowchat-markdown-table-cell-pad-x: 0.72rem; --flowchat-link-color: #60a5fa; --flowchat-link-hover-color: #93c5fd; diff --git a/src/web-ui/src/flow_chat/components/ChatEmptyState.scss b/src/web-ui/src/flow_chat/components/ChatEmptyState.scss index df92467beb..a4df910e2a 100644 --- a/src/web-ui/src/flow_chat/components/ChatEmptyState.scss +++ b/src/web-ui/src/flow_chat/components/ChatEmptyState.scss @@ -84,7 +84,7 @@ background: var(--element-bg-soft); border: 1px solid var(--border-medium); border-radius: $size-radius-sm; - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); transition: all $motion-base $easing-standard; &:hover { @@ -104,7 +104,7 @@ background: var(--element-bg-soft); border: 1px solid var(--border-medium); border-radius: $size-radius-sm; - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); transition: all $motion-base $easing-standard; &:hover { @@ -162,7 +162,7 @@ margin: 0; font-size: var(--flowchat-font-size-xl); color: var(--color-text-secondary); - line-height: 1.7; + line-height: var(--flowchat-text-line-height, 1.58); font-weight: $font-weight-normal; } diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index da128db3d5..6940399d08 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -75,7 +75,7 @@ inset 0 1px 0 rgba(255, 255, 255, 0.06); flex-direction: row; align-items: center; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); &:hover { background: rgba(25, 25, 40, 0.38); @@ -141,7 +141,7 @@ max-height: 28px !important; max-width: 100%; font-size: var(--flowchat-font-size-base); - line-height: 28px; + line-height: var(--flowchat-support-line-height, 1.45); overflow: hidden; padding: 0 !important; margin: 0 auto; @@ -152,7 +152,7 @@ display: block; color: color-mix(in srgb, var(--color-text-muted) 52%, transparent); font-size: var(--flowchat-font-size-base); - line-height: 28px; + line-height: var(--flowchat-support-line-height, 1.45); text-align: center; width: 100%; } @@ -164,10 +164,10 @@ inset: 0; align-items: center; justify-content: center; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); width: 100%; font-size: var(--flowchat-font-size-sm); - line-height: 28px; + line-height: var(--flowchat-support-line-height, 1.45); color: color-mix(in srgb, var(--color-text-muted) 48%, transparent); white-space: nowrap; pointer-events: none; @@ -209,7 +209,7 @@ .bitfun-chat-input__actions-right { display: flex !important; align-items: center; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); } .bitfun-chat-input__space-hint { @@ -338,9 +338,9 @@ display: inline-flex; align-items: center; flex-shrink: 0; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); font-size: var(--flowchat-font-size-sm); - line-height: 24px; + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-muted); opacity: 0.72; white-space: nowrap; @@ -406,7 +406,7 @@ } } - gap: $size-gap-3; + gap: var(--flowchat-control-gap, #{$size-gap-2}); /* Queue sits directly above the input card; keep this pair tighter than other stacked blocks. */ > .bitfun-pending-queue-panel + .bitfun-chat-input__container { @@ -414,11 +414,11 @@ } &__recommendations { - margin-bottom: 8px; + margin-bottom: var(--flowchat-card-gap, 0.42rem); } &__cowork-examples { - margin-bottom: 8px; + margin-bottom: var(--flowchat-card-gap, 0.42rem); } & > * { @@ -430,7 +430,7 @@ display: flex; flex-direction: column; align-items: center; - gap: $size-gap-2; + gap: var(--flowchat-card-gap, #{$size-gap-2}); justify-content: center; .session-file-modifications-bar { @@ -454,8 +454,8 @@ gap: 0; pointer-events: auto; border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 12px; - padding: 12px 8px 0; + border-radius: calc(var(--flowchat-card-radius, 8px) + 4px); + padding: var(--flowchat-card-expanded-pad-x, 0.625rem) var(--flowchat-card-expanded-pad-y, 0.5rem) 0; background: var(--color-bg-scene); backdrop-filter: blur(16px) saturate(1.2); -webkit-backdrop-filter: blur(16px) saturate(1.2); @@ -506,9 +506,9 @@ &__target-switcher { display: flex; align-items: center; - gap: 2px; + gap: 0.125rem; padding-bottom: 7px; - margin-bottom: 1px; + margin-bottom: 0.06rem; border-bottom: 1px solid rgba(255, 255, 255, 0.05); animation: bitfun-chat-input-fade-in 0.1s ease-out; } @@ -517,8 +517,8 @@ font-size: var(--flowchat-font-size-xxs); color: var(--color-text-muted); font-weight: 400; - padding: 2px 8px 2px 0; - margin-right: 4px; + padding: 0.12rem var(--flowchat-card-expanded-pad-y, 0.5rem) 0.12rem 0; + margin-right: var(--flowchat-inline-gap, 0.35rem); flex-shrink: 0; letter-spacing: 0.02em; opacity: 0.72; @@ -527,8 +527,8 @@ &__target-tab { display: inline-flex; align-items: center; - gap: 4px; - padding: 2px 8px; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: 0.12rem var(--flowchat-card-expanded-pad-y, 0.5rem); border: none; background: transparent; color: var(--color-text-muted); @@ -638,9 +638,9 @@ &__image-strip { display: flex; flex-wrap: wrap; - gap: 6px; - margin-bottom: 6px; - padding: 2px 0; + gap: var(--flowchat-inline-gap, 0.35rem); + margin-bottom: var(--flowchat-inline-gap, 0.35rem); + padding: 0.12rem 0; } &__image-chip { @@ -768,9 +768,9 @@ &__agent-capsule { display: inline-flex; align-items: center; - gap: 2px; + gap: 0.125rem; max-width: 148px; - padding: 2px 4px 2px 10px; + padding: 0.12rem 0.25rem 0.12rem var(--flowchat-card-expanded-pad-x, 0.625rem); border-radius: 999px; border: none; font-size: var(--flowchat-font-size-xxs); @@ -821,14 +821,14 @@ } &__agent-boost-empty { - padding: 12px 14px; + padding: var(--flowchat-card-expanded-pad-x, 0.625rem) var(--flowchat-card-expanded-pad-x, 0.625rem); font-size: var(--flowchat-font-size-2xs); color: var(--color-text-tertiary); text-align: center; - line-height: 1.35; + line-height: var(--flowchat-support-line-height, 1.45); &--inline { - padding: 8px 12px 10px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem) var(--flowchat-card-expanded-pad-x, 0.625rem); text-align: left; } } @@ -846,10 +846,10 @@ &__boost-context-row { display: flex; align-items: center; - gap: 10px; + gap: var(--flowchat-control-gap, 0.5rem); width: 100%; margin: 0; - padding: 8px 12px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); background: transparent; font-size: var(--flowchat-font-size-2xs); color: var(--color-text-secondary); @@ -892,10 +892,10 @@ display: flex; align-items: center; justify-content: space-between; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); width: 100%; margin: 0; - padding: 8px 12px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); background: transparent; font-size: var(--flowchat-font-size-2xs); color: var(--color-text-secondary); @@ -907,7 +907,7 @@ &__boost-submenu-trigger-main { display: inline-flex; align-items: center; - gap: 10px; + gap: var(--flowchat-control-gap, 0.5rem); min-width: 0; } @@ -979,8 +979,8 @@ flex-direction: row; align-items: center; justify-content: center; - gap: 8px; - padding: 14px 12px; + gap: var(--flowchat-control-gap, 0.5rem); + padding: var(--flowchat-card-expanded-pad-x, 0.625rem) var(--flowchat-card-expanded-pad-x, 0.625rem); font-size: var(--flowchat-font-size-2xs); color: var(--color-text-muted); } @@ -1010,9 +1010,9 @@ &__boost-submenu-item { display: flex; align-items: center; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); width: 100%; - padding: 5px 8px; + padding: 0.32rem var(--flowchat-card-expanded-pad-y, 0.5rem); border-radius: 4px; background: transparent; font-size: var(--flowchat-font-size-xs); @@ -1038,14 +1038,14 @@ text-overflow: ellipsis; white-space: nowrap; min-width: 0; - line-height: 1.35; + line-height: var(--flowchat-support-line-height, 1.45); } &__boost-submenu-manage { flex-shrink: 0; width: 100%; margin: 0; - padding: 8px 10px 10px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem) var(--flowchat-card-expanded-pad-x, 0.625rem); border-top: 1px solid var(--border-subtle); background: transparent; font-size: var(--flowchat-font-size-xxs); @@ -1080,7 +1080,7 @@ display: inline-flex; align-items: center; font-weight: 500; - line-height: 1; + line-height: var(--flowchat-compact-line-height, 1.32); vertical-align: middle; } @@ -1111,7 +1111,7 @@ } &__mode-option { - padding: 7px 12px; + padding: var(--flowchat-inline-gap, 0.35rem) var(--flowchat-card-expanded-pad-x, 0.625rem); font-size: var(--flowchat-font-size-2xs); color: var(--color-text-secondary); cursor: pointer; @@ -1119,7 +1119,7 @@ display: flex; align-items: center; justify-content: space-between; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); &:hover:not(&--disabled) { background: rgba(255, 255, 255, 0.04); @@ -1183,14 +1183,14 @@ } &__mode-submenu-trigger { - padding: 7px 12px; + padding: var(--flowchat-inline-gap, 0.35rem) var(--flowchat-card-expanded-pad-x, 0.625rem); font-size: var(--flowchat-font-size-2xs); color: var(--color-text-secondary); cursor: pointer; transition: all 0.15s ease; display: flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); &:hover { background: rgba(255, 255, 255, 0.04); @@ -1257,7 +1257,7 @@ display: inline-flex; align-items: center; justify-content: center; - padding: 2px 5px; + padding: 0.12rem 0.32rem; border-radius: 3px; background: var(--color-warning-bg); color: var(--color-warning); @@ -1307,7 +1307,7 @@ display: flex; align-items: center; justify-content: space-between; - padding: 8px 12px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); border-bottom: 1px solid var(--border-subtle); font-size: var(--flowchat-font-size-xxs); font-weight: 500; @@ -1350,8 +1350,8 @@ &__slash-command-item { display: flex; align-items: center; - gap: 12px; - padding: 7px 12px; + gap: var(--flowchat-control-gap, 0.5rem); + padding: var(--flowchat-inline-gap, 0.35rem) var(--flowchat-card-expanded-pad-x, 0.625rem); cursor: pointer; transition: all 0.15s ease; @@ -1386,7 +1386,7 @@ &__slash-command-current { font-size: var(--flowchat-font-size-xxs); - padding: 2px 5px; + padding: 0.12rem 0.32rem; border-radius: 3px; background: rgba(100, 140, 255, 0.15); color: rgba(140, 180, 255, 0.9); @@ -1394,7 +1394,7 @@ } &__slash-command-empty { - padding: 16px 12px; + padding: var(--size-gap-4, 16px) var(--flowchat-card-expanded-pad-x, 0.625rem); text-align: center; font-size: var(--flowchat-font-size-2xs); color: var(--color-text-tertiary); @@ -1855,4 +1855,3 @@ transform: translateY(0); } } - diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss index 36acf646af..bc00c8a730 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss @@ -29,7 +29,7 @@ padding: 3px 18px 4px; font-family: var(--font-sans, var(--font-family-sans, inherit)); font-size: var(--flowchat-font-size-xxs); - line-height: 1.45; + line-height: var(--flowchat-support-line-height, 1.45); font-weight: 400; letter-spacing: 0.01em; color: var(--color-text-primary); diff --git a/src/web-ui/src/flow_chat/components/CodePreview.scss b/src/web-ui/src/flow_chat/components/CodePreview.scss index 3482032938..86d44ed642 100644 --- a/src/web-ui/src/flow_chat/components/CodePreview.scss +++ b/src/web-ui/src/flow_chat/components/CodePreview.scss @@ -76,8 +76,8 @@ white-space: pre-wrap; word-break: break-word; font-family: var(--markdown-font-mono, "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace); - font-size: 12px; - line-height: 1.6; + font-size: var(--flowchat-font-size-sm); + line-height: var(--flowchat-code-line-height, 1.52); code { display: block; @@ -130,7 +130,7 @@ justify-content: center; min-height: 60px; color: var(--color-text-muted, #888); - font-size: 12px; + font-size: var(--flowchat-font-size-sm); font-style: italic; } diff --git a/src/web-ui/src/flow_chat/components/CopyOutputButton.css b/src/web-ui/src/flow_chat/components/CopyOutputButton.css index f5eb49c27e..cd51ce455c 100644 --- a/src/web-ui/src/flow_chat/components/CopyOutputButton.css +++ b/src/web-ui/src/flow_chat/components/CopyOutputButton.css @@ -18,7 +18,7 @@ border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 6px; color: rgba(255, 255, 255, 0.8); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 500; cursor: pointer; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); @@ -89,7 +89,7 @@ @media (max-width: 768px) { .copy-output-button { padding: 5px 8px; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); min-width: 50px; bottom: 6px; right: 8px; diff --git a/src/web-ui/src/flow_chat/components/CoworkExampleCards.scss b/src/web-ui/src/flow_chat/components/CoworkExampleCards.scss index 4e8286f342..b53a4f6c02 100644 --- a/src/web-ui/src/flow_chat/components/CoworkExampleCards.scss +++ b/src/web-ui/src/flow_chat/components/CoworkExampleCards.scss @@ -4,20 +4,20 @@ .bitfun-cowork-example-cards { border: 1px solid var(--color-border); - border-radius: 10px; - padding: 12px 14px 12px 0; - margin-bottom: 10px; + border-radius: calc(var(--flowchat-card-radius, 8px) + 2px); + padding: var(--flowchat-card-expanded-pad-x, 0.625rem) var(--flowchat-card-expanded-pad-x, 0.625rem) var(--flowchat-card-expanded-pad-x, 0.625rem) 0; + margin-bottom: var(--flowchat-card-gap, 0.42rem); &__header { display: flex; align-items: center; justify-content: space-between; - gap: 10px; - margin-bottom: 10px; + gap: var(--flowchat-control-gap, 0.5rem); + margin-bottom: var(--flowchat-card-gap, 0.42rem); } &__title { - font-size: 13px; + font-size: var(--flowchat-font-size-sm); font-weight: 600; color: var(--color-text-primary); } @@ -25,13 +25,13 @@ &__header-actions { display: flex; align-items: center; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); } &__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; + gap: var(--flowchat-control-gap, 0.5rem); } &__card { @@ -40,14 +40,14 @@ min-height: 86px; display: flex; flex-direction: column; - gap: 6px; - padding: 10px 10px; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: var(--flowchat-card-expanded-pad-x, 0.625rem); } &__card-header { display: flex; align-items: center; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); } &__card-icon { @@ -64,16 +64,16 @@ } &__card-title { - font-size: 13px; + font-size: var(--flowchat-font-size-sm); font-weight: 600; color: var(--color-text-primary); - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); } &__card-desc { - font-size: 12px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-secondary); - line-height: 1.35; + line-height: var(--flowchat-support-line-height, 1.45); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; diff --git a/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.scss b/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.scss index ef7b135b62..d30f66e011 100644 --- a/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.scss +++ b/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.scss @@ -59,13 +59,13 @@ font-size: 21px; font-weight: 680; letter-spacing: 0; - line-height: 1.22; + line-height: var(--flowchat-compact-line-height, 1.32); } } .deep-review-consent__eyebrow { color: color-mix(in srgb, var(--deep-review-accent) 58%, var(--color-text-muted)); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 720; letter-spacing: 0; text-transform: uppercase; @@ -114,8 +114,8 @@ p { margin: 4px 0 0; color: color-mix(in srgb, var(--color-text-secondary) 72%, var(--color-text-primary)); - font-size: 12px; - line-height: 1.58; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-text-line-height, 1.58); } } @@ -135,8 +135,8 @@ p { margin: 4px 0 0; color: var(--color-text-secondary); - font-size: 12px; - line-height: 1.55; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); } } @@ -151,9 +151,9 @@ min-height: 20px; padding: 0; color: color-mix(in srgb, var(--color-text-secondary) 70%, var(--color-text-primary)); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 650; - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); } } @@ -177,8 +177,8 @@ p { margin: 4px 0 0; color: var(--color-text-secondary); - font-size: 12px; - line-height: 1.45; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); } } @@ -189,15 +189,15 @@ strong { color: var(--color-text-primary); - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 780; - line-height: 1.3; + line-height: var(--flowchat-compact-line-height, 1.32); } span { color: color-mix(in srgb, var(--color-text-secondary) 70%, var(--color-text-primary)); - font-size: 12px; - line-height: 1.42; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); text-align: left; } } @@ -228,8 +228,8 @@ color: color-mix(in srgb, var(--color-text-secondary) 72%, var(--color-text-primary)); cursor: pointer; text-align: left; - font-size: 12px; - line-height: 1.25; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-compact-line-height, 1.32); transition: background 160ms ease, border-color 160ms ease, @@ -291,7 +291,7 @@ .deep-review-consent__strategy-option-label { min-width: 0; color: var(--color-text-primary); - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 780; overflow-wrap: anywhere; } @@ -312,9 +312,9 @@ gap: 3px; margin-top: auto; color: color-mix(in srgb, var(--color-text-muted) 62%, var(--color-text-primary)); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 680; - line-height: 1.25; + line-height: var(--flowchat-compact-line-height, 1.32); } .deep-review-consent__reviewer-group-title { @@ -322,7 +322,7 @@ align-items: center; gap: 6px; color: color-mix(in srgb, var(--color-text-secondary) 72%, var(--color-text-primary)); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 720; text-transform: uppercase; } @@ -350,8 +350,8 @@ border-radius: 6px; background: color-mix(in srgb, var(--deep-review-warm) 6%, transparent); color: color-mix(in srgb, var(--color-text-secondary) 72%, var(--color-text-primary)); - font-size: 12px; - line-height: 1.35; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-compact-line-height, 1.32); span { min-width: 0; @@ -361,7 +361,7 @@ strong { color: color-mix(in srgb, var(--deep-review-warm) 72%, var(--color-text-primary)); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 720; text-align: right; white-space: normal; @@ -389,9 +389,9 @@ li.deep-review-consent__skipped-more { .deep-review-consent__fact-title { color: var(--color-text-primary); - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 680; - line-height: 1.35; + line-height: var(--flowchat-compact-line-height, 1.32); } .deep-review-consent__footer { diff --git a/src/web-ui/src/flow_chat/components/FileMentionPicker.scss b/src/web-ui/src/flow_chat/components/FileMentionPicker.scss index e420933fdf..018f8c6d9e 100644 --- a/src/web-ui/src/flow_chat/components/FileMentionPicker.scss +++ b/src/web-ui/src/flow_chat/components/FileMentionPicker.scss @@ -48,11 +48,11 @@ &__header { display: flex; align-items: center; - gap: 5px; - padding: 6px 10px; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: var(--flowchat-inline-gap, 0.35rem) var(--flowchat-card-expanded-pad-x, 0.625rem); border-bottom: 1px solid var(--color-border-subtle); color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 500; letter-spacing: 0.02em; @@ -103,10 +103,10 @@ flex-direction: row; align-items: center; justify-content: center; - gap: 6px; - padding: 12px; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: var(--flowchat-card-expanded-pad-x, 0.625rem); color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); span { opacity: 0.7; @@ -124,14 +124,14 @@ } &__list { - padding: 3px; + padding: 0.18rem; } &__item { display: flex; align-items: center; - gap: 6px; - padding: 4px 8px; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: 0.25rem var(--flowchat-card-expanded-pad-y, 0.5rem); border-radius: 4px; cursor: pointer; transition: all 0.12s ease; @@ -171,13 +171,13 @@ &__item-name { flex: 1; - font-size: 12px; + font-size: var(--flowchat-font-size-sm); font-weight: 400; color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); } &__expand-icon { @@ -191,16 +191,16 @@ display: flex; align-items: center; justify-content: center; - gap: 12px; - padding: 6px 10px; + gap: var(--flowchat-control-gap, 0.5rem); + padding: var(--flowchat-inline-gap, 0.35rem) var(--flowchat-card-expanded-pad-x, 0.625rem); border-top: 1px solid var(--color-border-subtle); - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); color: var(--color-text-muted); span { display: flex; align-items: center; - gap: 3px; + gap: 0.18rem; opacity: 0.7; } @@ -214,7 +214,7 @@ background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 3px; - font-size: 9px; + font-size: var(--flowchat-font-size-xxs); font-family: $font-family-mono; color: var(--color-text-secondary); } @@ -280,4 +280,3 @@ } } } - diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.scss b/src/web-ui/src/flow_chat/components/FlowTextBlock.scss index ac4c47953e..c538890fdd 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.scss +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.scss @@ -4,139 +4,24 @@ .flow-text-block { width: 100%; - /* - * Vertical rhythm of the flow chat is driven by per-flow-item margin-bottom (set - * here on `.flow-text-block`, on `.flow-thinking-item`, and on the tool wrappers). - * This token is the canonical "block gap" between any two - * sibling flow items (text → text, text → tool, tool → thinking, ...), as well as - * between the LAST item of one model round and the FIRST item of the next. - */ margin: 0 0 var(--flowchat-flow-item-gap, 0.45rem) 0; font-size: var(--flowchat-font-size-base); - line-height: var(--flowchat-text-line-height, 1.62); + line-height: var(--flowchat-text-line-height, 1.58); /* Transparent border to match the box-sizing of bordered cards (BaseToolCard, * TerminalRegion, etc.) so that text and card content share the same left edge. */ border: 1px solid transparent; box-sizing: border-box; - /* - * Markdown rendering inside a flow text block: - * — All headings (h1-h6) collapse to body font-size — only `font-weight` distinguishes - * them, so the chat reads as a single typographic flow (no jumping font sizes). - * — Block siblings (p / ul / ol / blockquote / heading / pre) get a compact - * ~0.6em gap from `--markdown-block-gap`, keeping paragraphs compact and readable. - * — List items get a smaller ~0.35em gap so each `li` reads as its own bullet/number - * line but the list still feels denser than paragraph→paragraph. - * — Loose-list `

  • ` is collapsed (display: inline) so loose and tight lists - * share the same vertical rhythm. - * — `ul > li::marker` is forced to body em (Safari < 17 ignores marker font-size, so - * we also draw our own bullet via `::before`) — prevents bullet rows from being - * ~20% taller than ordered-list / paragraph rows. - */ .markdown-renderer { - --markdown-block-gap: 0.5em; - --markdown-paragraph-gap: 0.25em; - font-size: var(--flowchat-font-size-base); - line-height: var(--flowchat-text-line-height, 1.62); - letter-spacing: 0.01em; - - /* Headings: same size as body, just heavier weight. */ - h1, h2, h3, h4, h5, h6 { - font-size: var(--flowchat-font-size-base) !important; - line-height: var(--flowchat-text-line-height, 1.62) !important; - font-weight: 600; - letter-spacing: normal; - border: none; - padding: 0; - color: var(--color-text-primary); - } - - /* Body block elements share the same line-height + font-size as paragraphs. */ - p, - ol, - ul, - li { - font-size: var(--flowchat-font-size-base); - line-height: var(--flowchat-text-line-height, 1.62); - } - - p, - ol, - ul { - margin-top: 0; - margin-bottom: 0; - } - - > * + * { - margin-top: var(--markdown-block-gap); - } - - p + p, - p + h1, - p + h2, - p + h3, - p + h4, - p + h5, - p + h6, - h1 + p, - h2 + p, - h3 + p, - h4 + p, - h5 + p, - h6 + p { - margin-top: var(--markdown-paragraph-gap); - } - - /* List item gap (smaller than block gap, but visible). */ - li + li { - margin-top: 0.35em; - } - - /* Tight rhythm WITHIN a list item (heading line + description, etc.). */ - li > * + * { - margin-top: 0.15em; - } + --markdown-font-size: var(--flowchat-font-size-base); + --markdown-font-size-sm: var(--flowchat-font-size-sm); + --markdown-code-font-size: var(--flowchat-code-font-size, var(--flowchat-font-size-sm)); + --markdown-line-height: var(--flowchat-text-line-height, 1.58); + --markdown-block-gap: var(--flowchat-markdown-block-gap, 0.58em); + --markdown-paragraph-gap: var(--flowchat-markdown-paragraph-gap, 0.28em); - /* Loose-list

  • ...

  • : collapse the inner

    so the bullet line and - * the description read as one paragraph — avoids the markdown loose-mode blank - * line that breaks the visual list rhythm. */ - li > p { - display: inline; - } - - /* Pin list marker to body em so every list / paragraph line shares the exact same - * line box height. (Global Markdown.scss makes ul markers 1.2em + bold.) */ - ul > li::marker, - ol > li::marker { - font-size: 1em !important; - font-weight: normal !important; - } - - ul { - list-style: none; - padding-left: 1.25rem; - } - - ul > li { - position: relative; - } - - ul > li::before { - content: ''; - position: absolute; - left: -0.85rem; - top: 0.65em; - width: 0.32em; - height: 0.32em; - border-radius: 50%; - background: currentColor; - opacity: 0.55; - } - - /* Code fences keep mono font but match flow-chat sizing. */ - pre { - font-size: var(--flowchat-font-size-sm); - } + font-size: var(--flowchat-font-size-base); + line-height: var(--flowchat-text-line-height, 1.58); } /* Keep all rendered link variants (anchors and button-like local links) visibly link-blue. */ @@ -165,13 +50,13 @@ color: inherit !important; } - /* Markdown.scss sets 0.875rem !important on fenced blocks — override with flow-chat scale. */ + /* Flow chat keeps code fences one text step below prose. */ .markdown-renderer .code-block-wrapper pre[class*='language-'], .markdown-renderer .code-block-wrapper pre code, .markdown-renderer .code-block-wrapper pre[style], .markdown-renderer .code-block-wrapper code[style], .markdown-renderer pre code { - font-size: var(--flowchat-font-size-sm) !important; + font-size: var(--flowchat-code-font-size, var(--flowchat-font-size-sm)) !important; } .markdown-renderer .table-wrapper table { @@ -198,7 +83,7 @@ .markdown-renderer .code-block-lang { font-size: var(--flowchat-font-size-xxs); - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); } .markdown-renderer .code-block-wrapper .copy-button { @@ -234,7 +119,7 @@ .text-content { color: var(--color-text-primary); - line-height: var(--flowchat-text-line-height, 1.62); + line-height: var(--flowchat-text-line-height, 1.58); white-space: pre-wrap; word-wrap: break-word; } @@ -245,11 +130,11 @@ &--runtime-status { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--flowchat-control-gap, 0.5rem); min-height: 1.5rem; color: var(--color-text-muted); font-size: var(--flowchat-font-size-base); - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); opacity: 0.86; } @@ -300,4 +185,3 @@ border: none; } } - diff --git a/src/web-ui/src/flow_chat/components/ImageAnalysisCard.scss b/src/web-ui/src/flow_chat/components/ImageAnalysisCard.scss index 2cc4ff9c88..a8da579de9 100644 --- a/src/web-ui/src/flow_chat/components/ImageAnalysisCard.scss +++ b/src/web-ui/src/flow_chat/components/ImageAnalysisCard.scss @@ -89,7 +89,7 @@ display: flex; align-items: center; gap: 6px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); &.analyzing { color: var(--color-primary); @@ -114,7 +114,7 @@ .retry-btn { margin-left: 8px; padding: 2px 8px; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); background: var(--color-error); color: white; border: none; @@ -148,8 +148,8 @@ display: flex; align-items: flex-start; gap: 8px; - font-size: 13px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text); .summary-icon { @@ -165,7 +165,7 @@ gap: 4px; margin-top: 8px; padding: 4px 8px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-primary); background: transparent; border: none; @@ -193,7 +193,7 @@ } h4 { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; color: var(--color-text-secondary); margin: 0 0 6px 0; @@ -202,8 +202,8 @@ } p { - font-size: 13px; - line-height: 1.6; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-text-line-height, 1.58); color: var(--color-text); margin: 0; } @@ -217,7 +217,7 @@ .tag { display: inline-block; padding: 4px 10px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); background: var(--color-surface); color: var(--color-text); border: 1px solid var(--color-border); @@ -229,7 +229,7 @@ display: flex; align-items: center; gap: 8px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-secondary); .meta-separator { @@ -247,7 +247,7 @@ padding: 8px 12px; background: rgba(var(--color-error-rgb), 0.1); border-radius: 6px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-error); } diff --git a/src/web-ui/src/flow_chat/components/InlineDiffPreview.scss b/src/web-ui/src/flow_chat/components/InlineDiffPreview.scss index fd7d4558d2..055acdeb7a 100644 --- a/src/web-ui/src/flow_chat/components/InlineDiffPreview.scss +++ b/src/web-ui/src/flow_chat/components/InlineDiffPreview.scss @@ -10,7 +10,7 @@ width: 100%; background: transparent; font-family: var(--markdown-font-mono, "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace); - font-size: 12px; + font-size: var(--flowchat-font-size-sm); font-weight: 400; &__content { @@ -43,7 +43,7 @@ justify-content: center; min-height: 60px; color: var(--color-text-muted, #888); - font-size: 12px; + font-size: var(--flowchat-font-size-sm); font-style: italic; } @@ -57,7 +57,7 @@ display: flex; align-items: stretch; min-height: 22px; - line-height: 1.6; + line-height: var(--flowchat-code-line-height, 1.52); cursor: pointer; transition: background-color 0.15s ease; @@ -115,7 +115,7 @@ &--separator { background: var(--color-bg-tertiary, rgba(255, 255, 255, 0.02)); justify-content: center; - padding: 4px 0; + padding: var(--flowchat-inline-gap, 0.35rem) 0; cursor: default; &:hover { @@ -128,7 +128,7 @@ .diff-line__content--separator { color: var(--color-text-muted, #888); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-style: italic; text-align: center; } @@ -139,7 +139,7 @@ align-items: center; flex-shrink: 0; padding: 0 8px; - gap: 2px; + gap: 0.125rem; background: var(--color-bg-secondary, rgba(255, 255, 255, 0.03)); user-select: none; border-right: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.05)); @@ -161,7 +161,7 @@ text-align: right; color: var(--color-text-muted, #666); opacity: 0.6; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); &--original { border-right: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.08)); diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.scss b/src/web-ui/src/flow_chat/components/ModelSelector.scss index ae3a81d6b9..0d5af0e94f 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.scss +++ b/src/web-ui/src/flow_chat/components/ModelSelector.scss @@ -28,7 +28,7 @@ border: none; background: transparent; color: var(--color-text-secondary); - font-size: 9px; + font-size: var(--flowchat-font-size-2xs); font-weight: 400; cursor: pointer; transition: all 0.2s ease; @@ -80,7 +80,7 @@ border-radius: 3px; background: rgba(100, 140, 255, 0.15); color: rgba(140, 180, 255, 0.9); - font-size: 7px; + font-size: var(--flowchat-font-size-2xs); font-weight: 600; letter-spacing: 0.5px; line-height: 1; @@ -94,7 +94,7 @@ border-radius: 3px; background: rgba(100, 200, 160, 0.15); color: rgba(100, 200, 160, 0.9); - font-size: 7px; + font-size: var(--flowchat-font-size-2xs); font-weight: 600; letter-spacing: 0.3px; line-height: 1; @@ -102,7 +102,7 @@ } &__ctx-usage { - font-size: 9px; + font-size: var(--flowchat-font-size-2xs); font-weight: 400; color: var(--color-text-muted); opacity: 0.55; @@ -160,7 +160,7 @@ justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 500; color: var(--color-text-muted); text-transform: uppercase; @@ -168,7 +168,7 @@ } &__dropdown-hint { - font-size: 9px; + font-size: var(--flowchat-font-size-2xs); color: var(--color-text-tertiary); font-weight: 400; text-transform: none; @@ -255,7 +255,7 @@ display: flex; align-items: center; gap: 4px; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 500; color: var(--color-text-secondary); white-space: nowrap; @@ -264,7 +264,7 @@ } &__option-desc { - font-size: 9px; + font-size: var(--flowchat-font-size-2xs); color: var(--color-text-tertiary); white-space: nowrap; overflow: hidden; @@ -283,7 +283,7 @@ } &__option-subdesc { - font-size: 9px; + font-size: var(--flowchat-font-size-2xs); color: var(--color-text-muted); white-space: nowrap; overflow: hidden; diff --git a/src/web-ui/src/flow_chat/components/PendingQueuePanel.scss b/src/web-ui/src/flow_chat/components/PendingQueuePanel.scss index 4097fe51c9..e9e04ee96e 100644 --- a/src/web-ui/src/flow_chat/components/PendingQueuePanel.scss +++ b/src/web-ui/src/flow_chat/components/PendingQueuePanel.scss @@ -56,8 +56,8 @@ &__title { flex: 1 1 auto; - font-size: 10px; - line-height: 1.25; + font-size: var(--flowchat-font-size-xxs); + line-height: var(--flowchat-compact-line-height, 1.32); font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; @@ -65,8 +65,8 @@ } &__hint { - font-size: 9px; - line-height: 1.25; + font-size: var(--flowchat-font-size-2xs); + line-height: var(--flowchat-compact-line-height, 1.32); color: var(--color-text-muted); font-weight: 400; text-transform: none; @@ -139,7 +139,7 @@ &__preview { font-size: var(--flowchat-font-size-base, 13px); - line-height: 1.45; + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-primary); white-space: pre-wrap; word-break: break-word; @@ -182,7 +182,7 @@ padding: 6px 8px; font-size: var(--flowchat-font-size-base, 13px); font-family: inherit; - line-height: 1.45; + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-primary); border: 1px solid color-mix(in srgb, var(--color-accent-400) 28%, transparent); border-radius: 6px; diff --git a/src/web-ui/src/flow_chat/components/PlannerButton.scss b/src/web-ui/src/flow_chat/components/PlannerButton.scss index bf06ff5576..fdd5950c4e 100644 --- a/src/web-ui/src/flow_chat/components/PlannerButton.scss +++ b/src/web-ui/src/flow_chat/components/PlannerButton.scss @@ -97,7 +97,7 @@ } &__text { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 500; white-space: nowrap; position: relative; @@ -105,7 +105,7 @@ } &__progress { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 600; white-space: nowrap; position: relative; diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.scss b/src/web-ui/src/flow_chat/components/RichTextInput.scss index a9d677d24a..4fccdc7e54 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.scss +++ b/src/web-ui/src/flow_chat/components/RichTextInput.scss @@ -78,7 +78,7 @@ cursor: pointer; user-select: text; vertical-align: middle; - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); transition: all 0.2s ease; white-space: nowrap; @@ -123,7 +123,7 @@ cursor: default; user-select: none; vertical-align: middle; - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); &:hover { @@ -191,7 +191,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); font-family: $font-family-mono; font-size: 0.85em; } @@ -215,7 +215,7 @@ border-radius: 999px; background: rgba(255, 255, 255, 0.16); color: inherit; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; diff --git a/src/web-ui/src/flow_chat/components/ScrollToBottomButton.scss b/src/web-ui/src/flow_chat/components/ScrollToBottomButton.scss index 752045af10..3ccf12f964 100644 --- a/src/web-ui/src/flow_chat/components/ScrollToBottomButton.scss +++ b/src/web-ui/src/flow_chat/components/ScrollToBottomButton.scss @@ -84,7 +84,7 @@ backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); - font-size: 9px; + font-size: var(--flowchat-font-size-2xs); font-weight: 600; line-height: 1; diff --git a/src/web-ui/src/flow_chat/components/SnapshotRollbackButton.scss b/src/web-ui/src/flow_chat/components/SnapshotRollbackButton.scss index 299035a89c..2ce5a06ed3 100644 --- a/src/web-ui/src/flow_chat/components/SnapshotRollbackButton.scss +++ b/src/web-ui/src/flow_chat/components/SnapshotRollbackButton.scss @@ -7,9 +7,9 @@ align-items: center; gap: 6px; padding: 4px 12px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); border-radius: 6px; border: 1px solid var(--color-border, #e5e7eb); background: var(--color-bg-elevated, #ffffff); diff --git a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.scss b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.scss index e0f28d8dbc..bd0f439338 100644 --- a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.scss +++ b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.scss @@ -17,7 +17,7 @@ align-items: center; justify-content: center; color: var(--color-text-muted); - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } } @@ -39,7 +39,7 @@ } &__header-title { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--color-text-primary); overflow: hidden; @@ -52,7 +52,7 @@ &__header-badge { padding: 2px 6px; border-radius: 4px; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 500; color: var(--color-text-secondary); background: rgba(255, 255, 255, 0.08); @@ -67,7 +67,7 @@ display: flex; align-items: center; gap: 3px; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-muted); } @@ -105,8 +105,8 @@ &-text { flex: 1; - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-error); word-break: break-word; user-select: text; @@ -130,7 +130,7 @@ summary { padding: 8px 12px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--color-text-secondary); cursor: pointer; @@ -158,9 +158,9 @@ margin: 0; padding: 10px 12px; font-family: inherit; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); color: var(--tool-card-text-secondary, var(--color-text-secondary)); background: transparent; white-space: pre-wrap; @@ -195,7 +195,7 @@ summary { padding: 8px 12px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--color-text-secondary); cursor: pointer; @@ -227,13 +227,13 @@ border: 1px dashed var(--tool-card-border, rgba(255, 255, 255, 0.15)); border-top: none; border-radius: 0 0 6px 6px; - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); } &__reviewer-role { font-weight: 600; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } &__reviewer-desc { @@ -278,8 +278,8 @@ } &__actions-hint { - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-muted); } @@ -291,14 +291,14 @@ gap: 12px; padding: 40px 20px; color: var(--color-text-muted); - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } &__loading--inline { flex-direction: row; justify-content: center; padding: 10px 12px 4px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); flex-shrink: 0; } @@ -308,7 +308,7 @@ gap: 8px; padding: 10px 12px; border-radius: 6px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-error); background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.2); diff --git a/src/web-ui/src/flow_chat/components/TokenUsageIndicator.scss b/src/web-ui/src/flow_chat/components/TokenUsageIndicator.scss index 80b1a8ac79..28013528cc 100644 --- a/src/web-ui/src/flow_chat/components/TokenUsageIndicator.scss +++ b/src/web-ui/src/flow_chat/components/TokenUsageIndicator.scss @@ -11,7 +11,7 @@ justify-content: center; padding: 0; border-radius: 8px; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; transition: all 0.25s ease; cursor: default; @@ -69,9 +69,9 @@ } &__percentage { - line-height: 1; + line-height: var(--flowchat-compact-line-height, 1.32); font-weight: 600; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); } // Hover behavior @@ -161,4 +161,3 @@ opacity: 1; } } - diff --git a/src/web-ui/src/flow_chat/components/TurnHistoryPanel.scss b/src/web-ui/src/flow_chat/components/TurnHistoryPanel.scss index 813d528012..19b31242e0 100644 --- a/src/web-ui/src/flow_chat/components/TurnHistoryPanel.scss +++ b/src/web-ui/src/flow_chat/components/TurnHistoryPanel.scss @@ -22,7 +22,7 @@ } .turn-count { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: #6b7280; background: #f3f4f6; padding: 4px 8px; @@ -75,7 +75,7 @@ border-radius: 4px; .files-label { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: #6b7280; font-weight: 500; display: block; @@ -87,14 +87,14 @@ padding-left: 16px; .file-item { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: #374151; margin: 2px 0; font-family: 'Monaco', 'Consolas', monospace; } .file-item-more { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: #6b7280; font-style: italic; list-style: none; @@ -104,7 +104,7 @@ } .turn-item-time { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: #9ca3af; margin-top: 8px; } @@ -128,7 +128,7 @@ font-size: 14px; &.hint { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: #9ca3af; } } diff --git a/src/web-ui/src/flow_chat/components/TurnRollbackButton.scss b/src/web-ui/src/flow_chat/components/TurnRollbackButton.scss index 4c3bca41c2..31cd2e482b 100644 --- a/src/web-ui/src/flow_chat/components/TurnRollbackButton.scss +++ b/src/web-ui/src/flow_chat/components/TurnRollbackButton.scss @@ -1,6 +1,6 @@ .turn-rollback-button { padding: 4px 12px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; background: #3b82f6; color: white; @@ -30,7 +30,7 @@ .turn-rollback-button-current { padding: 4px 12px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; background: #22c55e; color: white; diff --git a/src/web-ui/src/flow_chat/components/UserMessage.css b/src/web-ui/src/flow_chat/components/UserMessage.css index dbc15862e1..a6ff899655 100644 --- a/src/web-ui/src/flow_chat/components/UserMessage.css +++ b/src/web-ui/src/flow_chat/components/UserMessage.css @@ -1,6 +1,6 @@ /* User message styling aligned with inline tags. */ .message-inline-content { - line-height: 1.8; + line-height: var(--flowchat-text-line-height, 1.58); word-break: break-word; white-space: pre-wrap; } @@ -15,7 +15,7 @@ border-radius: 12px; font-size: 0.875em; font-weight: 500; - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); border: 1px solid; vertical-align: middle; cursor: default; @@ -53,7 +53,7 @@ white-space: nowrap; opacity: 0.95; font-weight: 500; - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); display: flex; align-items: center; } diff --git a/src/web-ui/src/flow_chat/components/UserMessage.scss b/src/web-ui/src/flow_chat/components/UserMessage.scss index f21e700a97..f092f3969e 100644 --- a/src/web-ui/src/flow_chat/components/UserMessage.scss +++ b/src/web-ui/src/flow_chat/components/UserMessage.scss @@ -46,7 +46,7 @@ .message-content { font-weight: 500; - line-height: 1.6; + line-height: var(--flowchat-text-line-height, 1.58); color: var(--color-text-primary); position: relative; z-index: 1; @@ -55,7 +55,7 @@ } .message-inline-content { - line-height: 1.8; + line-height: var(--flowchat-text-line-height, 1.58); word-break: break-word; white-space: pre-wrap; } diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.css b/src/web-ui/src/flow_chat/components/WelcomePanel.css index 1974b6929d..592789994f 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.css +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.css @@ -9,7 +9,7 @@ justify-content: center; width: 100%; height: 100%; - padding: 32px 24px; + padding: var(--size-gap-8, 32px) var(--size-gap-6, 24px); box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain; @@ -19,7 +19,7 @@ .welcome-panel__content { display: flex; flex-direction: column; - gap: 28px; + gap: var(--size-gap-6, 24px); width: 100%; max-width: 520px; } @@ -29,20 +29,20 @@ .welcome-panel__greeting { display: flex; flex-direction: column; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); } .welcome-panel__greeting-inner { display: flex; flex-direction: row; align-items: flex-end; - gap: 16px; + gap: var(--size-gap-4, 16px); } .welcome-panel__greeting-text { display: flex; flex-direction: column; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); min-width: 0; } @@ -86,18 +86,18 @@ .welcome-panel__heading { margin: 0; - font-size: 26px; + font-size: var(--flowchat-font-size-4xl); font-weight: 700; color: var(--color-text-primary); - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); letter-spacing: -0.02em; } .welcome-panel__tagline { margin: 6px 0 0; - font-size: 15px; + font-size: var(--flowchat-font-size-lg); color: var(--color-text-muted); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); } /* ── Divider ── */ @@ -115,8 +115,8 @@ .welcome-panel__narrative-text { margin: 0; - font-size: 15px; - line-height: 1.55; + font-size: var(--flowchat-font-size-lg); + line-height: var(--flowchat-text-line-height, 1.58); color: var(--color-text-muted); } @@ -136,7 +136,7 @@ .welcome-panel__inline-btn { display: inline-flex; align-items: center; - gap: 3px; + gap: 0.18rem; padding: 0 5px; margin: 0 1px; background: none; @@ -232,7 +232,7 @@ max-width: 300px; background: var(--color-bg-elevated); border: 1px solid var(--border-base); - border-radius: 10px; + border-radius: calc(var(--flowchat-card-radius, 8px) + 2px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); overflow: hidden; animation: wp-dropdownIn 0.15s ease-out; @@ -241,9 +241,9 @@ .welcome-panel__dropdown-current { display: flex; align-items: center; - gap: 8px; - padding: 10px 14px; - font-size: 13px; + gap: var(--flowchat-control-gap, 0.5rem); + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); + font-size: var(--flowchat-font-size-sm); font-weight: 600; color: var(--color-text-primary); } @@ -261,15 +261,15 @@ .welcome-panel__dropdown-item { display: flex; align-items: center; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); width: 100%; - padding: 10px 14px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); box-sizing: border-box; background: transparent; border: 0; color: var(--color-text-secondary); font: inherit; - font-size: 13px; + font-size: var(--flowchat-font-size-sm); text-align: left; cursor: pointer; transition: background 0.12s, color 0.12s; @@ -324,11 +324,11 @@ @media (max-width: 480px) { .welcome-panel { - padding: 20px 16px; + padding: var(--size-gap-5, 20px) var(--size-gap-4, 16px); } .welcome-panel__heading { - font-size: 22px; + font-size: var(--flowchat-font-size-3xl); } .welcome-panel__dropdown { diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss index 1550f47cd5..ed75b6a590 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss @@ -21,7 +21,7 @@ justify-content: center; padding: 0 16px; color: var(--color-text-muted); - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); text-align: center; } } @@ -64,7 +64,7 @@ &__badge { padding: 2px 6px; border-radius: 4px; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 500; color: var(--color-text-secondary); background: rgba(255, 255, 255, 0.08); @@ -72,7 +72,7 @@ } &__title { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--color-text-primary); overflow: hidden; @@ -88,7 +88,7 @@ gap: 4px; min-width: 0; max-width: 100%; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-muted); white-space: nowrap; overflow: hidden; @@ -168,7 +168,7 @@ justify-content: center; min-height: 120px; color: var(--color-text-muted); - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); text-align: center; } @@ -204,7 +204,7 @@ background: var(--color-bg-secondary); border: 1px solid var(--border-base); color: var(--color-text-primary); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; cursor: pointer; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); @@ -229,7 +229,7 @@ padding: 2px 8px; border-radius: 10px; background: var(--element-bg-soft); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-secondary); font-variant-numeric: tabular-nums; flex-shrink: 0; diff --git a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.scss b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.scss index 3865a28f69..5e8a16a587 100644 --- a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.scss +++ b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.scss @@ -12,8 +12,8 @@ display: flex; flex-direction: column; gap: 8px; - font-size: 12px; - line-height: 1.45; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(255, 255, 255, 0.06); @@ -155,13 +155,13 @@ &__status-title { font-weight: 600; - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-primary); } &__error-message { margin-left: auto; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-error, #ef4444); max-width: 50%; overflow: hidden; @@ -185,7 +185,7 @@ background: transparent; border: none; color: var(--color-text-primary); - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; cursor: pointer; transition: background 0.2s; @@ -232,7 +232,7 @@ width: 100%; text-align: left; transition: background 0.2s; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-primary); border-radius: 6px; @@ -268,7 +268,7 @@ } &__remediation-group-count { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-muted); font-variant-numeric: tabular-nums; } @@ -338,17 +338,17 @@ border-radius: 4px; background: var(--element-bg-subtle); color: var(--color-text-muted); - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 500; - line-height: 1.3; + line-height: var(--flowchat-compact-line-height, 1.32); vertical-align: text-bottom; } &__remediation-text { flex: 1; min-width: 0; - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-secondary); display: -webkit-box; -webkit-line-clamp: 2; @@ -371,11 +371,11 @@ &__remediation-tradeoffs { display: block; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); color: var(--color-text-muted); font-style: italic; margin-top: 2px; - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); } &__decision-toggle { @@ -385,7 +385,7 @@ border: none; background: none; color: var(--color-accent-500, #60a5fa); - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); cursor: pointer; opacity: 0.8; transition: opacity 0.15s ease; @@ -415,8 +415,8 @@ border-radius: 4px; background: none; color: var(--color-text-secondary); - font-size: 11px; - line-height: 1.45; + font-size: var(--flowchat-font-size-xs); + line-height: var(--flowchat-support-line-height, 1.45); cursor: pointer; text-align: left; transition: background-color 0.12s ease; @@ -439,8 +439,8 @@ &__decision-option-marker { flex-shrink: 0; - font-size: 10px; - line-height: 1.45; + font-size: var(--flowchat-font-size-xxs); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-muted); } @@ -455,7 +455,7 @@ align-items: center; padding: 1px 6px; border-radius: 4px; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 500; color: var(--color-accent-500, #60a5fa); background: color-mix(in srgb, var(--color-accent-500, #60a5fa) 12%, transparent); @@ -466,9 +466,9 @@ /* Hint texts */ &__remediation-hint { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-muted); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); padding: 0 2px; } @@ -484,7 +484,7 @@ color-mix(in srgb, var(--color-warning, #f59e0b) 10%, var(--color-bg-secondary)) 0%, color-mix(in srgb, var(--color-warning, #f59e0b) 4%, var(--color-bg-secondary)) 100% ); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-secondary); box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent); } @@ -519,15 +519,15 @@ &__decision-gate-title { color: var(--color-text-primary); - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 650; } &__decision-gate-desc { margin-top: 2px; color: var(--color-text-secondary); - font-size: 12px; - line-height: 1.45; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); } &__decision-gate-items { @@ -549,7 +549,7 @@ &__decision-question { color: var(--color-text-primary); font-weight: 600; - line-height: 1.45; + line-height: var(--flowchat-support-line-height, 1.45); } &__decision-plan, @@ -557,8 +557,8 @@ &__decision-gate-note, &__decision-gate-warning { color: var(--color-text-secondary); - font-size: 12px; - line-height: 1.45; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); } &__decision-tradeoffs { @@ -582,8 +582,8 @@ border-radius: 6px; background: transparent; color: var(--color-text-secondary); - font-size: 12px; - line-height: 1.45; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); text-align: left; cursor: pointer; transition: @@ -623,7 +623,7 @@ flex-direction: column; gap: 6px; color: var(--color-text-secondary); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } &__decision-gate-supplement textarea { @@ -635,8 +635,8 @@ border-radius: 7px; background: var(--color-bg-primary); color: var(--color-text-primary); - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); resize: vertical; &::placeholder { @@ -676,7 +676,7 @@ } &__no-issues-text { - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-secondary); } @@ -697,7 +697,7 @@ &__fix-done-text { color: var(--color-text-secondary); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } /* Custom instructions */ @@ -716,7 +716,7 @@ background: transparent; border: 1px solid transparent; color: var(--color-text-muted); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); cursor: pointer; transition: background 0.2s, border-color 0.2s, color 0.2s; width: fit-content; @@ -735,8 +735,8 @@ border: 1px solid var(--border-base); background: var(--color-bg-primary); color: var(--color-text-primary); - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); resize: vertical; min-height: 52px; max-height: 120px; @@ -787,7 +787,7 @@ padding: 8px 10px; border-radius: 6px; background: var(--element-bg-subtle); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-secondary); margin-bottom: 8px; } @@ -805,7 +805,7 @@ padding: 6px 10px; border-radius: 6px; background: var(--element-bg-subtle); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } &__progress-text { @@ -879,7 +879,7 @@ border: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 24%, var(--border-base)); background: color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent); color: var(--color-text-secondary); - line-height: 1.35; + line-height: var(--flowchat-compact-line-height, 1.32); } &__capacity-queue-reviewers { @@ -891,7 +891,7 @@ &__capacity-queue-reviewers-title { color: var(--color-text-secondary); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; } @@ -911,7 +911,7 @@ border-radius: 999px; background: color-mix(in srgb, var(--color-bg-primary) 72%, transparent); color: var(--color-text-secondary); - line-height: 1.35; + line-height: var(--flowchat-compact-line-height, 1.32); } &__capacity-queue-reviewer-name { @@ -921,7 +921,7 @@ &__capacity-queue-reviewer-meta { color: var(--color-text-muted); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } &__capacity-queue-actions { @@ -940,7 +940,7 @@ padding: 8px 12px; border-radius: 8px; background: var(--element-bg-subtle); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } &__partial-count { @@ -956,7 +956,7 @@ background: none; border: none; cursor: pointer; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); padding: 2px 6px; border-radius: 4px; transition: background 0.2s; @@ -977,7 +977,7 @@ } &__partial-section { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-secondary); } @@ -993,7 +993,7 @@ &__partial-text { margin: 4px 0; color: var(--color-text-secondary); - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); } /* Error attribution */ @@ -1003,7 +1003,7 @@ gap: 10px; padding: 9px 12px; border-radius: 8px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); border: 1px solid var(--border-subtle, var(--border-base)); background: color-mix(in srgb, var(--color-bg-secondary) 82%, transparent); @@ -1042,7 +1042,7 @@ &__attribution-message { flex: 1 1 auto; color: var(--color-text-secondary); - line-height: 1.45; + line-height: var(--flowchat-support-line-height, 1.45); } /* Recovery plan */ @@ -1066,7 +1066,7 @@ display: flex; align-items: center; gap: 8px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-secondary); } @@ -1096,7 +1096,7 @@ } &__degradation-title { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; color: var(--color-text-primary); } @@ -1125,13 +1125,13 @@ } &__degradation-label { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--color-text-primary); } &__degradation-desc { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-muted); } diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss index c3368e241a..e67fd9cddd 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss +++ b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss @@ -15,7 +15,7 @@ .explore-region--collapsible { margin: 0 0 0.3rem 0; font-size: var(--flowchat-font-size-sm); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); .explore-region__header { display: flex; @@ -24,7 +24,7 @@ padding: 0; color: var(--text-tertiary, #6b7280); font-size: var(--flowchat-font-size-sm); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); cursor: pointer; user-select: none; border-radius: var(--radius-sm, 6px); @@ -46,7 +46,7 @@ .explore-region__summary { font-size: var(--flowchat-font-size-sm); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); opacity: 0.8; } diff --git a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.scss b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.scss index 8d123acf58..a091ddcc7f 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.scss +++ b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.scss @@ -99,14 +99,14 @@ } &__subtitle { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-muted); display: inline-block; } &__timestamp { margin-left: auto; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-muted); display: inline-block; flex-shrink: 0; @@ -123,7 +123,7 @@ border-radius: 12px; padding: 14px 16px; font-size: 14px; - line-height: 1.6; + line-height: var(--flowchat-text-line-height, 1.58); color: var(--color-text-primary); white-space: pre-wrap !important; word-break: break-word; @@ -193,7 +193,7 @@ align-items: center; justify-content: center; gap: 6px; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-muted); white-space: nowrap; diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss index 77a5ca0d05..b56a0c203d 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss @@ -7,7 +7,7 @@ * round transitions all read as one continuous, evenly-spaced text column. */ margin-bottom: 0; - padding: 0 3rem; + padding: 0 var(--flowchat-content-inline-pad, 3rem); animation: fadeIn 0.4s ease; position: relative; @@ -23,15 +23,15 @@ } @media (max-width: 768px) { - padding: 0 1.5rem; + padding: 0 var(--flowchat-content-inline-pad-mobile, 1.5rem); } } .model-round-item__header { display: flex; justify-content: flex-end; - margin-bottom: 0.35rem; - padding: 0 0.5rem; + margin-bottom: var(--flowchat-inline-gap, 0.35rem); + padding: 0 var(--flowchat-control-gap, 0.5rem); } // Action button container (bottom-right of last round). @@ -39,9 +39,9 @@ display: flex; align-items: center; justify-content: flex-end; - gap: 0.4rem; - padding: 0.35rem 0; - margin-top: 0.35rem; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: var(--flowchat-inline-gap, 0.35rem) 0; + margin-top: var(--flowchat-inline-gap, 0.35rem); } .model-round-item__action-btn { @@ -157,13 +157,13 @@ opacity: 1; transform: translateY(0); max-height: 96px; - margin-bottom: 0.35rem; + margin-bottom: var(--flowchat-flow-item-gap, 0.42rem); } 55% { opacity: 0.72; transform: translateY(-1px); max-height: 96px; - margin-bottom: 0.35rem; + margin-bottom: var(--flowchat-flow-item-gap, 0.42rem); } 100% { opacity: 0; @@ -184,6 +184,6 @@ .flowchat-flow-item.flowchat-flow-item--focused { outline: 2px solid var(--border-accent); outline-offset: 2px; - border-radius: 12px; + border-radius: var(--flowchat-card-radius, 8px); box-shadow: var(--glow-shadow-blue); } diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss index d0b09c1149..b6b423e311 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss @@ -5,6 +5,7 @@ .modern-flowchat-container.flow-chat-typography { font-family: var(--font-sans, var(--font-family-sans, inherit)); font-size: var(--flowchat-font-size-base); + line-height: var(--flowchat-text-line-height, 1.58); } .modern-flowchat-container { @@ -38,7 +39,7 @@ &__input { flex-shrink: 0; - padding: 1rem; + padding: var(--flowchat-card-expanded-pad-x, 0.625rem); background: var(--color-surface, #1e1e1e); border-top: 1px solid var(--color-border, #3a3a3a); } @@ -52,8 +53,8 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 14px; - padding: 32px; + gap: var(--flowchat-control-gap, 0.5rem); + padding: var(--size-gap-8, 32px); color: var(--color-text-secondary); text-align: center; } @@ -88,15 +89,15 @@ .history-session-placeholder__title { margin: 0; color: var(--color-text-primary); - font-size: 16px; + font-size: var(--flowchat-font-size-xl); font-weight: 600; - line-height: 1.35; + line-height: var(--flowchat-support-line-height, 1.45); } .history-session-placeholder__description { margin: 0; - font-size: 13px; - line-height: 1.5; + font-size: var(--flowchat-font-size-sm); + line-height: var(--flowchat-support-line-height, 1.45); } .history-session-placeholder__retry { @@ -104,14 +105,14 @@ display: inline-flex; align-items: center; justify-content: center; - gap: 7px; - padding: 0 12px; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: 0 var(--size-gap-3, 12px); border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12)); border-radius: 6px; color: var(--color-text-primary); background: var(--color-surface-hover, rgba(255, 255, 255, 0.06)); font: inherit; - font-size: 13px; + font-size: var(--flowchat-font-size-sm); cursor: pointer; } diff --git a/src/web-ui/src/flow_chat/components/modern/ScrollAnchor.scss b/src/web-ui/src/flow_chat/components/modern/ScrollAnchor.scss index 7b906b2e3a..ac1c7b17fa 100644 --- a/src/web-ui/src/flow_chat/components/modern/ScrollAnchor.scss +++ b/src/web-ui/src/flow_chat/components/modern/ScrollAnchor.scss @@ -118,14 +118,14 @@ } &__preview-indicator { - padding: 8px 12px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); background: var(--color-accent-50); border-bottom: 1px solid var(--color-border); text-align: center; } &__preview-turn { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 600; color: var(--color-accent-500); font-variant-numeric: tabular-nums; @@ -141,18 +141,21 @@ display: flex; align-items: center; justify-content: space-between; - padding: 8px 12px 4px; + padding: + var(--flowchat-card-expanded-pad-y, 0.5rem) + var(--flowchat-card-expanded-pad-x, 0.625rem) + var(--flowchat-inline-gap, 0.35rem); } &__preview-label { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 500; color: var(--color-accent-500); letter-spacing: 0.3px; text-transform: uppercase; display: flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); &::before { content: ''; @@ -164,14 +167,17 @@ } &__preview-time { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); color: var(--color-text-muted); } &__preview-content { - padding: 4px 12px 10px; - font-size: 12px; - line-height: 1.5; + padding: + var(--flowchat-inline-gap, 0.35rem) + var(--flowchat-card-expanded-pad-x, 0.625rem) + var(--flowchat-card-expanded-pad-y, 0.5rem); + font-size: var(--flowchat-font-size-sm); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-primary); max-height: 60px; overflow: hidden; diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.scss b/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.scss index 5b2d08c919..ef95d94228 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.scss +++ b/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.scss @@ -52,7 +52,7 @@ } .file-count { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: $font-weight-medium; color: var(--color-text-primary); white-space: nowrap; @@ -62,7 +62,7 @@ display: flex; align-items: center; gap: $size-gap-2; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: $font-weight-medium; font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace; @@ -151,7 +151,7 @@ background: transparent; border: none; border-radius: 4px; - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-primary); cursor: pointer; transition: all $motion-fast $easing-standard; @@ -204,7 +204,7 @@ border: 1px solid color-mix(in srgb, var(--color-accent-400, #0ea5e9) 18%, var(--border-subtle)); background: color-mix(in srgb, var(--color-accent-400, #0ea5e9) 9%, transparent); color: color-mix(in srgb, var(--color-accent-400, #0ea5e9) 72%, var(--color-text-secondary)); - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: $font-weight-semibold; line-height: 1; white-space: nowrap; @@ -216,7 +216,7 @@ align-items: center; gap: 6px; flex-shrink: 0; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: $font-weight-medium; font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace; @@ -230,7 +230,7 @@ } &__error { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-error); font-style: italic; } @@ -259,7 +259,7 @@ align-items: center; justify-content: center; padding: 8px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-secondary); font-style: italic; background: color-mix(in srgb, var(--color-bg-secondary) 50%, transparent); diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.scss b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.scss index 52ae4bab67..0a48b7088b 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.scss +++ b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.scss @@ -231,7 +231,7 @@ background: transparent; color: var(--color-text-secondary); font-size: var(--flowchat-font-size-sm); - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); text-align: left; cursor: pointer; transition: all $motion-fast $easing-standard; @@ -341,7 +341,7 @@ padding: 8px 10px; border-bottom: 1px solid var(--border-subtle); font-size: var(--flowchat-font-size-xs); - line-height: 1.3; + line-height: var(--flowchat-compact-line-height, 1.32); color: var(--color-text-secondary); } diff --git a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss index 6151261d1a..7cf74b315a 100644 --- a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss +++ b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss @@ -49,11 +49,11 @@ .flow-text-block--subagent-compact { margin-bottom: 0.15rem; - line-height: 1.45; + line-height: var(--flowchat-support-line-height, 1.45); .markdown-renderer, .text-content { - line-height: 1.45; + line-height: var(--flowchat-support-line-height, 1.45); } .markdown-renderer { diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss index 9edcd6deb1..523b60e739 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss @@ -3,11 +3,15 @@ */ .user-message-item { - padding: 0.28rem 0.75rem; + padding: var(--flowchat-user-message-pad-y, 0.32rem) var(--flowchat-user-message-pad-x, 0.75rem); background: color-mix(in srgb, var(--element-bg-medium) 86%, var(--color-bg-flowchat, transparent)); border: 1px solid color-mix(in srgb, var(--border-muted, transparent) 38%, transparent); - border-radius: 8px; - margin: 0.06rem 3rem var(--flowchat-turn-gap, 0.28rem) 3rem; + border-radius: var(--flowchat-card-radius, 8px); + margin: + 0.06rem + var(--flowchat-content-inline-pad, 3rem) + var(--flowchat-turn-gap, 0.46rem) + var(--flowchat-content-inline-pad, 3rem); box-shadow: 0 1px 0 color-mix(in srgb, #fff 4%, transparent) inset; transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; animation: slideInUp 0.3s ease; @@ -28,23 +32,23 @@ // Responsive: reduce margins on smaller screens. @media (max-width: 768px) { - margin-left: 1.5rem; - margin-right: 1.5rem; + margin-left: var(--flowchat-content-inline-pad-mobile, 1.5rem); + margin-right: var(--flowchat-content-inline-pad-mobile, 1.5rem); } } .user-message-item__timestamp { - font-size: 0.75em; + font-size: var(--flowchat-font-size-xxs); color: var(--color-text-muted, #888); font-weight: 400; - margin-bottom: 0.1rem; - line-height: 1.25; + margin-bottom: 0.12rem; + line-height: var(--flowchat-compact-line-height, 1.32); } .user-message-item__main { display: flex; align-items: center; - gap: 0.35rem; + gap: var(--flowchat-inline-gap, 0.35rem); } // Bridges children to `.user-message-item__main` flex without an extra layout box. @@ -57,12 +61,12 @@ align-items: center; flex-shrink: 0; width: fit-content; - padding: 2px 7px; + padding: 0.12rem 0.44rem; border-radius: 999px; - font-size: 0.72rem; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; - line-height: 1.25; - letter-spacing: 0.01em; + line-height: var(--flowchat-compact-line-height, 1.32); + letter-spacing: 0; user-select: none; } @@ -75,19 +79,19 @@ .user-message-item__actions { display: flex; align-items: center; - gap: 2px; + gap: 0.125rem; flex-shrink: 0; } .user-message-item__blocks { - margin-top: 6px; + margin-top: var(--flowchat-flow-item-gap, 0.42rem); } .user-message-item__content { flex: 1; font-size: var(--flowchat-font-size-base); font-weight: 500; - line-height: 1.52; + line-height: var(--flowchat-text-line-height, 1.58); color: var(--color-text-primary, #e0e0e0); white-space: pre-wrap; display: -webkit-box; @@ -98,7 +102,7 @@ text-overflow: ellipsis; word-break: break-word; min-width: 0; /* Ensures the content can shrink properly. */ - max-height: calc(1.52em * 3); /* Collapsed: up to 3 lines at compact line-height. */ + max-height: 4.75em; /* Collapsed: up to 3 compact lines. */ user-select: text; transition: all 0.2s ease; } @@ -123,7 +127,7 @@ .user-message-item__main { align-items: flex-start; - gap: 0.45rem; + gap: var(--flowchat-flow-item-gap, 0.42rem); } // Text column + actions row: actions sit beside text; optional vertical nudge vs first line. @@ -131,8 +135,8 @@ display: flex; flex-flow: row wrap; align-items: flex-start; - column-gap: 0.35rem; - row-gap: 0.25rem; + column-gap: var(--flowchat-inline-gap, 0.35rem); + row-gap: var(--flowchat-markdown-paragraph-gap, 0.28em); flex: 1; min-width: 0; } @@ -143,7 +147,7 @@ display: flex; flex-direction: column; align-items: flex-start; - gap: 0.35rem; + gap: var(--flowchat-inline-gap, 0.35rem); } .user-message-item__failed-avatar { @@ -391,4 +395,3 @@ transform: translateY(0); } } - diff --git a/src/web-ui/src/flow_chat/components/smart-recommendations/SmartRecommendations.scss b/src/web-ui/src/flow_chat/components/smart-recommendations/SmartRecommendations.scss index eccf2fae93..9a37ea6e11 100644 --- a/src/web-ui/src/flow_chat/components/smart-recommendations/SmartRecommendations.scss +++ b/src/web-ui/src/flow_chat/components/smart-recommendations/SmartRecommendations.scss @@ -18,7 +18,7 @@ } &__title { - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; color: var(--color-text-primary); } @@ -56,7 +56,7 @@ border-radius: 6px; background: var(--color-bg-primary); color: var(--color-text-primary); - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; cursor: pointer; transition: all 0.2s; diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss index 12184df14f..2bf2e64483 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss @@ -51,8 +51,8 @@ flex: 0 0 auto; padding: 2px 7px; border-radius: 999px; - font-size: 10px; - line-height: 1.2; + font-size: var(--flowchat-font-size-xxs); + line-height: var(--flowchat-compact-line-height, 1.32); &--complete { color: var(--color-success); @@ -82,7 +82,7 @@ color: var(--color-text-primary); font-size: 14px; font-weight: 650; - line-height: 1.3; + line-height: var(--flowchat-compact-line-height, 1.32); } } @@ -101,7 +101,7 @@ gap: 6px; min-width: 0; color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); } &__meta-label { @@ -183,7 +183,7 @@ border-bottom: 2px solid transparent; background: transparent; color: var(--color-text-secondary); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); white-space: nowrap; cursor: pointer; @@ -226,7 +226,7 @@ border-left: 2px solid color-mix(in srgb, var(--color-warning) 74%, transparent); background: color-mix(in srgb, var(--color-warning) 8%, transparent); color: var(--color-text-secondary); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); svg { flex: 0 0 auto; @@ -266,7 +266,7 @@ span { color: var(--color-text-secondary); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); } strong { @@ -278,7 +278,7 @@ em { color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-style: normal; } } @@ -302,7 +302,7 @@ dd { min-width: 0; margin: 0; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } dt { @@ -336,13 +336,13 @@ } strong { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } span { color: var(--color-text-secondary); - font-size: 11px; - line-height: 1.45; + font-size: var(--flowchat-font-size-xs); + line-height: var(--flowchat-support-line-height, 1.45); } } @@ -350,7 +350,7 @@ justify-content: space-between; gap: 8px; color: var(--color-text-secondary); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); strong { color: var(--color-text-primary); @@ -361,8 +361,8 @@ &__section-help { margin: -4px 0 0; color: var(--color-text-muted); - font-size: 12px; - line-height: 1.45; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); } &__table-wrap { @@ -376,7 +376,7 @@ width: 100%; min-width: 620px; border-collapse: collapse; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); th, td { @@ -492,7 +492,7 @@ border-radius: 999px; background: color-mix(in srgb, var(--element-bg-soft) 58%, transparent); color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); line-height: 1; white-space: nowrap; @@ -575,7 +575,7 @@ &__turn-index-overflow { color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); white-space: nowrap; } @@ -585,7 +585,7 @@ justify-content: flex-end; gap: 10px; color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); } &__table-expand { @@ -612,7 +612,7 @@ min-height: 120px; padding: 12px; color: var(--color-text-muted); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); text-align: center; border: 1px dashed color-mix(in srgb, var(--border-base) 72%, transparent); border-radius: 6px; @@ -624,7 +624,7 @@ span { max-width: 440px; - line-height: 1.45; + line-height: var(--flowchat-support-line-height, 1.45); } } diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.scss b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.scss index a10e7afd30..4f2a9112ef 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.scss +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.scss @@ -44,14 +44,14 @@ color: var(--color-text-primary); font-size: var(--flowchat-font-size-sm); font-weight: 650; - line-height: 1.35; + line-height: var(--flowchat-compact-line-height, 1.32); } &__loading-description, &__loading-step { color: var(--color-text-secondary); font-size: var(--flowchat-font-size-xs); - line-height: 1.45; + line-height: var(--flowchat-support-line-height, 1.45); } &__loading-description { @@ -100,7 +100,7 @@ color: var(--color-text-primary); font-size: var(--flowchat-font-size-lg); font-weight: 650; - line-height: 1.25; + line-height: var(--flowchat-compact-line-height, 1.32); white-space: nowrap; } @@ -366,7 +366,7 @@ } span { - line-height: 1.35; + line-height: var(--flowchat-compact-line-height, 1.32); } } diff --git a/src/web-ui/src/flow_chat/tool-cards/AcpPermissionActions.scss b/src/web-ui/src/flow_chat/tool-cards/AcpPermissionActions.scss index b71af4a5c1..732126f051 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AcpPermissionActions.scss +++ b/src/web-ui/src/flow_chat/tool-cards/AcpPermissionActions.scss @@ -2,24 +2,24 @@ display: inline-flex; align-items: center; justify-content: flex-end; - gap: 2px; + gap: 0.125rem; flex-shrink: 0; } .acp-permission-actions--text { flex-wrap: wrap; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); } .acp-permission-actions__text-button { min-height: 26px; - padding: 0 10px; + padding: 0 var(--flowchat-card-expanded-pad-x, 0.625rem); border: 1px solid var(--color-border); border-radius: 6px; background: var(--color-surface, transparent); color: var(--color-text); - font-size: 12px; - line-height: 1; + font-size: var(--flowchat-font-size-sm); + line-height: var(--flowchat-compact-line-height, 1.32); cursor: pointer; white-space: nowrap; } diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.scss b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.scss index d85131adf4..d6d21f238f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.scss @@ -60,7 +60,7 @@ } .questions-count { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; color: var(--color-text-secondary); letter-spacing: -0.01em; @@ -88,7 +88,7 @@ display: flex; align-items: center; gap: 6px; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-muted); white-space: nowrap; padding-left: 10px; @@ -116,7 +116,7 @@ align-items: center; gap: 8px; padding: 12px 14px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-secondary); } @@ -160,7 +160,7 @@ color: var(--color-text-secondary); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm, 4px); - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; @@ -169,11 +169,11 @@ } .question-text { - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--color-text-primary); flex: 1; - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); min-width: 0; } @@ -345,17 +345,17 @@ } .option-label-text { - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--color-text-primary); - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); flex-shrink: 0; } .option-description { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-muted); - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); flex: 1; text-align: right; min-width: 0; @@ -416,7 +416,7 @@ padding: 6px 10px; border: 1px solid var(--border-base); border-radius: var(--radius-sm, 4px); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-primary); background: var(--color-bg-tertiary); transition: all 0.15s ease; @@ -449,7 +449,7 @@ color: var(--color-text-primary); border: 1px solid var(--border-base); border-radius: var(--radius-sm, 5px); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; cursor: pointer; transition: all 0.15s ease; @@ -507,20 +507,20 @@ } .summary-questions-count { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; color: var(--color-text-secondary); flex-shrink: 0; } .summary-arrow { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-muted); flex-shrink: 0; } .summary-answer { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--color-text-primary); flex: 1; @@ -550,7 +550,7 @@ gap: 8px; padding: 8px 12px; border-radius: var(--radius-md, 6px); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 500; margin: 0 14px 12px; background: var(--color-warning-bg); @@ -566,7 +566,7 @@ /* ========== Error message ========== */ .error-message { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-error); text-align: center; padding: 12px; diff --git a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss index 466bac4878..ba0ff21559 100644 --- a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss @@ -9,8 +9,8 @@ .base-tool-card-wrapper { display: flex; flex-direction: column; - margin: 4px 0; - border-radius: 8px; + margin: var(--flowchat-card-gap, 0.42rem) 0; + border-radius: var(--flowchat-card-radius, 8px); border: 1px solid var(--border-base); background: var(--color-bg-flowchat); backdrop-filter: blur(10px); @@ -94,14 +94,14 @@ .base-tool-card-header { display: flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); padding: var(--tool-card-header-pad-y) var(--tool-card-header-pad-right) var(--tool-card-header-pad-y) var(--tool-card-header-pad-x); position: relative; z-index: 11; flex-shrink: 0; min-height: 0; /* 1.2 clips CJK / some Latin descenders inside overflow:hidden; keep room for em-box */ - line-height: 1.45; + line-height: var(--flowchat-support-line-height, 1.45); width: 100%; box-sizing: border-box; background: transparent; @@ -138,28 +138,28 @@ /* Action text */ .tool-card-action { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--color-text-primary); transition: color 0.2s ease; letter-spacing: -0.01em; flex-shrink: 0; - line-height: 1.45; + line-height: var(--tool-card-action-line-height, var(--flowchat-support-line-height, 1.45)); } /* Main content */ .tool-card-content { display: flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); flex: 1 1 auto; min-width: 0; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--color-text-primary); transition: color 0.2s ease; letter-spacing: -0.01em; - line-height: 1.45; + line-height: var(--tool-card-action-line-height, var(--flowchat-support-line-height, 1.45)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -177,7 +177,7 @@ .tool-card-extra { display: flex; align-items: center; - gap: 2px; + gap: 0.125rem; flex-shrink: 0; min-width: 0; @@ -189,8 +189,8 @@ /* ========== Expanded content area ========== */ .base-tool-card-expanded { /* Full-bleed dividers in children use these (header pad-x is 0; do not conflate). */ - --tool-card-expanded-pad-x: 9px; - --tool-card-expanded-pad-y: 8px; + --tool-card-expanded-pad-x: var(--flowchat-card-expanded-pad-x, 0.625rem); + --tool-card-expanded-pad-y: var(--flowchat-card-expanded-pad-y, 0.5rem); padding: var(--tool-card-expanded-pad-y) var(--tool-card-expanded-pad-x); background: transparent; position: relative; @@ -201,9 +201,13 @@ /* ========== Error details area ========== */ .base-tool-card-error { - margin-top: 6px; - padding: 7px 10px 7px 20px; - margin-left: 6px; + margin-top: var(--flowchat-inline-gap, 0.35rem); + padding: + var(--flowchat-card-expanded-pad-y, 0.5rem) + var(--flowchat-card-expanded-pad-x, 0.625rem) + var(--flowchat-card-expanded-pad-y, 0.5rem) + calc(var(--flowchat-card-expanded-pad-x, 0.625rem) + var(--flowchat-card-expanded-pad-x, 0.625rem)); + margin-left: var(--flowchat-inline-gap, 0.35rem); background: none !important; border: none !important; box-shadow: none !important; @@ -213,15 +217,15 @@ .error-content { display: flex; flex-direction: column; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); background: none !important; border: none !important; padding: 0 !important; } .error-message { - font-size: 12px !important; - line-height: 1.5 !important; + font-size: var(--flowchat-font-size-sm) !important; + line-height: var(--flowchat-support-line-height, 1.45) !important; color: var(--color-text-muted) !important; white-space: pre-wrap !important; word-break: break-word !important; @@ -316,4 +320,3 @@ opacity: 0.25; } } - diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.scss index 802c72407e..5ac5d3e1e3 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.scss @@ -88,7 +88,7 @@ .review-reliability-status__title { color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 650; } @@ -109,8 +109,8 @@ border-radius: 4px; background: color-mix(in srgb, var(--color-bg-elevated) 70%, transparent); color: var(--color-text-secondary); - font-size: 11px; - line-height: 1.4; + font-size: var(--flowchat-font-size-xs); + line-height: var(--flowchat-support-line-height, 1.45); &--warning { border-left-color: var(--color-warning, #f59e0b); @@ -151,7 +151,7 @@ border-bottom: 1px dashed var(--border-base); .summary-header { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; color: var(--color-text-primary); margin-bottom: 8px; @@ -172,8 +172,8 @@ align-items: center; gap: 12px; padding: 7px 10px; - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); border-bottom: 1px dashed var(--border-base); &:last-child { @@ -191,7 +191,7 @@ .summary-label { font-weight: 600; color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); white-space: nowrap; flex-shrink: 0; min-width: 90px; @@ -245,7 +245,7 @@ .review-report-section__title { overflow: hidden; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 650; text-overflow: ellipsis; white-space: nowrap; @@ -253,7 +253,7 @@ .review-report-section__summary { color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); white-space: nowrap; } @@ -275,7 +275,7 @@ .review-report-group__title { color: var(--color-text-primary); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; @@ -287,8 +287,8 @@ margin: 0; padding-left: 18px; color: var(--color-text-secondary); - font-size: 12px; - line-height: 1.55; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); li { padding: 2px 4px; @@ -325,8 +325,8 @@ margin: 0; padding-left: 16px; color: var(--color-text-secondary); - font-size: 12px; - line-height: 1.55; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); li { padding: 1px 0; @@ -340,7 +340,7 @@ .review-decision-item__tradeoffs { color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-style: italic; } @@ -366,13 +366,13 @@ span { color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); } strong { overflow: hidden; color: var(--color-text-primary); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 650; text-overflow: ellipsis; white-space: nowrap; @@ -386,7 +386,7 @@ .run-manifest__group-title { color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 650; &--warning { @@ -413,7 +413,7 @@ .run-manifest__chip-name { overflow: hidden; color: var(--color-text-primary); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; @@ -422,7 +422,7 @@ .run-manifest__chip-meta { overflow: hidden; color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); text-overflow: ellipsis; white-space: nowrap; } @@ -445,7 +445,7 @@ border-radius: 6px; background: var(--color-bg-elevated, rgba(255, 255, 255, 0.03)); color: var(--color-text-secondary); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } span { @@ -457,7 +457,7 @@ strong { color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 650; white-space: nowrap; } @@ -508,7 +508,7 @@ } .reviewer-name { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; color: var(--color-text-primary); } @@ -516,7 +516,7 @@ .reviewer-specialty, .reviewer-status, .reviewer-issues { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); color: var(--color-text-muted); border: 1px solid var(--border-base); background: var(--color-bg-elevated, rgba(255, 255, 255, 0.03)); @@ -526,8 +526,8 @@ } .reviewer-summary { - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-secondary); } @@ -542,8 +542,8 @@ border: 1px solid var(--border-base); border-radius: 6px; padding: 10px; - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); transition: all 0.2s ease; position: relative; overflow: hidden; @@ -594,7 +594,7 @@ font-weight: 600; color: var(--color-text-secondary); flex-shrink: 0; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); padding: 1px 5px; background: var(--color-bg-elevated, rgba(255, 255, 255, 0.03)); border-radius: 3px; @@ -602,7 +602,7 @@ } .issue-source { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); color: var(--color-accent-500, #60a5fa); border: 1px solid color-mix(in srgb, var(--color-accent-500, #60a5fa) 35%, transparent); background: color-mix(in srgb, var(--color-accent-500, #60a5fa) 10%, transparent); @@ -614,14 +614,14 @@ .issue-location { color: var(--color-text-muted); font-family: var(--tool-card-font-mono); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .issue-certainty { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); color: var(--color-text-muted); background: var(--color-bg-elevated, rgba(255, 255, 255, 0.03)); padding: 1px 6px; @@ -635,13 +635,13 @@ font-weight: 600; color: var(--color-text-primary); margin-bottom: 4px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } .issue-description { color: var(--color-text-secondary); margin-bottom: 6px; - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); } .issue-validation-note { @@ -651,7 +651,7 @@ border-radius: 4px; background: rgba(59, 130, 246, 0.08); border: 1px solid rgba(59, 130, 246, 0.16); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); } .issue-suggestion { @@ -669,13 +669,13 @@ font-weight: 600; color: var(--color-success, #16a34a); flex-shrink: 0; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); } .suggestion-text { color: var(--color-text-primary); flex: 1; - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); } } @@ -683,7 +683,7 @@ margin-bottom: 12px; .team-header { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; color: var(--color-text-primary); margin-bottom: 8px; @@ -719,7 +719,7 @@ } .reviewer-name { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; color: var(--color-text-primary); } @@ -727,7 +727,7 @@ .reviewer-specialty, .reviewer-status, .reviewer-issues { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); color: var(--color-text-muted); border: 1px solid var(--border-base); background: var(--color-bg-elevated, rgba(255, 255, 255, 0.03)); @@ -737,8 +737,8 @@ } .reviewer-summary { - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-secondary); } } @@ -750,7 +750,7 @@ .issues-header { display: flex; align-items: center; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; color: var(--color-text-primary); margin-bottom: 8px; @@ -766,8 +766,8 @@ border: 1px solid var(--border-base); border-radius: 6px; padding: 10px; - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); transition: all 0.2s ease; position: relative; overflow: hidden; @@ -817,7 +817,7 @@ font-weight: 600; color: var(--color-text-secondary); flex-shrink: 0; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); padding: 1px 5px; background: var(--color-bg-elevated, rgba(255, 255, 255, 0.03)); border-radius: 3px; @@ -825,7 +825,7 @@ } .issue-source { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); color: var(--color-accent-500, #60a5fa); border: 1px solid color-mix(in srgb, var(--color-accent-500, #60a5fa) 35%, transparent); background: color-mix(in srgb, var(--color-accent-500, #60a5fa) 10%, transparent); @@ -837,7 +837,7 @@ .issue-location { color: var(--color-text-muted); font-family: var(--tool-card-font-mono); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -845,7 +845,7 @@ } .issue-certainty { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); color: var(--color-text-muted); background: var(--color-bg-elevated, rgba(255, 255, 255, 0.03)); padding: 1px 6px; @@ -860,13 +860,13 @@ font-weight: 600; color: var(--color-text-primary); margin-bottom: 4px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } .issue-description { color: var(--color-text-secondary); margin-bottom: 6px; - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); } .issue-validation-note { @@ -876,7 +876,7 @@ border-radius: 4px; background: rgba(59, 130, 246, 0.08); border: 1px solid rgba(59, 130, 246, 0.16); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); } .issue-suggestion { @@ -893,13 +893,13 @@ font-weight: 600; color: var(--color-success, #16a34a); flex-shrink: 0; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); } .suggestion-text { color: var(--color-text-primary); flex: 1; - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); } } } @@ -918,7 +918,7 @@ } .remediation-header { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; color: var(--color-text-primary); margin-bottom: 3px; @@ -926,8 +926,8 @@ .remediation-selection-count { color: var(--color-text-muted); - font-size: 11px; - line-height: 1.35; + font-size: var(--flowchat-font-size-xs); + line-height: var(--flowchat-compact-line-height, 1.32); } .remediation-select-all { @@ -949,8 +949,8 @@ border-radius: 10px; background: var(--color-bg-elevated, rgba(255, 255, 255, 0.03)); color: var(--color-text-primary); - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); &.is-selected { border-color: color-mix(in srgb, var(--color-accent-500, #60a5fa) 34%, var(--border-base)); @@ -989,7 +989,7 @@ border-radius: 999px; background: color-mix(in srgb, var(--color-accent-500, #60a5fa) 16%, transparent); color: var(--color-accent-500, #60a5fa); - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 700; flex-shrink: 0; margin-top: 1px; @@ -1005,7 +1005,7 @@ border-radius: 999px; background: transparent; color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); cursor: pointer; transition: background 160ms ease, @@ -1031,7 +1031,7 @@ p { margin: 0; - line-height: 1.55; + line-height: var(--flowchat-support-line-height, 1.45); } } @@ -1041,7 +1041,7 @@ span { color: var(--color-text-muted); - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; @@ -1069,13 +1069,13 @@ span { color: var(--color-text-muted); - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); } strong { overflow: hidden; color: var(--color-text-primary); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 650; text-overflow: ellipsis; white-space: nowrap; @@ -1129,15 +1129,15 @@ gap: 10px; &__title { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 700; color: var(--color-text-primary); margin-bottom: 3px; } &__hint { - font-size: 11px; - line-height: 1.45; + font-size: var(--flowchat-font-size-xs); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-muted); } @@ -1150,15 +1150,15 @@ &__empty-selection { margin-top: 10px; color: color-mix(in srgb, var(--color-warning, #f59e0b) 80%, var(--color-text-secondary)); - font-size: 11px; - line-height: 1.45; + font-size: var(--flowchat-font-size-xs); + line-height: var(--flowchat-support-line-height, 1.45); } } /* ---------- Positive points section ---------- */ .review-positive { .positive-header { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; color: var(--color-success, #22c55e); margin-bottom: 8px; @@ -1176,8 +1176,8 @@ align-items: flex-start; gap: 8px; color: var(--color-text-primary); - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); padding: 6px 8px; background: var(--color-bg-elevated, rgba(255, 255, 255, 0.02)); border-radius: 4px; diff --git a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss index 5cab954983..688504ead1 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss @@ -14,7 +14,7 @@ width: 100%; box-sizing: border-box; font-size: var(--flowchat-font-size-base); - line-height: 1.65; + line-height: var(--flowchat-support-line-height, 1.45); } .compact-tool-card-wrapper--dense-command { @@ -67,20 +67,20 @@ .flowchat-tool-wrapper { margin: 0 0 0.35rem 0; font-size: var(--flowchat-font-size-base); - line-height: 1.65; + line-height: var(--flowchat-support-line-height, 1.45); } .flow-tool-card-wrapper { display: flex; flex-direction: column; - gap: 0.2rem; + gap: var(--flowchat-inline-gap, 0.35rem); } .flow-tool-card-note { margin-left: 1.5rem; color: var(--color-text-secondary); font-size: 0.82em; - line-height: 1.45; + line-height: var(--flowchat-support-line-height, 1.45); opacity: 0.84; } @@ -322,13 +322,13 @@ margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border-subtle); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); } .compact-detail-inline-item { display: flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); white-space: nowrap; } @@ -370,7 +370,7 @@ align-items: center; gap: 6px; color: var(--color-text-secondary); - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; cursor: pointer; margin-bottom: 4px; @@ -395,15 +395,15 @@ .compact-expanded-result-snippet { color: var(--color-text-muted); - font-size: 12px; - line-height: 1.65; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-text-line-height, 1.58); margin-bottom: 4px; padding-left: 18px; } .compact-expanded-result-url { color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-family: var(--tool-card-font-mono); padding-left: 18px; overflow: hidden; @@ -421,8 +421,8 @@ padding: 10px; color: var(--color-text-primary); font-family: var(--tool-card-font-mono); - font-size: 11px; - line-height: 1.65; + font-size: var(--flowchat-font-size-xs); + line-height: var(--flowchat-text-line-height, 1.58); white-space: pre-wrap; word-break: break-word; overflow-x: auto; @@ -452,4 +452,3 @@ opacity: 0.25; } } - diff --git a/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.scss index 92ebb6d959..1d71956d4e 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.scss @@ -11,7 +11,7 @@ .compression-info { display: flex; align-items: center; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); flex-wrap: nowrap; .token-stat { @@ -23,9 +23,9 @@ .savings-tag { display: inline-flex; align-items: center; - padding: 2px 8px; + padding: 0.12rem 0.5rem; border-radius: 4px; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 600; flex-shrink: 0; white-space: nowrap; @@ -40,19 +40,19 @@ } .compression-meta { - font-size: 12px; + font-size: var(--flowchat-font-size-sm); color: var(--tool-card-text-secondary); white-space: nowrap; } .error-content { - padding: 12px 16px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--size-gap-4, 16px); background: rgba(239, 68, 68, 0.05); border-top: 1px solid rgba(239, 68, 68, 0.2); .error-message { color: rgba(239, 68, 68, 0.9); - font-size: 13px; + font-size: var(--flowchat-font-size-sm); } } @@ -61,12 +61,11 @@ } .compression-detail-note { - padding: 12px 16px; - font-size: 12px; - line-height: 1.5; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--size-gap-4, 16px); + font-size: var(--flowchat-font-size-sm); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--tool-card-text-secondary); background: rgba(148, 163, 184, 0.08); border-top: 1px solid rgba(148, 163, 184, 0.18); } } - diff --git a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.scss index b4199306a8..4b1c3827c2 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.scss @@ -10,7 +10,7 @@ background: var(--color-bg-flowchat); border: 1px solid var(--border-base); border-radius: 8px; - margin: 6px 0; + margin: var(--flowchat-card-gap, 0.45rem) 0; overflow: hidden; transition: all 0.2s ease; box-sizing: border-box; @@ -25,7 +25,7 @@ } &--loading { - padding: 12px 16px; + padding: var(--flowchat-card-pad-y, 0.625rem) var(--flowchat-card-pad-x, 0.75rem); /* Same chrome as the filled card when params are not ready yet (streaming start). */ border: 1px solid var(--border-base); border-radius: 8px; @@ -34,9 +34,9 @@ .create-plan-header { justify-content: flex-start; - gap: 8px; + gap: var(--flowchat-inline-gap, 0.35rem); color: var(--tool-card-text-muted); - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); border-bottom: none; } } @@ -49,7 +49,7 @@ display: flex; align-items: center; justify-content: space-between; - padding: 10px 14px; + padding: var(--flowchat-card-pad-y, 0.625rem) var(--flowchat-card-pad-x, 0.75rem); background: linear-gradient( 90deg, rgba(245, 158, 11, 0.07) 0%, @@ -79,7 +79,7 @@ .header-left { display: flex; align-items: center; - gap: 8px; + gap: var(--flowchat-inline-gap, 0.35rem); } .file-icon-wrapper { @@ -99,7 +99,7 @@ } .file-name { - font-size: 12px; + font-size: var(--flowchat-font-size-xs); font-family: var(--tool-card-font-mono); color: var(--tool-card-text-secondary); } @@ -114,8 +114,8 @@ .create-plan-content { display: flex; align-items: flex-start; - gap: 10px; - padding: 14px 16px; + gap: var(--flowchat-card-gap, 0.45rem); + padding: var(--flowchat-card-pad-y, 0.625rem) var(--flowchat-card-pad-x, 0.75rem); .plan-content-left { flex: 1; @@ -123,18 +123,18 @@ } .plan-title { - margin: 0 0 6px 0; - font-size: 15px; + margin: 0 0 var(--flowchat-inline-gap, 0.35rem) 0; + font-size: var(--flowchat-font-size-base); font-weight: 600; color: var(--tool-card-text-primary); - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); } .plan-overview { margin: 0; - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--tool-card-text-secondary); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); } .todos-toggle-btn { @@ -168,12 +168,12 @@ .create-plan-todos { border-top: 1px solid var(--border-base); - padding: 10px 16px; + padding: var(--flowchat-card-pad-y, 0.625rem) var(--flowchat-card-pad-x, 0.75rem); .todos-list { display: flex; flex-direction: column; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); max-height: 200px; overflow-x: hidden; overflow-y: auto; @@ -183,7 +183,7 @@ .todo-item { display: flex; align-items: flex-start; - gap: 8px; + gap: var(--flowchat-inline-gap, 0.35rem); padding: 5px 0; transition: background 0.15s ease; border-radius: 4px; @@ -193,7 +193,7 @@ .todo-icon { flex-shrink: 0; - margin-top: 2px; + margin-top: 0.15em; width: 12px; height: 12px; @@ -217,8 +217,8 @@ } .todo-content { - font-size: 13px; - line-height: 1.4; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--tool-card-text-secondary); } @@ -285,7 +285,7 @@ display: flex; align-items: center; justify-content: space-between; - padding: 10px 14px; + padding: var(--flowchat-card-pad-y, 0.625rem) var(--flowchat-card-pad-x, 0.75rem); border-top: 1px solid var(--border-base); background: rgba(255, 255, 255, 0.02); @@ -296,9 +296,9 @@ .view-plan-btn { display: flex; align-items: center; - gap: 4px; - padding: 6px 12px; - font-size: 12px; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: var(--flowchat-control-pad-y, 0.35rem) var(--flowchat-control-pad-x, 0.75rem); + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--tool-card-text-secondary); background: transparent; @@ -321,9 +321,9 @@ .build-btn { display: flex; align-items: center; - gap: 6px; - padding: 6px 16px; - font-size: 12px; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: var(--flowchat-control-pad-y, 0.35rem) var(--flowchat-card-pad-x, 0.75rem); + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: #fff; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); diff --git a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.scss index 153487fc7c..efcfca508b 100644 --- a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.scss @@ -22,7 +22,7 @@ .compact-tool-card { min-height: 0; padding: 0 !important; - line-height: 1.65; + line-height: var(--flowchat-support-line-height, 1.45); } .compact-card-action { @@ -58,8 +58,8 @@ border-radius: 4px; background: transparent; color: var(--color-text-muted); - font-size: 10px; - line-height: 1.4; + font-size: var(--flowchat-font-size-xxs); + line-height: var(--flowchat-compact-line-height, 1.32); font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; @@ -68,13 +68,13 @@ .default-tool-card__expanded { display: flex; flex-direction: column; - gap: 12px; + gap: var(--flowchat-card-gap, 0.45rem); } .default-tool-card__meta { display: flex; flex-wrap: wrap; - gap: 8px; + gap: var(--flowchat-inline-gap, 0.35rem); align-items: center; padding-bottom: 8px; border-bottom: 1px solid var(--border-subtle); @@ -83,31 +83,31 @@ .default-tool-card__meta-label { display: inline-flex; align-items: center; - padding: 2px 8px; + padding: 0.125rem var(--flowchat-control-pad-x, 0.75rem); border-radius: 999px; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border-subtle); color: var(--color-text-secondary); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 600; font-family: var(--tool-card-font-mono); } .default-tool-card__meta-description { color: var(--color-text-muted); - font-size: 12px; - line-height: 1.4; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); } .default-tool-card__section { display: flex; flex-direction: column; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); } .default-tool-card__section-label { color: var(--color-text-secondary); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; @@ -115,14 +115,14 @@ .default-tool-card__code-block { margin: 0; - padding: 10px 12px; + padding: var(--flowchat-code-block-pad-y, 0.55rem) var(--flowchat-code-block-pad-x, 0.75rem); border: 1px solid var(--border-subtle); border-radius: 8px; background: var(--color-bg-secondary); color: var(--color-text-primary); font-family: var(--tool-card-font-mono); - font-size: 11px; - line-height: 1.65; + font-size: var(--flowchat-code-font-size, 0.92em); + line-height: var(--flowchat-code-line-height, 1.52); white-space: pre-wrap; word-break: break-word; overflow: auto; @@ -130,20 +130,20 @@ } .default-tool-card__error-message { - padding: 10px 12px; + padding: var(--flowchat-card-pad-y, 0.625rem) var(--flowchat-card-pad-x, 0.75rem); border: 1px solid rgba(239, 68, 68, 0.24); border-radius: 8px; background: rgba(239, 68, 68, 0.08); color: var(--color-error); - font-size: 12px; - line-height: 1.65; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); white-space: pre-wrap; word-break: break-word; } .default-tool-card__actions { display: flex; - gap: 8px; + gap: var(--flowchat-inline-gap, 0.35rem); flex-wrap: wrap; } @@ -151,8 +151,8 @@ appearance: none; border: 1px solid var(--border-base); border-radius: 8px; - padding: 7px 12px; - font-size: 12px; + padding: var(--flowchat-control-pad-y, 0.35rem) var(--flowchat-control-pad-x, 0.75rem); + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; cursor: pointer; transition: all 0.2s ease; @@ -200,4 +200,3 @@ } } } - diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.scss index 3b25fd0037..60d785371e 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.scss @@ -75,7 +75,7 @@ display: flex; flex-direction: row; align-items: stretch; - gap: 2px; + gap: 0.125rem; min-height: 0; } @@ -112,14 +112,14 @@ } .params-streaming-indicator { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); color: rgba(139, 92, 246, 0.75); background: rgba(139, 92, 246, 0.08); - padding: 2px 6px; + padding: 0.125rem var(--flowchat-inline-gap, 0.35rem); border-radius: 3px; animation: pulse-subtle 2s ease-in-out infinite; flex-shrink: 0; - line-height: 1; + line-height: var(--flowchat-compact-line-height, 1.32); } @keyframes pulse-subtle { @@ -142,18 +142,18 @@ .file-op-diff-pill { display: inline-flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); flex-shrink: 0; margin-left: 2px; - padding: 2px 5px; + padding: 0.125rem var(--flowchat-inline-gap, 0.35rem); border: none; border-radius: 999px; background: transparent; font-family: var(--tool-card-font-mono); - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; color: var(--color-text-muted); - line-height: 1; + line-height: var(--flowchat-compact-line-height, 1.32); cursor: pointer; transition: background-color 0.15s ease, @@ -324,15 +324,15 @@ gap: 8px; padding: 16px; color: var(--color-text-muted); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } >pre { margin: 0; padding: 0; font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Consolas', 'Monaco', 'Courier New', monospace; - font-size: 12px; - line-height: 1.6; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-text-line-height, 1.58); color: var(--color-text-primary); white-space: pre-wrap; word-break: break-word; @@ -348,7 +348,7 @@ align-items: center; gap: 6px; color: var(--color-error); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 600; margin-bottom: 6px; @@ -358,8 +358,8 @@ } .error-message { - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-muted); white-space: pre-wrap; word-break: break-word; diff --git a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.scss index 8c4babc3f4..eed29deb8f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.scss @@ -18,18 +18,18 @@ .generative-widget-card__title { display: inline-flex; align-items: center; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); min-width: 0; } .generative-widget-card__extra { display: inline-flex; align-items: center; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); } .generative-widget-card__status { - font-size: 12px; + font-size: var(--flowchat-font-size-sm); color: var(--color-text-secondary, #98a2b3); white-space: nowrap; } @@ -39,7 +39,7 @@ } .generative-widget-card__preview { - padding: 10px 12px 12px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); } .generative-widget-card__preview--export { @@ -51,9 +51,9 @@ display: flex; align-items: center; justify-content: center; - padding: 16px; + padding: var(--size-gap-4, 16px); color: var(--color-text-secondary, #98a2b3); - font-size: 13px; + font-size: var(--flowchat-font-size-sm); } .generative-widget-card__placeholder--error { @@ -100,6 +100,6 @@ } .generative-widget-card__export-stage-inner { - padding: 20px; + padding: var(--size-gap-5, 20px); background: var(--color-bg-flowchat, #121214); } diff --git a/src/web-ui/src/flow_chat/tool-cards/GetFileDiffDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/GetFileDiffDisplay.scss index 7b49b8d895..3b7fb3dac0 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GetFileDiffDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/GetFileDiffDisplay.scss @@ -7,14 +7,14 @@ .diff-tool-info { display: flex; align-items: center; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); overflow: hidden; } .diff-file-name { color: var(--color-text-primary); font-family: var(--font-mono); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -23,8 +23,8 @@ .diff-type-tag { display: inline-flex; align-items: center; - padding: 1px 6px; - font-size: 10px; + padding: 0.06rem 0.38rem; + font-size: var(--flowchat-font-size-xxs); font-weight: 500; border-radius: 4px; background: var(--color-bg-secondary); @@ -37,9 +37,9 @@ .diff-stats { display: inline-flex; align-items: center; - gap: 6px; - margin-right: 8px; - font-size: 11px; + gap: var(--flowchat-inline-gap, 0.35rem); + margin-right: var(--flowchat-control-gap, 0.5rem); + font-size: var(--flowchat-font-size-xs); font-family: var(--font-mono); flex-shrink: 0; @@ -55,22 +55,22 @@ .diff-expanded-content { .diff-message { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-muted); - margin-bottom: 8px; - padding-bottom: 8px; + margin-bottom: var(--flowchat-card-gap, 0.42rem); + padding-bottom: var(--flowchat-card-gap, 0.42rem); border-bottom: 1px solid var(--border-subtle); } .diff-content-preview { margin: 0; - padding: 12px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); background: var(--color-bg-secondary); border: 1px solid var(--border-subtle); border-radius: 4px; font-family: var(--font-mono); - font-size: 12px; - line-height: 1.5; + font-size: var(--flowchat-font-size-sm); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-primary); overflow-x: auto; max-height: 400px; diff --git a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.scss index 68fcb80512..f5f7153e8e 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.scss @@ -32,7 +32,7 @@ .compact-card-content .tool-command-preview.tool-command-preview--compact { font-family: var(--tool-compact-summary-font); font-size: var(--flowchat-font-size-sm); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); font-weight: normal; color: var(--color-text-muted); } @@ -45,7 +45,7 @@ .output-summary { font-family: var(--tool-compact-summary-font); font-size: var(--flowchat-font-size-sm); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); font-weight: normal; color: var(--tool-card-text-secondary); } @@ -57,7 +57,7 @@ margin-left: -8px; margin-right: -8px; margin-bottom: -8px; - padding: 6px 8px; + padding: var(--flowchat-card-tight-pad-y, 0.375rem) var(--flowchat-card-tight-pad-x, 0.5rem); border-radius: 0 0 6px 6px; } @@ -66,7 +66,7 @@ margin-left: -12px; margin-right: -12px; margin-bottom: -12px; - padding: 6px 12px; + padding: var(--flowchat-card-tight-pad-y, 0.375rem) var(--flowchat-card-pad-x, 0.75rem); } /* ========== Fix scrollbar visibility on card hover ========== */ @@ -112,14 +112,14 @@ } .git-output-summary-inline { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--tool-card-text-secondary, var(--color-text-muted)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1 1 0; min-width: 0; - margin-left: 6px; + margin-left: var(--flowchat-inline-gap, 0.35rem); font-family: var(--tool-card-font-mono); opacity: 0.7; } @@ -133,24 +133,24 @@ .git-header-extra { display: inline-flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); min-width: 0; } .git-hover-actions { display: inline-flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); } .git-confirm-actions { display: inline-flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); } .output-summary { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--tool-card-text-secondary); white-space: nowrap; overflow: hidden; @@ -164,7 +164,7 @@ align-items: center; .error-text { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: rgba(239, 68, 68, 0.9); font-weight: 600; } @@ -185,30 +185,30 @@ } .git-result-output + .git-result-footer { - margin-top: 6px; + margin-top: var(--flowchat-card-gap, 0.45rem); border-top: 1px solid var(--border-subtle); } .git-stderr-block { - margin-top: 8px; + margin-top: var(--flowchat-card-gap, 0.45rem); } .git-output-label { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; - margin-bottom: 4px; + margin-bottom: var(--flowchat-inline-gap, 0.35rem); } .git-output-block { margin: 0; - padding: 8px 10px; + padding: var(--flowchat-code-block-pad-y, 0.55rem) var(--flowchat-code-block-pad-x, 0.75rem); background: var(--color-bg-scene); border-radius: 4px; - font-size: 12px; - line-height: 1.5; + font-size: var(--flowchat-code-font-size, 0.92em); + line-height: var(--flowchat-code-line-height, 1.52); color: var(--color-text-secondary); white-space: pre-wrap; word-break: break-word; @@ -235,15 +235,15 @@ display: flex; align-items: center; align-content: center; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); flex-wrap: wrap; - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); background: var(--element-bg-subtle, rgba(255, 255, 255, 0.05)); box-sizing: border-box; } .git-result-label { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; @@ -252,24 +252,24 @@ .git-result-value { font-family: var(--tool-card-font-mono); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-primary); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); } .git-exit-code { display: inline-flex; align-items: center; padding: 0; - font-size: 9px; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; font-family: var(--tool-card-font-mono); - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); margin-left: auto; background: transparent; @@ -283,21 +283,22 @@ } .git-execution-time { - font-size: 9px; + font-size: var(--flowchat-font-size-xxs); color: var(--color-text-muted); font-family: var(--tool-card-font-mono); - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); } .error-content { - padding: 12px 16px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); background: rgba(239, 68, 68, 0.05); border-top: 1px solid rgba(239, 68, 68, 0.2); .error-message { color: rgba(239, 68, 68, 0.9); - font-size: 13px; - margin-bottom: 8px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); + margin-bottom: var(--flowchat-card-gap, 0.45rem); font-family: var(--tool-card-font-mono); white-space: pre-wrap; word-break: break-word; @@ -306,8 +307,8 @@ .error-meta { display: flex; align-items: center; - gap: 8px; - font-size: 12px; + gap: var(--flowchat-inline-gap, 0.35rem); + font-size: var(--flowchat-font-size-xs); color: var(--tool-card-text-secondary); .error-separator { @@ -356,4 +357,3 @@ } } } - diff --git a/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.scss index 57df1acdb7..3524ebddd3 100644 --- a/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.scss @@ -23,7 +23,7 @@ } .content-summary { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--tool-card-text-secondary); white-space: nowrap; } @@ -53,7 +53,7 @@ align-items: center; .error-text { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: rgba(239, 68, 68, 0.9); font-weight: 600; } @@ -62,8 +62,8 @@ .mcp-expanded-content { .content-item { &:not(:last-child) { - margin-bottom: 12px; - padding-bottom: 12px; + margin-bottom: var(--flowchat-card-gap, 0.42rem); + padding-bottom: var(--flowchat-card-gap, 0.42rem); border-bottom: 1px solid rgba(255, 255, 255, 0.05); } } @@ -71,11 +71,11 @@ .text-content { pre { margin: 0; - padding: 8px 12px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); background: rgba(0, 0, 0, 0.2); border-radius: 4px; - font-size: 12px; - line-height: 1.5; + font-size: var(--flowchat-font-size-sm); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--tool-card-text-primary); white-space: pre-wrap; word-wrap: break-word; @@ -91,10 +91,10 @@ display: flex; align-items: center; justify-content: center; - gap: 8px; - padding: 24px; + gap: var(--flowchat-control-gap, 0.5rem); + padding: var(--size-gap-6, 24px); color: var(--tool-card-text-secondary); - font-size: 13px; + font-size: var(--flowchat-font-size-sm); } .mcp-app-error { @@ -110,7 +110,7 @@ } .resource-content { - padding: 8px 12px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); background: rgba(139, 92, 246, 0.05); border-left: 3px solid rgba(139, 92, 246, 0.5); border-radius: 4px; @@ -118,18 +118,18 @@ .resource-name { font-weight: 600; color: var(--tool-card-text-primary); - margin-bottom: 4px; + margin-bottom: var(--flowchat-inline-gap, 0.35rem); } .resource-uri { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: rgba(139, 92, 246, 0.7); font-family: monospace; - margin-bottom: 4px; + margin-bottom: var(--flowchat-inline-gap, 0.35rem); } .resource-description { - font-size: 12px; + font-size: var(--flowchat-font-size-sm); color: var(--tool-card-text-secondary); } } @@ -141,7 +141,7 @@ .error-message { color: rgba(239, 68, 68, 0.9); - font-size: 13px; + font-size: var(--flowchat-font-size-sm); } } @@ -152,7 +152,7 @@ .mcp-action-buttons { display: flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); } .mcp-icon-button { @@ -201,4 +201,4 @@ background: rgba(239, 68, 68, 0.25); } } -} \ No newline at end of file +} diff --git a/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.scss index 0c5e7a4758..8561ca2544 100644 --- a/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.scss @@ -13,10 +13,10 @@ } .base-tool-card-wrapper.miniapp-tool-display .miniapp-result-footer { - margin-left: -10px; - margin-right: -10px; - margin-bottom: -10px; - padding: 8px 10px; + margin-left: calc(-1 * var(--flowchat-card-expanded-pad-x, 0.625rem)); + margin-right: calc(-1 * var(--flowchat-card-expanded-pad-x, 0.625rem)); + margin-bottom: calc(-1 * var(--flowchat-card-expanded-pad-y, 0.5rem)); + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); border-radius: 0 0 7px 7px; } @@ -32,7 +32,7 @@ .miniapp-tool-info { display: flex; align-items: center; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); flex-wrap: nowrap; min-width: 0; flex: 1; @@ -40,7 +40,7 @@ .operation-tag { display: inline-flex; align-items: center; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; flex-shrink: 0; white-space: nowrap; @@ -49,7 +49,7 @@ .command-text { font-family: var(--tool-card-font-mono); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--tool-card-text-primary); white-space: nowrap; @@ -60,7 +60,7 @@ } .output-summary { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--tool-card-text-secondary); white-space: nowrap; overflow: hidden; @@ -74,7 +74,7 @@ align-items: center; .error-text { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: rgba(239, 68, 68, 0.9); font-weight: 600; } @@ -87,22 +87,22 @@ } .miniapp-result-rows { - padding: 8px 10px 0; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem) 0; display: flex; flex-direction: column; - gap: 8px; + gap: var(--flowchat-flow-item-gap, 0.42rem); } .miniapp-result-row { display: flex; align-items: baseline; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); flex-wrap: wrap; - line-height: 1.3; + line-height: var(--flowchat-support-line-height, 1.45); } .miniapp-result-label { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; @@ -112,24 +112,24 @@ .miniapp-result-value { font-family: var(--tool-card-font-mono); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-primary); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); } .miniapp-result-footer { display: flex; align-items: center; justify-content: flex-start; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); flex-wrap: wrap; - line-height: 1.2; - margin-top: 8px; + line-height: var(--flowchat-compact-line-height, 1.32); + margin-top: var(--flowchat-card-gap, 0.42rem); background: var(--element-bg-subtle, rgba(255, 255, 255, 0.05)); box-sizing: border-box; border-top: 1px solid var(--border-subtle); @@ -138,13 +138,13 @@ .miniapp-open-btn { display: inline-flex; align-items: center; - gap: 6px; - padding: 5px 10px; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: 0.32rem var(--flowchat-card-expanded-pad-x, 0.625rem); border-radius: 4px; border: 1px solid var(--border-subtle); background: transparent; color: var(--tool-card-text-primary); - font-size: 12px; + font-size: var(--flowchat-font-size-sm); font-weight: 500; cursor: pointer; white-space: nowrap; @@ -157,14 +157,14 @@ } .error-content { - padding: 12px 16px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--size-gap-4, 16px); background: rgba(239, 68, 68, 0.05); border-top: 1px solid rgba(239, 68, 68, 0.2); .error-message { color: rgba(239, 68, 68, 0.9); - font-size: 13px; - margin-bottom: 8px; + font-size: var(--flowchat-font-size-sm); + margin-bottom: var(--flowchat-card-gap, 0.42rem); font-family: var(--tool-card-font-mono); white-space: pre-wrap; word-break: break-word; @@ -173,8 +173,8 @@ .error-meta { display: flex; align-items: center; - gap: 8px; - font-size: 12px; + gap: var(--flowchat-control-gap, 0.5rem); + font-size: var(--flowchat-font-size-sm); color: var(--tool-card-text-secondary); .error-operation { diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss index a15d5d6065..dcedb79b64 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss @@ -11,7 +11,7 @@ /* Minimal, borderless layout. * WebKit (Tauri/macOS) baseline-quirk fix: - * — Collapsed summary uses `--flowchat-font-size-sm` + line-height 1.5 to match + * — Collapsed summary uses `--flowchat-font-size-sm` + shared support line-height to match * `.explore-region--collapsible` (e.g. “执行了 N 个命令”). * — Make the chevron SVG `display: block` and the label simple `inline` flow * so flex `align-items: center` stays stable. */ @@ -26,20 +26,20 @@ box-sizing: border-box; /* Collapsed summary line: match `.explore-region--collapsible` (e.g. command count). */ font-size: var(--flowchat-font-size-sm); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); transition: padding 0.2s ease; } .thinking-collapsed-header { display: flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); padding: 0; border-radius: 4px; cursor: pointer; background: transparent; font-size: var(--flowchat-font-size-sm); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); &:hover { .thinking-label { @@ -66,7 +66,7 @@ font-weight: normal; color: var(--text-tertiary, #6b7280); opacity: 0.8; - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); } } @@ -92,13 +92,13 @@ .thinking-content { font-size: var(--flowchat-font-size-base); - line-height: var(--flowchat-text-line-height, 1.62); + line-height: var(--flowchat-text-line-height, 1.58); /* Match FlowChat body text (sans); mono stack + generic monospace yields Songti-like CJK on Windows. */ font-family: var(--font-family-sans, 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif); word-break: break-word; color: var(--tool-card-text-muted); - padding: 8px 10px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); background: transparent; border: none; border-radius: 6px; @@ -194,169 +194,50 @@ --markdown-font-mono: var(--tool-card-font-mono); --markdown-font-heading: var(--font-family-sans, 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif); - /* Match flow-text-block: compact block gap, smaller list-item gap. */ - --markdown-block-gap: 0.5em; - --markdown-paragraph-gap: 0.25em; + --markdown-font-size: var(--flowchat-font-size-base); + --markdown-font-size-sm: var(--flowchat-font-size-sm); + --markdown-code-font-size: var(--flowchat-font-size-xs); + --markdown-line-height: var(--flowchat-text-line-height, 1.58); + --markdown-block-gap: var(--flowchat-markdown-block-gap, 0.58em); + --markdown-paragraph-gap: var(--flowchat-markdown-paragraph-gap, 0.28em); font-family: var(--font-family-sans, 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif); font-size: var(--flowchat-font-size-base); - line-height: var(--flowchat-text-line-height, 1.62); + line-height: var(--flowchat-text-line-height, 1.58); color: var(--tool-card-text-muted); animation: none; - /* Headings: same size as body, just bold. */ - h1, h2, h3, h4, h5, h6 { - font-family: var(--font-family-sans, 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', - sans-serif); - font-weight: 600; + h1, h2, h3, h4, h5, h6, + strong { color: var(--tool-card-text-secondary); - font-size: var(--flowchat-font-size-base) !important; - line-height: var(--flowchat-text-line-height, 1.62) !important; - letter-spacing: normal; - border: none; - padding: 0; } p, ol, ul, - li { - font-size: var(--flowchat-font-size-base); - line-height: var(--flowchat-text-line-height, 1.62); + li, + blockquote, + .custom-blockquote { color: var(--tool-card-text-muted); } - p, - ol, - ul { - margin-top: 0; - margin-bottom: 0; - } - - > * + * { - margin-top: var(--markdown-block-gap); - } - - p + p, - p + h1, - p + h2, - p + h3, - p + h4, - p + h5, - p + h6, - h1 + p, - h2 + p, - h3 + p, - h4 + p, - h5 + p, - h6 + p { - margin-top: var(--markdown-paragraph-gap); - } - - /* List item gap (smaller than block gap, but visible). */ - li + li { - margin-top: 0.35em; - } - - /* Tight rhythm WITHIN a list item. */ - li > * + * { - margin-top: 0.15em; - } - - /* Loose-list

  • ...

  • : collapse the inner

    . */ - li > p { - display: inline; - } - - /* Pin list marker size to body em so ul/ol items share the same line box height - * as paragraphs (global Markdown.scss otherwise makes ul markers 1.2em + bold). - * Safari < 17 ignores `::marker` font-size, so also draw our own bullet for ul. */ - ul > li::marker, - ol > li::marker { - font-size: 1em !important; - font-weight: normal !important; - } - - ul { - list-style: none; - padding-left: 1.25rem; - } - - ul > li { - position: relative; - } - - ul > li::before { - content: ''; - position: absolute; - left: -0.85rem; - top: 0.65em; - width: 0.32em; - height: 0.32em; - border-radius: 50%; - background: currentColor; - opacity: 0.55; - } - - strong { - font-weight: 600; - color: var(--tool-card-text-secondary); - } - em { color: var(--tool-card-text-muted); } - a, - .file-link, - .visualization-link, - .tab-link { - color: var(--flowchat-link-color, #60a5fa); - text-decoration: underline; - text-underline-offset: 2px; - } - - a:hover, - .file-link:hover, - .visualization-link:hover, - .tab-link:hover { - color: var(--flowchat-link-hover-color, #93c5fd); - } - - a .inline-code, - .file-link .inline-code, - .visualization-link .inline-code, - .tab-link .inline-code { - color: inherit !important; - } - - ul, - ol { - padding-left: 1.25rem; - } - hr { - border: none; - border-top: 1px solid color-mix(in srgb, var(--tool-card-text-muted) 25%, transparent); + background: color-mix(in srgb, var(--tool-card-text-muted) 25%, transparent); } blockquote, .custom-blockquote { - padding: 0.25rem 0 0.25rem 0.6rem; border-left: 2px solid color-mix(in srgb, var(--tool-card-text-muted) 35%, transparent); - color: var(--tool-card-text-muted); background: transparent; } - blockquote p { - color: inherit; - } - .inline-code { font-family: var(--tool-card-font-mono); - font-size: 0.85em; - padding: 0.1em 0.2em; border-radius: 0; background: transparent; border: none; @@ -376,13 +257,13 @@ .code-block-lang { font-size: 0.6rem; - line-height: 1.15; + line-height: var(--flowchat-compact-line-height, 1.32); } .code-block-wrapper pre[class*='language-'], .code-block-wrapper pre[style] { - font-size: 11px !important; - line-height: 1.4 !important; + font-size: var(--flowchat-font-size-xs) !important; + line-height: var(--flowchat-support-line-height, 1.45) !important; border-radius: 6px !important; } @@ -429,4 +310,3 @@ background: color-mix(in srgb, var(--tool-card-text-muted) 5%, transparent); } } - diff --git a/src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.scss b/src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.scss index e38ec6702c..b3d8c9e028 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.scss @@ -32,15 +32,15 @@ .review-session-summary-card__summary { margin: 0; color: var(--color-text-secondary); - font-size: 12px; - line-height: 1.6; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-text-line-height, 1.58); } .review-session-summary-card__section-label { display: inline-flex; margin-bottom: 6px; color: var(--color-text-muted); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 650; letter-spacing: 0.06em; text-transform: uppercase; @@ -53,8 +53,8 @@ margin: 0; padding-left: 16px; color: var(--color-text-secondary); - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); } } @@ -65,7 +65,7 @@ border-radius: 8px; background: var(--element-bg-soft); color: var(--color-text-primary); - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); cursor: pointer; &:hover { diff --git a/src/web-ui/src/flow_chat/tool-cards/SnapshotFullscreenDiffViewer.css b/src/web-ui/src/flow_chat/tool-cards/SnapshotFullscreenDiffViewer.css index 6131073b6a..f598e92b91 100644 --- a/src/web-ui/src/flow_chat/tool-cards/SnapshotFullscreenDiffViewer.css +++ b/src/web-ui/src/flow_chat/tool-cards/SnapshotFullscreenDiffViewer.css @@ -72,7 +72,7 @@ display: flex; align-items: center; gap: 8px; - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-secondary); } @@ -99,7 +99,7 @@ padding: 8px 12px; border: none; border-radius: 6px; - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; cursor: pointer; transition: all 0.2s ease; @@ -214,7 +214,7 @@ transition: all 0.2s ease; white-space: nowrap; flex-shrink: 0; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); border: 1px solid transparent; } @@ -231,11 +231,11 @@ .file-tab .file-name { font-family: 'Monaco', 'Menlo', monospace; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); } .file-tab .file-status { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); } .file-tab .file-status[data-status="pending"] { @@ -299,7 +299,7 @@ } .file-details .file-path-full { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: rgba(255, 255, 255, 0.5); font-family: 'Monaco', 'Menlo', monospace; overflow: hidden; @@ -321,7 +321,7 @@ padding: 6px 10px; border: none; border-radius: 4px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; cursor: pointer; transition: all 0.2s ease; @@ -430,12 +430,12 @@ } .session-stats { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } .header-btn { padding: 6px 8px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } .file-navigation { @@ -447,11 +447,11 @@ } .file-details .file-name { - font-size: 13px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } .file-details .file-path-full { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); } /* Hide file status text on small screens */ @@ -500,7 +500,7 @@ color: #7096c4; padding: 2px 6px; border-radius: 10px; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; border: 1px solid rgba(112, 150, 196, 0.3); } diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss index 6561220b8c..9b3dfdfdff 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss @@ -4,7 +4,7 @@ .task-header-wrapper { display: flex; align-items: stretch; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); width: 100%; } @@ -160,7 +160,7 @@ pointer-events: none; > * + * { - margin-top: 6px; + margin-top: var(--flowchat-inline-gap, 0.35rem); } > svg { @@ -190,14 +190,14 @@ .task-header-main { display: flex; align-items: center; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); width: 100%; min-height: 20px; } .task-action { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; color: var(--color-text-primary); flex-shrink: 1; @@ -207,7 +207,7 @@ white-space: nowrap; &__model-tag { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 400; color: var(--color-text-secondary); vertical-align: baseline; @@ -228,9 +228,9 @@ .task-failed-badge { display: inline-flex; align-items: center; - padding: 1px 5px; + padding: 0.1rem var(--flowchat-inline-gap, 0.35rem); border-radius: 3px; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 500; flex-shrink: 0; color: var(--color-text-muted, #6b7280); @@ -240,9 +240,9 @@ .agent-type-badge { display: inline-flex; align-items: center; - padding: 2px 6px; + padding: 0.125rem var(--flowchat-inline-gap, 0.35rem); border-radius: 4px; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 500; flex-shrink: 0; color: var(--color-text-secondary, #9ca3af); @@ -253,7 +253,7 @@ .task-header-meta { display: flex; align-items: center; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); margin-left: auto; margin-right: 10px; flex-shrink: 0; @@ -273,8 +273,8 @@ The generic thinking content has vertical padding; with a one-line max-height that can leave only padding visible and push the prompt text out of view. */ .task-expanded-content .task-prompt-content { - padding: 0 12px; - max-height: calc(var(--flowchat-font-size-base) * 1.5 * 2 + 6px); + padding: 0 var(--flowchat-card-pad-x, 0.75rem); + max-height: 3.2em; overflow-y: auto; } @@ -288,14 +288,14 @@ .task-interruption-note { display: flex; align-items: flex-start; - gap: 8px; + gap: var(--flowchat-inline-gap, 0.35rem); color: var(--color-text-secondary); - font-size: 12px; - line-height: 1.5; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); svg { flex-shrink: 0; - margin-top: 2px; + margin-top: 0.15em; color: var(--color-warning, #f59e0b); } } @@ -328,12 +328,12 @@ .duration-text { display: flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); color: var(--tool-card-text-secondary, var(--color-text-secondary, #9ca3af)); - font-size: 12px; + font-size: var(--flowchat-font-size-xs); font-weight: 600; letter-spacing: 0.01em; - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); svg { flex-shrink: 0; @@ -353,9 +353,9 @@ .section-header { display: flex; align-items: center; - gap: 6px; - margin-bottom: 8px; - font-size: 11px; + gap: var(--flowchat-inline-gap, 0.35rem); + margin-bottom: var(--flowchat-card-gap, 0.45rem); + font-size: var(--flowchat-font-size-xs); font-weight: 600; color: var(--tool-card-text-secondary); text-transform: uppercase; @@ -375,27 +375,27 @@ .task-expanded-content { display: flex; flex-direction: column; - gap: 8px; + gap: var(--flowchat-card-gap, 0.45rem); } .error-content { display: flex; flex-direction: column; - gap: 8px; + gap: var(--flowchat-card-gap, 0.45rem); } .error-message { - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-error, #ef4444); - line-height: 1.4; + line-height: var(--flowchat-support-line-height, 1.45); } .error-meta { display: flex; align-items: center; - gap: 8px; - font-size: 11px; + gap: var(--flowchat-inline-gap, 0.35rem); + font-size: var(--flowchat-font-size-xs); color: var(--tool-card-text-muted); } @@ -410,15 +410,15 @@ .tool-actions { display: flex; - gap: 8px; - margin-top: 12px; + gap: var(--flowchat-inline-gap, 0.35rem); + margin-top: var(--flowchat-card-gap, 0.45rem); } .confirm-button, .reject-button { - padding: 6px 12px; + padding: var(--flowchat-control-pad-y, 0.35rem) var(--flowchat-control-pad-x, 0.75rem); border-radius: 4px; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); font-weight: 500; cursor: pointer; transition: all 0.2s ease; @@ -575,15 +575,15 @@ .task-reviewer-context { display: flex; flex-direction: column; - gap: 6px; - padding: 6px 0; - font-size: 12px; - line-height: 1.45; + gap: var(--flowchat-inline-gap, 0.35rem); + padding: var(--flowchat-inline-gap, 0.35rem) 0; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); color: var(--color-text-secondary); &__role { font-weight: 600; - font-size: 12px; + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); } &__description { diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss index 179a8fa4c8..2e3904d78d 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss @@ -27,20 +27,20 @@ .compact-card-content .tool-command-preview.tool-command-preview--compact { font-family: var(--tool-compact-summary-font); font-size: var(--flowchat-font-size-sm); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); font-weight: normal; color: var(--color-text-muted); } .terminal-header-extra { - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); } .terminal-header-duration .duration-text { - gap: 3px; + gap: 0.18rem; font-family: var(--tool-compact-summary-font); font-size: var(--flowchat-font-size-sm); - line-height: 1.5; + line-height: var(--flowchat-support-line-height, 1.45); font-weight: normal; } @@ -59,23 +59,23 @@ .tool-card-action { display: inline-flex; align-items: center; - height: calc(12px * 1.45); - line-height: 1; + height: 1.45em; + line-height: var(--flowchat-compact-line-height, 1.32); margin-right: 2px; transform: translateY(-0.5px); } .tool-card-content { - min-height: calc(12px * 1.45); + min-height: 1.45em; } } /* Footer bar: full width + background to bottom inner edge of expanded content (matches .base-tool-card-expanded padding). */ .base-tool-card-wrapper.terminal-tool-card .terminal-result-footer { - margin-left: -12px; - margin-right: -12px; - margin-bottom: -12px; - padding: 6px 12px; + margin-left: calc(-1 * var(--flowchat-card-expanded-pad-x, 0.625rem)); + margin-right: calc(-1 * var(--flowchat-card-expanded-pad-x, 0.625rem)); + margin-bottom: calc(-1 * var(--flowchat-card-expanded-pad-y, 0.5rem)); + padding: var(--flowchat-inline-gap, 0.35rem) var(--flowchat-card-expanded-pad-x, 0.625rem); border-radius: 0 0 6px 6px; } @@ -102,7 +102,7 @@ flex: 1; min-width: 0; font-family: var(--tool-card-font-mono); - font-size: 12px; + font-size: var(--flowchat-font-size-sm); font-weight: 500; color: var(--tool-card-text-muted); overflow: hidden; @@ -115,8 +115,8 @@ * the monospace font's intrinsic baseline metrics. */ display: inline-flex; align-items: center; - line-height: 1.45; - height: calc(12px * 1.45); + line-height: var(--flowchat-support-line-height, 1.45); + height: 1.45em; background: none !important; border: none !important; outline: none !important; @@ -158,14 +158,14 @@ background: transparent; padding: 0; font-family: var(--tool-card-font-mono); - font-size: 12px; + font-size: var(--flowchat-font-size-sm); font-weight: 500; color: var(--color-text-primary); border: none !important; outline: none !important; box-shadow: none !important; transition: all 0.2s ease; - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); box-sizing: border-box; &:focus { @@ -191,9 +191,9 @@ .terminal-status-text { display: inline-flex; align-items: center; - padding: 2px 6px; + padding: 0.12rem 0.38rem; border-radius: 3px; - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; flex-shrink: 0; margin-right: 4px; @@ -218,7 +218,7 @@ display: inline-flex; align-items: center; justify-content: flex-end; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); min-width: 0; line-height: 1; } @@ -234,8 +234,8 @@ } .duration-text { - font-size: 11px; - line-height: 1; + font-size: var(--flowchat-font-size-xs); + line-height: var(--flowchat-compact-line-height, 1.32); font-weight: 500; } @@ -249,7 +249,7 @@ display: inline-flex; align-items: center; justify-content: flex-end; - gap: 2px; + gap: 0.125rem; flex-shrink: 0; &:empty { @@ -279,14 +279,14 @@ .terminal-hover-actions { display: inline-flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); } /* ========== Always-visible critical actions (compact collapsed state) ========== */ .terminal-critical-actions { display: inline-flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); flex-shrink: 0; } @@ -295,7 +295,7 @@ display: inline-flex; align-items: center; justify-content: flex-start; - gap: 2px; + gap: 0.125rem; flex-shrink: 0; width: auto; } @@ -312,7 +312,7 @@ background: transparent; color: var(--color-text-muted); cursor: pointer; - font-size: 12px; + font-size: var(--flowchat-font-size-sm); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); padding: 0; position: relative; @@ -425,7 +425,7 @@ .waiting-text { color: var(--color-text-muted); - font-size: 12px; + font-size: var(--flowchat-font-size-sm); font-style: italic; } } @@ -439,8 +439,8 @@ outline: none !important; box-shadow: none !important; font-family: var(--tool-card-font-mono); - font-size: 12px; - line-height: 1.5; + font-size: var(--flowchat-font-size-sm); + line-height: var(--flowchat-code-line-height, 1.52); white-space: pre-wrap; word-break: break-word; color: var(--color-text-secondary); @@ -455,9 +455,9 @@ &.cancelled { .terminal-cancelled-text { color: var(--color-warning); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 500; - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); } } } @@ -466,20 +466,20 @@ display: flex; align-items: center; align-content: center; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); flex-wrap: wrap; - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); background: var(--element-bg-subtle, rgba(255, 255, 255, 0.05)); box-sizing: border-box; } .terminal-result-output + .terminal-result-footer { - margin-top: 6px; + margin-top: var(--flowchat-inline-gap, 0.35rem); border-top: 1px solid var(--border-subtle); } .terminal-result-label { - font-size: 10px; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; @@ -488,23 +488,23 @@ .terminal-result-value { font-family: var(--tool-card-font-mono); - font-size: 11px; + font-size: var(--flowchat-font-size-xs); color: var(--color-text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); } .terminal-exit-code { display: inline-flex; align-items: center; padding: 0; - font-size: 9px; + font-size: var(--flowchat-font-size-xxs); font-weight: 600; font-family: var(--tool-card-font-mono); - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); margin-left: auto; background: transparent; @@ -518,10 +518,10 @@ } .terminal-execution-time { - font-size: 9px; + font-size: var(--flowchat-font-size-xxs); color: var(--color-text-muted); font-family: var(--tool-card-font-mono); - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); } /* ========== xterm output renderer styles ========== */ @@ -576,15 +576,15 @@ .error-content { display: flex; flex-direction: column; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); background: none !important; border: none !important; padding: 0 !important; } .error-message { - font-size: 12px !important; - line-height: 1.5 !important; + font-size: var(--flowchat-font-size-sm) !important; + line-height: var(--flowchat-support-line-height, 1.45) !important; color: var(--color-text-muted) !important; white-space: pre-wrap !important; word-break: break-word !important; @@ -605,8 +605,8 @@ } .base-tool-card-header { - padding: 2px 4px; - gap: 2px; + padding: 0.125rem 0.25rem; + gap: 0.125rem; } .terminal-action-btn { @@ -622,20 +622,20 @@ .terminal-command, .terminal-command-input { - font-size: 11px; + font-size: var(--flowchat-font-size-xs); } .compact-card-action, .compact-card-content .tool-command-preview--compact { - font-size: 11px; - line-height: 1.5; + font-size: var(--flowchat-font-size-xs); + line-height: var(--flowchat-support-line-height, 1.45); font-weight: normal; font-family: var(--font-family-sans, 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif); } .terminal-header-duration .duration-text { - font-size: 11px; - line-height: 1.5; + font-size: var(--flowchat-font-size-xs); + line-height: var(--flowchat-support-line-height, 1.45); font-weight: normal; font-family: var(--font-family-sans, 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif); } @@ -647,4 +647,3 @@ outline: 2px solid var(--color-accent-500); outline-offset: 2px; } - diff --git a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.scss index c05e0e4d49..f038cce0a7 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.scss @@ -16,7 +16,7 @@ .todo-progress { color: var(--color-text-muted); font-size: inherit; - margin-left: 4px; + margin-left: var(--flowchat-inline-gap, 0.35rem); font-family: var(--tool-card-font-mono); } } @@ -42,7 +42,7 @@ } .compact-card-content .todo-header-more { - padding: 0 5px; + padding: 0 0.32rem; } .todo-write-card { @@ -68,7 +68,7 @@ .todo-header-action-cluster { display: inline-flex; align-items: baseline; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); min-width: 0; } @@ -90,7 +90,7 @@ /* Tighter icon rail → text (expanded todo header) */ &.todo-write-card .base-tool-card-header { - gap: 3px; + gap: 0.18rem; } &.todo-write-card .tool-card-icon-slot { @@ -103,7 +103,7 @@ .todo-header-content { display: inline-flex; align-items: center; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); min-width: 0; overflow: hidden; white-space: nowrap; @@ -177,7 +177,7 @@ .todo-item { display: flex; align-items: center; - padding: 7px 12px; + padding: var(--flowchat-card-expanded-pad-y, 0.5rem) var(--flowchat-card-expanded-pad-x, 0.625rem); font-size: var(--tool-card-action-font-size); line-height: var(--tool-card-action-line-height); font-weight: 400; @@ -222,7 +222,7 @@ .todo-item-left { display: flex; align-items: center; - gap: 6px; + gap: var(--flowchat-inline-gap, 0.35rem); flex: 1; min-width: 0; } diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolCommandPreview.scss b/src/web-ui/src/flow_chat/tool-cards/ToolCommandPreview.scss index c60297473d..8c5bd839f1 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ToolCommandPreview.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ToolCommandPreview.scss @@ -9,7 +9,7 @@ .tool-command-preview--compact { font-size: var(--flowchat-font-size-xs); - line-height: 1.45; + line-height: var(--flowchat-support-line-height, 1.45); font-weight: normal; color: var(--color-text-muted); } @@ -24,6 +24,6 @@ white-space: pre-wrap; word-break: break-word; font-family: var(--tool-card-font-mono); - font-size: 12px; - line-height: 1.45; + font-size: var(--flowchat-font-size-sm); + line-height: var(--flowchat-support-line-height, 1.45); } diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss index 5609c51549..729df6bcaa 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss @@ -1,18 +1,18 @@ .tool-timeout-indicator { display: inline-flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); } .duration-text { display: inline-flex; align-items: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); color: var(--tool-card-text-secondary, var(--color-text-secondary, #9ca3af)); - font-size: 12px; + font-size: var(--flowchat-font-size-xs); font-weight: 600; letter-spacing: 0.01em; - line-height: 1.2; + line-height: var(--flowchat-compact-line-height, 1.32); svg { flex-shrink: 0; @@ -82,19 +82,19 @@ display: inline-flex; align-items: center; justify-content: center; - gap: 4px; + gap: var(--flowchat-inline-gap, 0.35rem); height: 22px; - padding: 0 6px; + padding: 0 var(--flowchat-inline-gap, 0.35rem); border: 1px solid var(--border-subtle, rgba(100, 116, 139, 0.12)); border-radius: 4px; background: transparent; color: var(--color-text-muted); cursor: pointer; flex-shrink: 0; - font-size: 11px; + font-size: var(--flowchat-font-size-xs); font-weight: 500; letter-spacing: 0.01em; - line-height: 1; + line-height: var(--flowchat-compact-line-height, 1.32); transition: all 0.15s ease; svg { @@ -128,11 +128,11 @@ .timeout-extend-popover { position: absolute; right: 0; - top: calc(100% + 4px); + top: calc(100% + var(--flowchat-inline-gap, 0.35rem)); z-index: 10; width: max-content; max-width: 150px; - padding: 4px 0; + padding: var(--flowchat-inline-gap, 0.35rem) 0; border-radius: 6px; background: var(--color-bg-elevated); border: 1px solid var(--border-base); @@ -142,10 +142,10 @@ .timeout-extend-option { display: flex; align-items: center; - gap: 8px; + gap: var(--flowchat-inline-gap, 0.35rem); width: 100%; - padding: 6px 12px; - font-size: 12px; + padding: var(--flowchat-control-pad-y, 0.35rem) var(--flowchat-control-pad-x, 0.75rem); + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); color: var(--color-text-primary); cursor: pointer; background: transparent; diff --git a/src/web-ui/src/flow_chat/tool-cards/_tool-card-common.scss b/src/web-ui/src/flow_chat/tool-cards/_tool-card-common.scss index 17d5962432..68c93be023 100644 --- a/src/web-ui/src/flow_chat/tool-cards/_tool-card-common.scss +++ b/src/web-ui/src/flow_chat/tool-cards/_tool-card-common.scss @@ -6,9 +6,9 @@ .tool-display-compact { display: flex; align-items: center; - gap: 8px; + gap: var(--flowchat-control-gap, 0.5rem); font-size: var(--flowchat-font-size-base); - line-height: 1.65; + line-height: var(--flowchat-text-line-height, 1.58); position: relative; } @@ -66,16 +66,16 @@ /* ========== Shared header icon variables ========== */ :root { - --tool-card-header-pad-y: 7px; - --tool-card-header-pad-x: 0px; - --tool-card-header-pad-right: 10px; + --tool-card-header-pad-y: var(--flowchat-card-header-pad-y, 0.44rem); + --tool-card-header-pad-x: var(--flowchat-card-header-pad-x, 0); + --tool-card-header-pad-right: var(--flowchat-card-header-pad-right, 0.625rem); --tool-card-header-icon-rail: 24px; --tool-card-header-icon-slot: calc(var(--tool-card-header-icon-rail) + 10px); /* Shared icon & text sizing — dense-command variants only override spacing, not these */ --tool-card-icon-size: 16px; - --tool-card-action-font-size: var(--flowchat-font-size-base); - --tool-card-action-line-height: 1.65; + --tool-card-action-font-size: var(--flowchat-font-size-sm); + --tool-card-action-line-height: var(--flowchat-support-line-height, 1.45); --tool-card-action-font-weight: 500; } From a6776e4346a9f5859c4e92848ca6aef9d54c762f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 17 May 2026 15:41:29 +0800 Subject: [PATCH 08/76] fix(web-ui): stabilize flow chat follow-output during collapses (#759) Align follow mode with collapse compensation and anchor lock so tool-card collapses no longer jitter the viewport; gate continuous auto-follow during layout transitions and restore unsignaled shrink clamps near the tail. --- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 66 +++++--- .../components/modern/VirtualMessageList.tsx | 151 +++++++++++++++--- .../modern/useFlowChatFollowOutput.ts | 26 ++- 3 files changed, 191 insertions(+), 52 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md index 39f7ebdda9..fadd69a0f6 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md @@ -165,27 +165,42 @@ During those transitions, the DOM may report intermediate sizes for multiple fra ## C. Follow-Output Mode (continuous tail) When the viewport is in follow-output mode and the latest turn is still -streaming, the user's intent is "keep the tail visible", which is the -opposite of "preserve the upper anchor". To avoid the visible -"stutter then jump" behavior caused by collapse pre-compensation -freezing the viewport mid-animation, follow mode short-circuits the -protection path: - -1. `handleToolCardCollapseIntent` returns early without writing - `pendingCollapseIntent`, without adding `collapse` reservation, and - without activating anchor lock. -2. The shrink branch of `measureHeightChange` returns early without - adding fallback footer compensation. -3. A continuous RAF loop in `useFlowChatFollowOutput` runs every frame - while `isFollowing && isStreaming`, calling `performAutoFollowScroll` - to chase the bottom and `reconcileStickyPinReservation` to keep the - sticky-latest pin floor aligned with the live DOM. -4. The loop is cancelled as soon as follow exits (user upward scroll, +streaming, the user's intent is "keep the tail visible". A naive +implementation that simply pins `scrollTop = maxScrollTop` every frame +produces a very visible "conversation sinks down" jitter every time a +tool card above the viewport auto-collapses: the browser clamps +`scrollTop` to the new (smaller) max, the loop re-pins to the new max +the next frame, and the upper content visibly drifts during the +320 ms collapse animation. + +To eliminate that jitter, follow mode uses the same collapse-protection +path as the rest of the list during a known collapse, and only resumes +bottom-tracking once the animation settles: + +1. `handleToolCardCollapseIntent` always writes `pendingCollapseIntent`, + adds a `collapse` bottom reservation, and activates an anchor lock — + regardless of whether follow mode is active. This freezes the upper + visual anchor so the conversation does not appear to move while the + card animates away. +2. The shrink branch of `measureHeightChange` runs the full compensation + reconciliation even in follow mode, so the synthetic footer absorbs + the real measured shrink. +3. The continuous RAF loop in `useFlowChatFollowOutput` honours + `shouldSuspendAutoFollow`. While a collapse intent / layout + transition is in flight, the loop keeps re-arming frames but skips + the `performAutoFollowScroll` call, so it does not fight the anchor + lock. +4. When the collapse transition finishes, `handleTransitionFinish` + clears `pendingCollapseIntent` and dispatches the deferred follow + reason via `scheduleFollowToLatest`. That single programmatic + bottom-snap releases the collapse reservation and re-aligns the + viewport with the live tail. Subsequent streaming tokens are + followed normally by the continuous loop. +5. The loop is cancelled as soon as follow exits (user upward scroll, session change, streaming ends, or an explicit navigation). -This branch coexists with the legacy collapse compensation path. Outside -follow mode (user reading older content), all original protections still -apply unchanged. +Outside follow mode (user reading older content), all original +protections still apply unchanged. ## Why `overflow-anchor: none` Must Stay @@ -242,11 +257,16 @@ If a future collapsible component shows the same "header drops" or "flash on col - Removing `overflow-anchor: none`. - Removing transition-aware delayed measurement. - Simplifying anchor restore to a one-shot restore without the scroll listener fallback. -- Removing the follow-mode short-circuit in `handleToolCardCollapseIntent` / - `measureHeightChange`. Without it, follow-output streaming will visibly stall - during collapse animations and then snap to the latest token. +- Re-introducing a follow-mode short-circuit in `handleToolCardCollapseIntent` + or `measureHeightChange`. Without the collapse compensation + anchor lock, + follow-output bottom-tracking causes the conversation to visibly "sink down" + every time a tool card above the viewport auto-collapses. +- Removing the `shouldSuspendAutoFollow` gate from the continuous RAF follow + loop. Without it, the loop will fight the anchor lock during the collapse + animation and reintroduce the same jitter. - Removing the continuous RAF follow loop. Event-driven follow alone cannot - keep up with collapse animations + dense token streams without visible jitter. + keep up with dense token streams without visible jitter outside collapse + windows. ## If You Need To Change This Logic diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index bd85c452c7..25790267ba 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -38,6 +38,7 @@ const ANCHOR_LOCK_MIN_DEVIATION_PX = 0.5; const ANCHOR_LOCK_DURATION_MS = 450; const PINNED_TURN_VIEWPORT_OFFSET_PX = 57; // Keep in sync with `.message-list-header`. const TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX = 6; +const USER_UPWARD_SCROLL_INTENT_WINDOW_MS = 800; // Read `FLOWCHAT_SCROLL_STABILITY.md` before changing collapse compensation logic. @@ -243,6 +244,12 @@ export const VirtualMessageList = forwardRef((_, ref) => const layoutTransitionCountRef = useRef(0); const touchScrollIntentStartYRef = useRef(null); const scrollbarPointerInteractionActiveRef = useRef(false); + // Timestamp until which we treat any upward scroll as user-initiated. Set by + // wheel/touch/keyboard/scrollbar handlers BEFORE the browser actually moves + // the scroller. Used by the handleScroll "shrink-clamp restore" intercept + // (below) to distinguish a genuine user upward scroll from a browser auto + // clamp caused by content shrinking near the bottom in follow mode. + const userInitiatedUpwardScrollUntilMsRef = useRef(0); const anchorLockRef = useRef({ active: false, targetScrollTop: 0, @@ -457,13 +464,16 @@ export const VirtualMessageList = forwardRef((_, ref) => // Content shrank: preserve the current visual anchor by extending the footer // when the user does not already have enough distance from the bottom. const shrinkAmount = -heightDelta; - // Follow-output mode wants to chase the bottom; absorbing the shrink with - // synthetic footer would visually freeze the viewport mid-animation. The - // continuous follow loop will scroll back to the tail on the next frame. - if (isFollowingOutputRef.current && isStreamingOutputRef.current) { - previousScrollTopRef.current = currentScrollTop; - return; - } + // Note: previously this branch returned early in follow-output mode to let + // the continuous follow loop chase the bottom every frame. That caused the + // visible "sink-down" jitter when tool-card auto-collapse shrank content + // above the viewport. We now run the full compensation path regardless of + // follow state — the bottom-reservation footer keeps `scrollHeight` stable + // and the anchor lock preserves the upper visual anchor during the + // animation. The continuous follow loop is gated by + // `shouldSuspendAutoFollow` while a collapse intent / layout transition is + // in flight, so it does not fight the anchor lock; once the transition + // ends, the deferred follow path resumes bottom-tracking smoothly. const collapseIntent = pendingCollapseIntentRef.current; const now = performance.now(); const hasValidCollapseIntent = collapseIntent.active && collapseIntent.expiresAtMs >= now; @@ -1150,6 +1160,51 @@ export const VirtualMessageList = forwardRef((_, ref) => releaseAnchorLock('expired-before-scroll'); } + // Reactive shrink-clamp restore: in follow + streaming mode, an upward + // jump in scrollTop that we did NOT request from JS and that is NOT + // attributable to a user gesture is the browser auto-clamping scrollTop + // because `scrollHeight` shrunk below `scrollTop + clientHeight` + // (typical cause: an unsignaled item shrink from Virtuoso re-measure + // or a tool result finalizing). With `overflow-anchor: none` we cannot + // ask the browser to keep the visual anchor for us, so we extend the + // bottom collapse reservation by the clamp amount and restore + // `scrollTop` to its pre-clamp value. The widened footer prevents the + // browser from re-clamping immediately; subsequent streaming-token + // growth drains the reservation via the grow branch of + // `measureHeightChange`. This is the only place that protects against + // unsignaled shrinks that do not arrive with a `collapse-intent` event. + const intentCheckScrollTop = scrollerElement.scrollTop; + const intentCheckPreviousScrollTop = previousScrollTopRef.current; + const intentCheckScrollDelta = intentCheckScrollTop - intentCheckPreviousScrollTop; + const hasRecentUserUpwardIntent = now <= userInitiatedUpwardScrollUntilMsRef.current; + if ( + intentCheckScrollDelta < -COMPENSATION_EPSILON_PX && + isFollowingOutputRef.current && + isStreamingOutputRef.current && + !hasRecentUserUpwardIntent && + !anchorLockRef.current.active + ) { + const clampAmount = -intentCheckScrollDelta; + const baseState = bottomReservationStateRef.current; + const nextReservationState: BottomReservationState = { + ...baseState, + collapse: { + ...baseState.collapse, + px: baseState.collapse.px + clampAmount, + floorPx: baseState.collapse.floorPx, + }, + }; + updateBottomReservationState(nextReservationState); + applyFooterCompensationNow(nextReservationState); + scrollerElement.scrollTop = intentCheckPreviousScrollTop; + previousScrollTopRef.current = intentCheckPreviousScrollTop; + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight( + scrollerElement, + nextReservationState, + ); + return; + } + const currentTotalCompensation = getTotalBottomCompensationPx(); if ( currentTotalCompensation > COMPENSATION_EPSILON_PX && @@ -1193,6 +1248,8 @@ export const VirtualMessageList = forwardRef((_, ref) => const handleWheel = (event: WheelEvent) => { if (event.deltaY < 0) { + userInitiatedUpwardScrollUntilMsRef.current = + performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; followOutputControllerRef.current.handleUserScrollIntent(); releaseAnchorLock('wheel-up'); } @@ -1211,6 +1268,8 @@ export const VirtualMessageList = forwardRef((_, ref) => if (currentY - startY > TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX) { touchScrollIntentStartYRef.current = currentY; + userInitiatedUpwardScrollUntilMsRef.current = + performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; followOutputControllerRef.current.handleUserScrollIntent(); releaseAnchorLock('touch-scroll-up'); } @@ -1225,6 +1284,8 @@ export const VirtualMessageList = forwardRef((_, ref) => return; } + userInitiatedUpwardScrollUntilMsRef.current = + performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; followOutputControllerRef.current.handleUserScrollIntent(); releaseAnchorLock('keyboard-scroll-up'); }; @@ -1239,6 +1300,8 @@ export const VirtualMessageList = forwardRef((_, ref) => } scrollbarPointerInteractionActiveRef.current = true; + userInitiatedUpwardScrollUntilMsRef.current = + performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; followOutputControllerRef.current.handleUserScrollIntent(); releaseAnchorLock('scrollbar-pointer-down'); }; @@ -1253,6 +1316,8 @@ export const VirtualMessageList = forwardRef((_, ref) => return; } + userInitiatedUpwardScrollUntilMsRef.current = + performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; followOutputControllerRef.current.handleUserScrollIntent(); releaseAnchorLock('scrollbar-pointer-move'); }; @@ -1292,11 +1357,12 @@ export const VirtualMessageList = forwardRef((_, ref) => // collapse animation, producing the "stutter then jump" effect. Skip // the protection path entirely and let the continuous follow loop // absorb the shrink frame-by-frame. - if (isFollowingOutputRef.current && isStreamingOutputRef.current) { - scheduleVisibleTurnMeasure(2); - schedulePinReservationReconcile(2); - return; - } + // Note: in follow-output mode we still run the full collapse pre-compensation + // path. Pinning the upper visual anchor during the collapse animation keeps + // the conversation visually stable; the continuous follow loop is gated by + // `shouldSuspendAutoFollow` while the layout transition is in progress, and + // resumes bottom-tracking via the deferred-follow path after the transition + // ends and the collapse reservation is consumed. const baseTotalCompensationPx = getTotalBottomCompensationPx(); const distanceFromBottom = Math.max( 0, @@ -1559,14 +1625,41 @@ export const VirtualMessageList = forwardRef((_, ref) => const scrollToLatestEndPositionInternal = useCallback((behavior: 'auto' | 'smooth') => { const scroller = scrollerElementRef.current; - if (scroller) { - clearAllBottomReservationsForUserNavigation(); - scroller.scrollTo({ - top: Math.max(0, scroller.scrollHeight - scroller.clientHeight), - behavior, - }); + if (!scroller) return; + + const compensationPx = getTotalBottomCompensationPx(); + // Auto-follow during streaming with active collapse compensation: scroll + // to the EFFECTIVE bottom (the top edge of the footer reservation), and + // preserve the reservation. Clearing it here would shrink `scrollHeight` + // by the full compensation amount in one frame, which clamps `scrollTop` + // downward and produces a visible whole-conversation "sink-down" jump. + // The reservation drains organically as the grow branch in + // `measureHeightChange` consumes it while new tokens stream in. + // 'smooth' is reserved for explicit user navigation ("jump to latest"), + // which intentionally clears reservations. + if (behavior === 'auto' && compensationPx > COMPENSATION_EPSILON_PX) { + const effectiveBottomTop = Math.max( + 0, + scroller.scrollHeight - scroller.clientHeight - compensationPx, + ); + // Only ever move DOWNWARD here. If `effectiveBottomTop` is above the + // current scrollTop (e.g. because the bottom reservation just grew to + // absorb an unsignaled shrink via the handleScroll auto-clamp restore), + // pulling scrollTop upward would itself produce a visible "sink-down" + // jump. Hold position; the reservation will be drained by future grow + // events. + if (effectiveBottomTop - scroller.scrollTop > COMPENSATION_EPSILON_PX) { + scroller.scrollTo({ top: effectiveBottomTop, behavior: 'auto' }); + } + return; } - }, [clearAllBottomReservationsForUserNavigation]); + + clearAllBottomReservationsForUserNavigation(); + scroller.scrollTo({ + top: Math.max(0, scroller.scrollHeight - scroller.clientHeight), + behavior, + }); + }, [clearAllBottomReservationsForUserNavigation, getTotalBottomCompensationPx]); const requestTurnPinToTop = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => { const requestedPinMode = options?.pinMode ?? 'transient'; @@ -1628,9 +1721,23 @@ export const VirtualMessageList = forwardRef((_, ref) => } }, shouldSuspendAutoFollow, - getAutoFollowDistanceFromBottom: (scroller) => ( - Math.max(0, scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop) - ), + // Subtract the bottom-reservation footer so the follow controller treats + // synthetic footer space as "already at the bottom". Without this, the + // post-collapse footer (kept around to preserve the upper anchor) would be + // classified as "user fell behind the tail" and trigger + // `performAutoFollowScroll` -> `clearAllBottomReservationsForUserNavigation`, + // which snaps scrollTop down by the entire compensation amount and produces + // the visible "sink-down" jump the user reported. With this subtraction, + // the loop and the deferred-follow path stay quiet while the grow-branch in + // `measureHeightChange` consumes the footer organically as streaming tokens + // refill the bottom space. + getAutoFollowDistanceFromBottom: (scroller) => { + const compensationPx = getTotalBottomCompensationPx(); + return Math.max( + 0, + scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop - compensationPx, + ); + }, onContinuousFollowFrame: undefined, }); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts index 044af7f356..daae57cf37 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts @@ -32,10 +32,13 @@ interface UseFlowChatFollowOutputOptions { performLatestTurnStickyPin: () => void; /** * Returns true when auto-follow should be suspended for layout-protection - * reasons (collapse animation, etc.). The continuous follow loop ignores - * this signal because follow mode actively wants to track the bottom even - * while intermediate cards collapse; only the event-driven `scheduleFollowToLatest` - * still respects it for backward compatibility with anchor restore paths. + * reasons (collapse animation, layout transition, pending collapse intent). + * Both the event-driven `scheduleFollowToLatest` and the continuous follow + * loop honour this signal: while a known collapse animation is in flight we + * must not fight the anchor-lock + bottom-reservation machinery, otherwise + * the conversation visibly "sinks down" each time content above shrinks. + * The continuous loop keeps requesting frames while suspended and resumes + * bottom-tracking on the next frame after the suspension clears. */ shouldSuspendAutoFollow?: () => boolean; getAutoFollowDistanceFromBottom?: (scroller: HTMLElement) => number; @@ -90,11 +93,13 @@ export function useFlowChatFollowOutput({ const performAutoFollowScrollRef = useRef(performAutoFollowScroll); const onContinuousFollowFrameRef = useRef(onContinuousFollowFrame); const getAutoFollowDistanceFromBottomRef = useRef(getAutoFollowDistanceFromBottom); + const shouldSuspendAutoFollowRef = useRef(shouldSuspendAutoFollow); isStreamingRef.current = isStreaming; performAutoFollowScrollRef.current = performAutoFollowScroll; onContinuousFollowFrameRef.current = onContinuousFollowFrame; getAutoFollowDistanceFromBottomRef.current = getAutoFollowDistanceFromBottom; + shouldSuspendAutoFollowRef.current = shouldSuspendAutoFollow; const setFollowingOutput = useCallback((nextValue: boolean) => { isFollowingOutputRef.current = nextValue; @@ -152,9 +157,16 @@ export function useFlowChatFollowOutput({ onContinuousFollowFrameRef.current?.(); - const rawDistance = getDistanceFromBottom(scroller); - const measuredDistance = getAutoFollowDistanceFromBottomRef.current?.(scroller) ?? rawDistance; - if (measuredDistance > AUTO_FOLLOW_BOTTOM_THRESHOLD_PX) { + // While a known collapse animation / layout transition is in flight, the + // VirtualMessageList anchor-lock + bottom-reservation footer is preserving + // the upper visual anchor. Issuing a programmatic scroll-to-bottom from + // this loop would fight that machinery and re-introduce the "sink-down" + // jitter the user reported. We simply re-arm the next frame and resume on + // the first frame after the suspension clears. + const isSuspended = shouldSuspendAutoFollowRef.current?.() === true; + const measuredDistance = getAutoFollowDistanceFromBottomRef.current?.(scroller) + ?? getDistanceFromBottom(scroller); + if (!isSuspended && measuredDistance > AUTO_FOLLOW_BOTTOM_THRESHOLD_PX) { programmaticScrollUntilMsRef.current = performance.now() + PROGRAMMATIC_SCROLL_GUARD_MS; explicitUserScrollIntentUntilMsRef.current = 0; performAutoFollowScrollRef.current(); From 22f24544e561d589ffefed836214ef4e60e7f0af Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 17 May 2026 14:36:08 +0800 Subject: [PATCH 09/76] perf(session): reduce startup and history load overhead Load historical session views with smaller UI payloads, defer stale snapshot refreshes until session history is ready, and aggregate startup/session diagnostics without logging normal optional-config misses. Also keeps remote workspace identity in the measured paths so startup and session hydration traces stay actionable without adding extra frontend/backend round trips. --- src/apps/desktop/src/api/agentic_api.rs | 457 +++++++++++++++++- src/apps/desktop/src/api/git_api.rs | 62 ++- src/apps/desktop/src/api/snapshot_service.rs | 58 ++- src/apps/desktop/src/lib.rs | 3 + src/apps/desktop/src/theme.rs | 106 +++- .../src/agentic/coordination/coordinator.rs | 22 + .../core/src/agentic/persistence/manager.rs | 256 +++++++++- .../src/agentic/session/session_manager.rs | 352 +++++++++++++- .../core/src/service/snapshot/manager.rs | 141 +++++- .../core/src/service/snapshot/service.rs | 29 +- .../src/service/snapshot/snapshot_core.rs | 26 +- .../src/service/snapshot/snapshot_system.rs | 25 +- .../services-integrations/src/git/service.rs | 25 + src/web-ui/src/app/App.tsx | 61 ++- .../src/app/components/NavPanel/MainNav.tsx | 10 +- .../features/ssh-remote/SSHRemoteProvider.tsx | 6 +- .../src/flow_chat/services/FlowChatManager.ts | 4 +- .../flow-chat-manager/SessionModule.test.ts | 51 +- .../flow-chat-manager/SessionModule.ts | 71 ++- .../services/flow-chat-manager/types.ts | 2 + .../src/flow_chat/store/FlowChatStore.test.ts | 450 ++++++++++++++++- .../src/flow_chat/store/FlowChatStore.ts | 314 +++++++++++- src/web-ui/src/flow_chat/types/flow-chat.ts | 12 + .../api/adapters/tauri-adapter.test.ts | 29 ++ .../api/adapters/tauri-adapter.ts | 23 +- .../api/service-api/AgentAPI.ts | 49 +- .../api/service-api/ApiClient.test.ts | 82 ++++ .../api/service-api/ApiClient.ts | 79 ++- .../infrastructure/api/service-api/GitAPI.ts | 11 + .../api/service-api/SnapshotAPI.test.ts | 62 +++ .../api/service-api/SnapshotAPI.ts | 41 +- .../services/AgentCompanionWindowService.ts | 15 +- .../src/shared/utils/startupTrace.test.ts | 45 ++ src/web-ui/src/shared/utils/startupTrace.ts | 45 ++ .../tools/git/state/GitStateManager.test.ts | 14 +- .../src/tools/git/state/GitStateManager.ts | 5 +- .../hooks/snapshotRefreshPolicy.test.ts | 31 ++ .../hooks/snapshotRefreshPolicy.ts | 15 + .../snapshot_system/hooks/useSnapshotState.ts | 71 ++- 39 files changed, 3026 insertions(+), 134 deletions(-) create mode 100644 src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.test.ts create mode 100644 src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.test.ts create mode 100644 src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.ts diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index e2fdb0532d..8e1cbb6aef 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1,6 +1,6 @@ //! Agentic API -use log::warn; +use log::{debug, warn}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::{AppHandle, State}; @@ -19,6 +19,7 @@ use bitfun_core::agentic::deep_review_policy::{ }; use bitfun_core::agentic::image_analysis::ImageContextData; use bitfun_core::agentic::tools::image_context::get_image_context; +use bitfun_core::service::session::DialogTurnData; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { @@ -157,6 +158,161 @@ pub struct SessionResponse { pub created_at: u64, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreSessionWithTurnsResponse { + pub session: SessionResponse, + pub turns: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreSessionViewResponse { + pub session: SessionResponse, + pub turns: Vec, + pub context_restore_state: String, +} + +#[derive(Debug, Default)] +struct RestoreTurnPayloadStats { + tool_result_count: usize, + raw_result_string_chars: usize, + result_for_assistant_chars: usize, + largest_raw_result_chars: usize, + largest_raw_result_path: String, + top_raw_results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RestoreToolOutputStats { + tool_name: String, + path: String, + raw_result_string_chars: usize, + result_for_assistant_chars: usize, +} + +#[derive(Debug, Default)] +struct JsonStringStats { + total_chars: usize, + largest_chars: usize, + largest_path: String, +} + +fn collect_json_string_stats(value: &serde_json::Value, path: &str, stats: &mut JsonStringStats) { + match value { + serde_json::Value::String(text) => { + let char_count = text.chars().count(); + stats.total_chars += char_count; + if char_count > stats.largest_chars { + stats.largest_chars = char_count; + stats.largest_path = path.to_string(); + } + } + serde_json::Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + collect_json_string_stats(item, &format!("{}[{}]", path, index), stats); + } + } + serde_json::Value::Object(map) => { + for (key, item) in map { + let next_path = if path.is_empty() { + key.to_string() + } else { + format!("{}.{}", path, key) + }; + collect_json_string_stats(item, &next_path, stats); + } + } + _ => {} + } +} + +fn push_top_raw_result(stats: &mut RestoreTurnPayloadStats, result: RestoreToolOutputStats) { + if result.raw_result_string_chars == 0 { + return; + } + stats.top_raw_results.push(result); + stats.top_raw_results.sort_by(|left, right| { + right + .raw_result_string_chars + .cmp(&left.raw_result_string_chars) + .then_with(|| left.tool_name.cmp(&right.tool_name)) + }); + stats.top_raw_results.truncate(3); +} + +fn format_top_raw_results(results: &[RestoreToolOutputStats]) -> String { + results + .iter() + .map(|item| { + format!( + "{}:{}:{}:{}", + item.tool_name, + item.raw_result_string_chars, + item.result_for_assistant_chars, + item.path + ) + }) + .collect::>() + .join("|") +} + +fn restore_turn_payload_stats(turns: &[DialogTurnData]) -> RestoreTurnPayloadStats { + let mut stats = RestoreTurnPayloadStats::default(); + for turn in turns { + for (round_index, round) in turn.model_rounds.iter().enumerate() { + for (tool_index, tool) in round.tool_items.iter().enumerate() { + let Some(result) = tool.tool_result.as_ref() else { + continue; + }; + stats.tool_result_count += 1; + let assistant_chars = result + .result_for_assistant + .as_deref() + .map(|text| text.chars().count()) + .unwrap_or(0); + stats.result_for_assistant_chars += assistant_chars; + let mut json_stats = JsonStringStats::default(); + collect_json_string_stats( + &result.result, + &format!( + "turn[{}].round[{}].tool[{}].{}", + turn.turn_index, round_index, tool_index, tool.tool_name + ), + &mut json_stats, + ); + stats.raw_result_string_chars += json_stats.total_chars; + if json_stats.largest_chars > stats.largest_raw_result_chars { + stats.largest_raw_result_chars = json_stats.largest_chars; + stats.largest_raw_result_path = json_stats.largest_path.clone(); + } + push_top_raw_result( + &mut stats, + RestoreToolOutputStats { + tool_name: tool.tool_name.clone(), + path: json_stats.largest_path, + raw_result_string_chars: json_stats.total_chars, + result_for_assistant_chars: assistant_chars, + }, + ); + } + } + } + stats +} + +fn omit_assistant_only_tool_results_for_session_view(turns: &mut [DialogTurnData]) { + for turn in turns { + for round in &mut turn.model_rounds { + for tool in &mut round.tool_items { + if let Some(result) = tool.tool_result.as_mut() { + result.result_for_assistant = None; + } + } + } + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CancelDialogTurnRequest { @@ -248,6 +404,8 @@ pub struct RestoreSessionRequest { pub remote_connection_id: Option, #[serde(default)] pub remote_ssh_host: Option, + #[serde(default)] + pub trace_id: Option, } #[derive(Debug, Deserialize)] @@ -879,6 +1037,140 @@ pub async fn restore_session( Ok(session_to_response(session)) } +#[tauri::command] +pub async fn restore_session_view( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: RestoreSessionRequest, +) -> Result { + let started_at = std::time::Instant::now(); + let trace_id = request.trace_id.as_deref().unwrap_or("none"); + debug!( + "restore_session_view request received: trace_id={}, session_id={}", + trace_id, request.session_id + ); + let path_started_at = std::time::Instant::now(); + let effective_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + debug!( + "restore_session_view storage path resolved: trace_id={}, session_id={}, duration_ms={}", + trace_id, + request.session_id, + path_started_at.elapsed().as_millis() + ); + + let (session, mut turns) = coordinator + .restore_session_view(&effective_path, &request.session_id) + .await + .map_err(|e| format!("Failed to restore session view: {}", e))?; + + if log::log_enabled!(log::Level::Debug) { + let payload_stats = restore_turn_payload_stats(&turns); + if payload_stats.raw_result_string_chars >= 1024 * 1024 + || payload_stats.result_for_assistant_chars >= 1024 * 1024 + { + debug!( + "restore_session_view payload diagnostics: trace_id={}, session_id={}, turn_count={}, tool_result_count={}, raw_result_string_chars={}, result_for_assistant_chars={}, largest_raw_result_chars={}, largest_raw_result_path={}, top_raw_results={}", + trace_id, + request.session_id, + turns.len(), + payload_stats.tool_result_count, + payload_stats.raw_result_string_chars, + payload_stats.result_for_assistant_chars, + payload_stats.largest_raw_result_chars, + payload_stats.largest_raw_result_path, + format_top_raw_results(&payload_stats.top_raw_results) + ); + } + } + + omit_assistant_only_tool_results_for_session_view(&mut turns); + + debug!( + "restore_session_view completed: trace_id={}, session_id={}, turn_count={}, context_restore_state=pending, duration_ms={}", + trace_id, + request.session_id, + turns.len(), + started_at.elapsed().as_millis() + ); + + Ok(RestoreSessionViewResponse { + session: session_to_response(session), + turns, + context_restore_state: "pending".to_string(), + }) +} + +#[tauri::command] +pub async fn restore_session_with_turns( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: RestoreSessionRequest, +) -> Result { + let started_at = std::time::Instant::now(); + let trace_id = request.trace_id.as_deref().unwrap_or("none"); + debug!( + "restore_session_with_turns request received: trace_id={}, session_id={}", + trace_id, request.session_id + ); + let path_started_at = std::time::Instant::now(); + let effective_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + debug!( + "restore_session_with_turns storage path resolved: trace_id={}, session_id={}, duration_ms={}", + trace_id, + request.session_id, + path_started_at.elapsed().as_millis() + ); + let (session, turns) = coordinator + .restore_session_with_turns(&effective_path, &request.session_id) + .await + .map_err(|e| format!("Failed to restore session: {}", e))?; + + if log::log_enabled!(log::Level::Debug) { + let payload_stats = restore_turn_payload_stats(&turns); + if payload_stats.raw_result_string_chars >= 1024 * 1024 + || payload_stats.result_for_assistant_chars >= 1024 * 1024 + { + debug!( + "restore_session_with_turns payload diagnostics: trace_id={}, session_id={}, turn_count={}, tool_result_count={}, raw_result_string_chars={}, result_for_assistant_chars={}, largest_raw_result_chars={}, largest_raw_result_path={}, top_raw_results={}", + trace_id, + request.session_id, + turns.len(), + payload_stats.tool_result_count, + payload_stats.raw_result_string_chars, + payload_stats.result_for_assistant_chars, + payload_stats.largest_raw_result_chars, + payload_stats.largest_raw_result_path, + format_top_raw_results(&payload_stats.top_raw_results) + ); + } + } + + debug!( + "restore_session_with_turns completed: trace_id={}, session_id={}, turn_count={}, duration_ms={}", + trace_id, + request.session_id, + turns.len(), + started_at.elapsed().as_millis() + ); + + Ok(RestoreSessionWithTurnsResponse { + session: session_to_response(session), + turns, + }) +} + #[tauri::command] pub async fn list_sessions( coordinator: State<'_, Arc>, @@ -1059,3 +1351,166 @@ fn system_time_to_unix_secs(time: std::time::SystemTime) -> u64 { } } } + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_core::service::session::{ + ModelRoundData, ToolCallData, ToolItemData, ToolResultData, TurnStatus, UserMessageData, + }; + use serde_json::json; + + fn tool_item( + tool_name: &str, + result: serde_json::Value, + assistant: Option<&str>, + ) -> ToolItemData { + ToolItemData { + id: format!("tool-{}", tool_name), + tool_name: tool_name.to_string(), + tool_call: ToolCallData { + id: format!("call-{}", tool_name), + input: json!({}), + }, + tool_result: Some(ToolResultData { + result, + success: true, + result_for_assistant: assistant.map(str::to_string), + error: None, + duration_ms: Some(1), + }), + ai_intent: None, + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + subagent_model_id: None, + subagent_model_alias: None, + status: None, + interruption_reason: None, + } + } + + #[test] + fn restore_turn_payload_stats_tracks_largest_outputs_without_contents() { + let turn = DialogTurnData { + turn_id: "turn-1".to_string(), + turn_index: 0, + session_id: "session-1".to_string(), + timestamp: 1, + kind: Default::default(), + user_message: UserMessageData { + id: "user-1".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + model_rounds: vec![ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![ + tool_item("Read", json!({ "content": "abc" }), Some("assistant")), + tool_item("Bash", json!({ "output": "x".repeat(20) }), Some("short")), + ], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + status: TurnStatus::Completed, + }; + + let stats = restore_turn_payload_stats(&[turn]); + + assert_eq!(stats.tool_result_count, 2); + assert_eq!(stats.raw_result_string_chars, 23); + assert_eq!(stats.result_for_assistant_chars, 14); + assert_eq!(stats.largest_raw_result_chars, 20); + assert_eq!(stats.top_raw_results.len(), 2); + assert_eq!(stats.top_raw_results[0].tool_name, "Bash"); + assert_eq!(stats.top_raw_results[0].raw_result_string_chars, 20); + assert_eq!(stats.top_raw_results[0].result_for_assistant_chars, 5); + assert_eq!(stats.top_raw_results[1].tool_name, "Read"); + assert_eq!(stats.top_raw_results[1].raw_result_string_chars, 3); + assert!(!stats.top_raw_results[0].path.contains(&"x".repeat(20))); + } + + #[test] + fn omit_assistant_only_tool_results_preserves_visible_results() { + let mut turns = vec![DialogTurnData { + turn_id: "turn-1".to_string(), + turn_index: 0, + session_id: "session-1".to_string(), + timestamp: 1, + kind: Default::default(), + user_message: UserMessageData { + id: "user-1".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + model_rounds: vec![ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![tool_item( + "Bash", + json!({ "output": "visible output" }), + Some("assistant-only payload"), + )], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + status: TurnStatus::Completed, + }]; + + omit_assistant_only_tool_results_for_session_view(&mut turns); + + let tool_result = turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .expect("tool result should remain present"); + assert_eq!(tool_result.result["output"], "visible output"); + assert_eq!(tool_result.result_for_assistant, None); + } +} diff --git a/src/apps/desktop/src/api/git_api.rs b/src/apps/desktop/src/api/git_api.rs index 1ae54fa997..ed7c6a239b 100644 --- a/src/apps/desktop/src/api/git_api.rs +++ b/src/apps/desktop/src/api/git_api.rs @@ -11,7 +11,7 @@ use bitfun_core::service::git::{ GitBranch, GitCommit, GitOperationResult, GitRepository, GitStatus, }; use bitfun_core::service::remote_ssh::{lookup_remote_connection, normalize_remote_workspace_path}; -use log::{error, info}; +use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use tauri::State; @@ -588,6 +588,66 @@ pub async fn git_get_repository( }) } +#[tauri::command] +pub async fn git_get_repository_basic( + state: State<'_, AppState>, + request: GitRepositoryRequest, +) -> Result { + let started_at = std::time::Instant::now(); + let result = if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let current_branch = execute_remote_git_success( + &state, + &target, + &["branch".to_string(), "--show-current".to_string()], + ) + .await + .map(|s| { + let branch = s.trim(); + if branch.is_empty() { + "HEAD".to_string() + } else { + branch.to_string() + } + })?; + + let name = target + .repository_path + .rsplit('/') + .find(|part| !part.is_empty()) + .unwrap_or("/") + .to_string(); + + Ok(GitRepository { + path: target.repository_path, + name, + current_branch, + is_bare: false, + has_changes: false, + remotes: Vec::new(), + }) + } else { + GitService::get_repository_basic(&request.repository_path) + .await + .map_err(|e| { + error!( + "Failed to get basic Git repository info: path={}, error={}", + request.repository_path, e + ); + format!("Failed to get basic Git repository info: {}", e) + }) + }; + + let duration_ms = started_at.elapsed().as_millis(); + if duration_ms >= 80 { + debug!( + "Git basic repository lookup completed: path={}, duration_ms={}", + request.repository_path, duration_ms + ); + } + + result +} + #[tauri::command] pub async fn git_get_status( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index 509ffad190..c50425dd90 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -233,9 +233,11 @@ async fn resolve_workspace_dir(workspace_path: &str) -> Result Ok(workspace_dir) } -async fn ensure_snapshot_manager_ready( +async fn ensure_snapshot_manager_ready_for( workspace_path: &str, + caller: &str, ) -> Result, String> { + let started_at = std::time::Instant::now(); // Remote workspaces don't support the snapshot system if is_remote_path(workspace_path).await { return Err(format!( @@ -247,11 +249,21 @@ async fn ensure_snapshot_manager_ready( let workspace_dir = resolve_workspace_dir(workspace_path).await?; if let Some(manager) = get_snapshot_manager_for_workspace(&workspace_dir) { + let duration_ms = started_at.elapsed().as_millis(); + if duration_ms >= 20 { + log::debug!( + "Snapshot manager ready: caller={}, workspace={}, source=cache, duration_ms={}", + caller, + workspace_dir.display(), + duration_ms + ); + } return Ok(manager); } info!( - "Snapshot manager missing, initializing lazily: workspace={}", + "Snapshot manager missing, initializing lazily: caller={}, workspace={}", + caller, workspace_dir.display() ); @@ -265,8 +277,21 @@ async fn ensure_snapshot_manager_ready( ) })?; - ensure_snapshot_manager_for_workspace(&workspace_dir) - .map_err(|e| format!("Failed to get snapshot manager: {}", e)) + let manager = ensure_snapshot_manager_for_workspace(&workspace_dir) + .map_err(|e| format!("Failed to get snapshot manager: {}", e))?; + log::debug!( + "Snapshot manager ready: caller={}, workspace={}, source=lazy_init, duration_ms={}", + caller, + workspace_dir.display(), + started_at.elapsed().as_millis() + ); + Ok(manager) +} + +async fn ensure_snapshot_manager_ready( + workspace_path: &str, +) -> Result, String> { + ensure_snapshot_manager_ready_for(workspace_path, "unspecified").await } #[tauri::command] @@ -274,7 +299,8 @@ pub async fn record_file_change( app_handle: AppHandle, request: RecordFileChangeRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "record_file_change").await?; let operation_type = match request.operation_type.as_str() { "Create" => OperationType::Create, @@ -323,7 +349,8 @@ pub async fn rollback_session( return Ok(vec![]); } - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "rollback_session").await?; let restored_files = manager .rollback_session(&request.session_id) @@ -373,7 +400,8 @@ pub async fn rollback_to_turn( } } - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "rollback_to_turn").await?; let restored_files = manager .rollback_to_turn(&request.session_id, request.turn_index) @@ -469,7 +497,8 @@ pub async fn accept_session( app_handle: AppHandle, request: AcceptSessionRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "accept_session").await?; manager .accept_session(&request.session_id) @@ -553,7 +582,8 @@ pub async fn get_session_files(request: GetSessionFilesRequest) -> Result Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "get_session_file_diff_stats") + .await?; let stats = manager .get_session_file_diff_stats(&request.sessionId, &request.filePath) @@ -704,7 +736,8 @@ pub async fn get_session_file_diff_stats( pub async fn get_operation_summary( request: GetOperationSummaryRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "get_operation_summary").await?; let summary = manager .get_operation_summary(&request.sessionId, &request.operationId) @@ -859,7 +892,8 @@ pub async fn get_session_stats( })); } - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "get_session_stats").await?; let stats = manager .get_session_stats(&request.session_id) diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index a28081286c..8be80fcdee 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -593,6 +593,8 @@ pub async fn run() { api::agentic_api::set_subagent_timeout, api::agentic_api::delete_session, api::agentic_api::restore_session, + api::agentic_api::restore_session_view, + api::agentic_api::restore_session_with_turns, webdriver_bridge_result, api::agentic_api::list_sessions, api::agentic_api::confirm_tool_execution, @@ -708,6 +710,7 @@ pub async fn run() { add_skill, delete_skill, git_is_repository, + git_get_repository_basic, git_get_repository, git_get_status, git_get_branches, diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index e5a45af019..29834a1680 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -1,11 +1,13 @@ //! Theme System use std::sync::{OnceLock, RwLock}; +use std::time::Instant; use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::service::config::types::GlobalConfig; use dark_light::Mode; use log::{debug, error, warn}; +use tauri::webview::PageLoadEvent; use tauri::{Manager, WebviewUrl}; const AGENT_COMPANION_WINDOW_LABEL: &str = "agent-companion-pet"; @@ -300,7 +302,6 @@ impl ThemeConfig { document.body.style.backgroundColor = '{bg_primary}'; }} - console.log('[Theme] Pre-injected theme: {id}'); return true; }} @@ -335,9 +336,14 @@ impl ThemeConfig { } pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) { + let total_started_at = Instant::now(); let theme = ThemeConfig::load_from_config(); let bg_color = theme.to_tauri_color(); let init_script = theme.generate_init_script(startup_trace_id); + debug!( + "Main window creation step completed: step=prepare_theme duration_ms={}", + total_started_at.elapsed().as_millis() + ); let main_url = if cfg!(debug_assertions) { match "http://localhost:1422".parse() { @@ -350,6 +356,11 @@ pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) } else { WebviewUrl::App("index.html".into()) }; + let main_url_kind = match &main_url { + WebviewUrl::External(_) => "external", + WebviewUrl::App(_) => "app", + _ => "other", + }; #[allow(unused_mut)] let mut builder = tauri::WebviewWindowBuilder::new(app_handle, "main", main_url) @@ -360,7 +371,24 @@ pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) .visible(false) .background_color(bg_color) .accept_first_mouse(true) - .initialization_script(&init_script); + .initialization_script(&init_script) + .on_page_load({ + let startup_trace_id = startup_trace_id.to_string(); + let total_started_at = total_started_at; + move |_window, payload| { + let event = match payload.event() { + PageLoadEvent::Started => "started", + PageLoadEvent::Finished => "finished", + }; + debug!( + "Main window page load event: trace_id={}, event={}, url={}, since_create_start_ms={}", + startup_trace_id, + event, + payload.url(), + total_started_at.elapsed().as_millis() + ); + } + }); // Keep HTML5 drag-and-drop working inside the webview for desktop UI drag targets. builder = builder.disable_drag_drop_handler(); @@ -379,8 +407,15 @@ pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) builder = builder.decorations(false); } + let build_started_at = Instant::now(); match builder.build() { Ok(window) => { + debug!( + "Main window creation step completed: step=build url_kind={} duration_ms={} total_duration_ms={}", + main_url_kind, + build_started_at.elapsed().as_millis(), + total_started_at.elapsed().as_millis() + ); #[cfg(any(debug_assertions, feature = "devtools"))] { if std::env::var("BITFUN_OPEN_DEVTOOLS") @@ -395,7 +430,11 @@ pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) let _ = window; } Err(e) => { - error!("Failed to create main window: {}", e); + error!( + "Failed to create main window: error={} duration_ms={}", + e, + total_started_at.elapsed().as_millis() + ); } } } @@ -563,7 +602,9 @@ fn resize_agent_companion_window( #[tauri::command] pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> { + let started_at = Instant::now(); let _guard = agent_companion_window_ops().lock().await; + debug!("Agent companion window show requested"); // Reuse any existing window: never destroy here. A previous implementation destroyed // whenever `is_visible` was false, which raced with another `show` that had built the @@ -578,6 +619,10 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( error!("Failed to show Agent companion window: {}", e); format!("Failed to show Agent companion window: {}", e) })?; + debug!( + "Agent companion window reused: total_duration_ms={}", + started_at.elapsed().as_millis() + ); return Ok(()); } @@ -601,21 +646,52 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( .shadow(false) .visible(false) .accept_first_mouse(true) - .background_color(tauri::window::Color(0, 0, 0, 0)); + .background_color(tauri::window::Color(0, 0, 0, 0)) + .on_page_load({ + let started_at = started_at; + move |_window, payload| { + let event = match payload.event() { + PageLoadEvent::Started => "started", + PageLoadEvent::Finished => "finished", + }; + debug!( + "Agent companion window page load event: event={}, url={}, since_show_request_ms={}", + event, + payload.url(), + started_at.elapsed().as_millis() + ); + } + }); builder = builder.disable_drag_drop_handler(); + let build_started_at = Instant::now(); let window = builder.build().map_err(|e| { - error!("Failed to create Agent companion window: {}", e); + error!( + "Failed to create Agent companion window: error={} duration_ms={}", + e, + build_started_at.elapsed().as_millis() + ); format!("Failed to create Agent companion window: {}", e) })?; + debug!( + "Agent companion window creation step completed: step=build duration_ms={} total_duration_ms={}", + build_started_at.elapsed().as_millis(), + started_at.elapsed().as_millis() + ); position_agent_companion_window(&app, &window); + let show_started_at = Instant::now(); window.show().map_err(|e| { error!("Failed to show Agent companion window: {}", e); format!("Failed to show Agent companion window: {}", e) })?; + debug!( + "Agent companion window shown: show_duration_ms={} total_duration_ms={}", + show_started_at.elapsed().as_millis(), + started_at.elapsed().as_millis() + ); Ok(()) } @@ -661,23 +737,34 @@ pub async fn hide_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( #[tauri::command] pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { + let total_started_at = Instant::now(); if let Some(main_window) = app.get_webview_window("main") { #[cfg(target_os = "windows")] { // Work around Windows startup flicker: avoid creating the native window // in maximized mode, and maximize it right before showing instead. + let step_started_at = Instant::now(); main_window.maximize().map_err(|e| { error!("Failed to maximize main window: {}", e); format!("Failed to maximize main window: {}", e) })?; + debug!( + "Main window show step completed: step=maximize duration_ms={}", + step_started_at.elapsed().as_millis() + ); tokio::time::sleep(std::time::Duration::from_millis(150)).await; } + let step_started_at = Instant::now(); main_window.show().map_err(|e| { error!("Failed to show main window: {}", e); format!("Failed to show main window: {}", e) })?; + debug!( + "Main window show step completed: step=show duration_ms={}", + step_started_at.elapsed().as_millis() + ); #[cfg(target_os = "macos")] { @@ -685,14 +772,23 @@ pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { crate::mark_main_window_hidden_on_macos(false); } + let step_started_at = Instant::now(); main_window.set_focus().map_err(|e| { error!("Failed to focus main window: {}", e); format!("Failed to focus main window: {}", e) })?; + debug!( + "Main window show step completed: step=focus duration_ms={}", + step_started_at.elapsed().as_millis() + ); } else { error!("Main window not found"); return Err("Main window not found".to_string()); } + debug!( + "Main window shown: total_duration_ms={}", + total_started_at.elapsed().as_millis() + ); Ok(()) } diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index bb1d6f44e0..c86bd837c8 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -2468,6 +2468,28 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + /// Restore session and return the persisted turns read during restore. + pub async fn restore_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.session_manager + .restore_session_with_turns(workspace_path, session_id) + .await + } + + /// Restore only the UI-visible persisted session view. + pub async fn restore_session_view( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.session_manager + .restore_session_view(workspace_path, session_id) + .await + } + /// List all sessions pub async fn list_sessions(&self, workspace_path: &Path) -> BitFunResult> { self.session_manager.list_sessions(workspace_path).await diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 4354105d71..2b6010cfa0 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -17,14 +17,14 @@ use crate::service::session::{ }; use crate::service::workspace_runtime::WorkspaceRuntimeService; use crate::util::errors::{BitFunError, BitFunResult}; -use log::{info, warn}; +use log::{debug, info, warn}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::fs; use tokio::sync::Mutex; @@ -60,6 +60,83 @@ struct StoredTurnContextSnapshotFile { messages: Vec, } +#[derive(Debug, Default)] +struct ContextSnapshotPayloadStats { + tool_result_count: usize, + raw_result_string_chars: usize, + result_for_assistant_chars: usize, + largest_raw_result_chars: usize, + largest_raw_result_path: String, +} + +fn collect_json_string_stats( + value: &serde_json::Value, + path: &str, + total: &mut usize, + largest: &mut (usize, String), +) { + match value { + serde_json::Value::String(text) => { + let char_count = text.chars().count(); + *total += char_count; + if char_count > largest.0 { + *largest = (char_count, path.to_string()); + } + } + serde_json::Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + collect_json_string_stats(item, &format!("{}[{}]", path, index), total, largest); + } + } + serde_json::Value::Object(map) => { + for (key, item) in map { + let next_path = if path.is_empty() { + key.to_string() + } else { + format!("{}.{}", path, key) + }; + collect_json_string_stats(item, &next_path, total, largest); + } + } + _ => {} + } +} + +fn context_snapshot_payload_stats(messages: &[Message]) -> ContextSnapshotPayloadStats { + let mut stats = ContextSnapshotPayloadStats::default(); + for (message_index, message) in messages.iter().enumerate() { + let MessageContent::ToolResult { + tool_name, + result, + result_for_assistant, + .. + } = &message.content + else { + continue; + }; + + stats.tool_result_count += 1; + if let Some(text) = result_for_assistant.as_deref() { + stats.result_for_assistant_chars += text.chars().count(); + } + + let mut raw_chars = 0usize; + let mut largest = (0usize, String::new()); + collect_json_string_stats( + result, + &format!("message[{}].{}", message_index, tool_name), + &mut raw_chars, + &mut largest, + ); + stats.raw_result_string_chars += raw_chars; + if largest.0 > stats.largest_raw_result_chars { + stats.largest_raw_result_chars = largest.0; + stats.largest_raw_result_path = largest.1; + } + } + stats +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredSessionTranscriptFile { schema_version: u32, @@ -322,10 +399,22 @@ impl PersistenceManager { &self, path: &Path, ) -> BitFunResult> { - if !path.exists() { - return Ok(None); - } + let started_at = Instant::now(); + let metadata_started_at = Instant::now(); + let metadata = match fs::metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(BitFunError::io(format!( + "Failed to read JSON metadata {}: {}", + path.display(), + error + ))); + } + }; + let metadata_duration = metadata_started_at.elapsed(); + let read_started_at = Instant::now(); let content = fs::read_to_string(path).await.map_err(|e| { BitFunError::io(format!( "Failed to read JSON file {}: {}", @@ -333,7 +422,9 @@ impl PersistenceManager { e )) })?; + let read_duration = read_started_at.elapsed(); + let parse_started_at = Instant::now(); let value = serde_json::from_str::(&content).map_err(|e| { BitFunError::Deserialization(format!( "Failed to deserialize JSON file {}: {}", @@ -341,6 +432,21 @@ impl PersistenceManager { e )) })?; + let parse_duration = parse_started_at.elapsed(); + let total_duration = started_at.elapsed(); + + if total_duration >= Duration::from_millis(80) || metadata.len() >= 1024 * 1024 { + debug!( + "Read JSON file: path={} type={} size_bytes={} metadata_duration_ms={} read_duration_ms={} parse_duration_ms={} total_duration_ms={}", + path.display(), + std::any::type_name::(), + metadata.len(), + metadata_duration.as_millis(), + read_duration.as_millis(), + parse_duration.as_millis(), + total_duration.as_millis() + ); + } Ok(Some(value)) } @@ -1521,12 +1627,15 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult)>> { + let started_at = Instant::now(); let dir = self.snapshots_dir(workspace_path, session_id); if !dir.exists() { return Ok(None); } + let scan_started_at = Instant::now(); let mut latest: Option = None; + let mut snapshot_file_count = 0usize; let mut rd = fs::read_dir(&dir) .await .map_err(|e| BitFunError::io(format!("Failed to read snapshots directory: {}", e)))?; @@ -1547,20 +1656,44 @@ impl PersistenceManager { continue; }; if let Ok(index) = index_str.parse::() { + snapshot_file_count += 1; latest = Some(latest.map(|value| value.max(index)).unwrap_or(index)); } } + let scan_duration = scan_started_at.elapsed(); let Some(turn_index) = latest else { return Ok(None); }; + let load_started_at = Instant::now(); let Some(messages) = self .load_turn_context_snapshot(workspace_path, session_id, turn_index) .await? else { return Ok(None); }; + let load_duration = load_started_at.elapsed(); + let total_duration = started_at.elapsed(); + + if total_duration >= Duration::from_millis(80) || snapshot_file_count >= 10 { + let payload_stats = context_snapshot_payload_stats(&messages); + debug!( + "Loaded latest context snapshot: session_id={} turn_index={} snapshot_file_count={} scan_duration_ms={} load_duration_ms={} total_duration_ms={} message_count={} tool_result_count={} raw_result_string_chars={} result_for_assistant_chars={} largest_raw_result_chars={} largest_raw_result_path={}", + session_id, + turn_index, + snapshot_file_count, + scan_duration.as_millis(), + load_duration.as_millis(), + total_duration.as_millis(), + messages.len(), + payload_stats.tool_result_count, + payload_stats.raw_result_string_chars, + payload_stats.result_for_assistant_chars, + payload_stats.largest_raw_result_chars, + payload_stats.largest_raw_result_path + ); + } Ok(Some((turn_index, messages))) } @@ -1639,6 +1772,18 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult { + let (session, _) = self + .load_session_with_turns(workspace_path, session_id) + .await?; + Ok(session) + } + + /// Load session and return the persisted turns read while rebuilding the session header. + pub async fn load_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { let metadata = self .load_session_metadata(workspace_path, session_id) .await? @@ -1678,7 +1823,8 @@ impl PersistenceManager { let created_at = Self::unix_ms_to_system_time(metadata.created_at); let last_activity_at = Self::unix_ms_to_system_time(metadata.last_active_at); - Ok(Session { + let dialog_turn_ids = turns.iter().map(|turn| turn.turn_id.clone()).collect(); + let session = Session { session_id: metadata.session_id.clone(), session_name: metadata.session_name.clone(), agent_type: metadata.agent_type.clone(), @@ -1687,14 +1833,16 @@ impl PersistenceManager { snapshot_session_id: stored_state .and_then(|value| value.snapshot_session_id) .or(metadata.snapshot_session_id.clone()), - dialog_turn_ids: turns.into_iter().map(|turn| turn.turn_id).collect(), + dialog_turn_ids, state: runtime_state, config, compression_state, created_at, updated_at: last_activity_at, last_activity_at, - }) + }; + + Ok((session, turns)) } /// Save session state @@ -1845,11 +1993,13 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult> { + let started_at = Instant::now(); let turns_dir = self.turns_dir(workspace_path, session_id); if !turns_dir.exists() { return Ok(Vec::new()); } + let scan_started_at = Instant::now(); let mut indexed_paths = Vec::new(); let mut entries = fs::read_dir(&turns_dir) .await @@ -1877,8 +2027,11 @@ impl PersistenceManager { } indexed_paths.sort_by_key(|(index, _)| *index); + let scan_duration = scan_started_at.elapsed(); + let read_started_at = Instant::now(); let mut turns = Vec::with_capacity(indexed_paths.len()); + let turn_file_count = indexed_paths.len(); for (_, path) in indexed_paths { if let Some(file) = self .read_json_optional::(&path) @@ -1887,6 +2040,19 @@ impl PersistenceManager { turns.push(file.turn); } } + let read_duration = read_started_at.elapsed(); + let total_duration = started_at.elapsed(); + if total_duration >= Duration::from_millis(80) || turn_file_count >= 50 { + debug!( + "Loaded session turns: session_id={} turn_count={} turn_file_count={} scan_duration_ms={} read_duration_ms={} total_duration_ms={}", + session_id, + turns.len(), + turn_file_count, + scan_duration.as_millis(), + read_duration.as_millis(), + total_duration.as_millis() + ); + } Ok(turns) } @@ -2252,8 +2418,8 @@ impl PersistenceManager { #[cfg(test)] mod tests { - use super::PersistenceManager; - use crate::agentic::core::SessionKind; + use super::{context_snapshot_payload_stats, PersistenceManager}; + use crate::agentic::core::{Message, Session, SessionConfig, SessionKind, ToolResult}; use crate::infrastructure::PathManager; use crate::service::session::{ DialogTurnData, SessionMetadata, SessionTranscriptExportOptions, UserMessageData, @@ -2374,6 +2540,76 @@ mod tests { assert!(transcript.contains("hello transcript")); } + #[tokio::test] + async fn load_session_with_turns_returns_session_and_persisted_turns() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Load once".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let user_message = UserMessageData { + id: "user-1".to_string(), + content: "hello once".to_string(), + timestamp: 0, + metadata: None, + }; + let mut turn = + DialogTurnData::new("turn-1".to_string(), 0, session_id.clone(), user_message); + turn.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + + let (loaded_session, loaded_turns) = manager + .load_session_with_turns(workspace.path(), &session_id) + .await + .expect("session and turns should load together"); + + assert_eq!(loaded_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!(loaded_turns.len(), 1); + assert_eq!(loaded_turns[0].turn_id, "turn-1"); + } + + #[test] + fn context_snapshot_payload_stats_counts_tool_result_payloads_without_contents() { + let messages = vec![ + Message::assistant("hello".to_string()), + Message::tool_result(ToolResult { + tool_id: "tool-1".to_string(), + tool_name: "Bash".to_string(), + result: serde_json::json!({ "output": "x".repeat(40) }), + result_for_assistant: Some("assistant summary".to_string()), + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }), + ]; + + let stats = context_snapshot_payload_stats(&messages); + + assert_eq!(stats.tool_result_count, 1); + assert_eq!(stats.raw_result_string_chars, 40); + assert_eq!(stats.result_for_assistant_chars, 17); + assert_eq!(stats.largest_raw_result_chars, 40); + assert_eq!(stats.largest_raw_result_path, "message[1].Bash.output"); + assert!(!stats.largest_raw_result_path.contains(&"x".repeat(40))); + } + #[tokio::test] async fn subagent_session_kind_is_hidden_from_visible_session_index() { let workspace = TestWorkspace::new(); diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 0837e27527..0888b60dd5 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -79,11 +79,14 @@ pub struct ResolvedSessionTitle { #[cfg(test)] mod tests { use super::{SessionManager, SessionManagerConfig}; - use crate::agentic::core::{ProcessingPhase, Session, SessionConfig, SessionState}; + use crate::agentic::core::{Message, ProcessingPhase, Session, SessionConfig, SessionState}; use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::SessionContextStore; use crate::infrastructure::PathManager; - use crate::service::session::{DialogTurnData, UserMessageData}; + use crate::service::session::{ + DialogTurnData, ModelRoundData, ToolCallData, ToolItemData, ToolResultData, UserMessageData, + }; + use serde_json::json; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -304,6 +307,194 @@ mod tests { assert_eq!(metadata.unread_completion, None); } + #[tokio::test] + async fn restore_session_view_loads_turns_without_restoring_runtime_context() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Large history".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.dialog_turn_ids = vec!["turn-1".to_string()]; + + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + let turn = DialogTurnData::new( + "turn-1".to_string(), + 0, + session_id.clone(), + UserMessageData { + id: "turn-1-user".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + persistence_manager + .save_turn_context_snapshot( + workspace.path(), + &session_id, + 0, + &[Message::user("snapshot prompt".to_string())], + ) + .await + .expect("context snapshot should save"); + + let (view_session, turns) = manager + .restore_session_view(workspace.path(), &session_id) + .await + .expect("session view should restore"); + + assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!(turns.len(), 1); + assert!(manager.get_session(&session_id).is_none()); + assert!(manager + .context_store + .get_context_messages(&session_id) + .is_empty()); + } + + #[tokio::test] + async fn restore_session_view_preserves_full_visible_tool_result_payload() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "History with tool output".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.dialog_turn_ids = vec!["turn-1".to_string()]; + + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let visible_output = "complete visible output ".repeat(128); + let assistant_output = "assistant visible summary ".repeat(16); + let mut turn = DialogTurnData::new( + "turn-1".to_string(), + 0, + session_id.clone(), + UserMessageData { + id: "turn-1-user".to_string(), + content: "show full output".to_string(), + timestamp: 1, + metadata: None, + }, + ); + turn.model_rounds.push(ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![ToolItemData { + id: "tool-1".to_string(), + tool_name: "Bash".to_string(), + tool_call: ToolCallData { + id: "call-1".to_string(), + input: json!({ "command": "printf output" }), + }, + tool_result: Some(ToolResultData { + result: json!({ + "stdout": visible_output, + "nested": { + "stderr": "also visible", + }, + }), + success: true, + result_for_assistant: Some(assistant_output.clone()), + error: None, + duration_ms: Some(1), + }), + ai_intent: None, + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + subagent_model_id: None, + subagent_model_alias: None, + status: Some("completed".to_string()), + interruption_reason: None, + }], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + + let (view_session, turns) = manager + .restore_session_view(workspace.path(), &session_id) + .await + .expect("session view should restore"); + + let restored_result = turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .expect("tool result should be preserved"); + assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!( + restored_result.result["stdout"].as_str(), + Some(visible_output.as_str()) + ); + assert_eq!( + restored_result.result["nested"]["stderr"].as_str(), + Some("also visible") + ); + assert_eq!( + restored_result.result_for_assistant.as_deref(), + Some(assistant_output.as_str()) + ); + assert!(manager.get_session(&session_id).is_none()); + } + #[tokio::test] async fn rollback_context_deletes_persisted_turns_from_target() { let workspace = TestWorkspace::new(); @@ -1700,9 +1891,113 @@ impl SessionManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult { + let (session, _) = self + .restore_session_with_turns(workspace_path, session_id) + .await?; + Ok(session) + } + + /// Restore the persisted session header and turns needed by the UI view + /// without loading runtime context snapshots or inserting the session into + /// the in-memory coordinator state. + pub async fn restore_session_view( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + let restore_started_at = Instant::now(); + let storage_path_started_at = Instant::now(); + let session_storage_path = { + let ws = workspace_path.to_string_lossy().to_string(); + let tmp_config = SessionConfig { + workspace_path: Some(ws), + ..Default::default() + }; + Self::effective_workspace_path_from_config(&tmp_config) + .await + .unwrap_or_else(|| workspace_path.to_path_buf()) + }; + debug!( + "Session view restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", + session_id, + elapsed_ms_u64(storage_path_started_at) + ); + + let metadata_started_at = Instant::now(); + if self + .persistence_manager + .load_session_metadata(&session_storage_path, session_id) + .await? + .is_some_and(|metadata| metadata.should_hide_from_user_lists()) + { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + } + debug!( + "Session view restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", + session_id, + elapsed_ms_u64(metadata_started_at) + ); + + let session_started_at = Instant::now(); + let (mut session, persisted_turns) = self + .persistence_manager + .load_session_with_turns(&session_storage_path, session_id) + .await?; + debug!( + "Session view restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", + session_id, + persisted_turns.len(), + elapsed_ms_u64(session_started_at) + ); + + if !matches!(session.state, SessionState::Idle) { + let old_state = session.state.clone(); + session.state = SessionState::Idle; + debug!( + "Resetting session state during view restore: session_id={}, state={:?} -> Idle", + session_id, old_state + ); + } + + let persisted_turn_ids: Vec = persisted_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect(); + if session.dialog_turn_ids != persisted_turn_ids { + debug!( + "Session view restore normalized turn ids: session_id={}, session_turn_count={}, persisted_turn_count={}", + session_id, + session.dialog_turn_ids.len(), + persisted_turn_ids.len() + ); + session.dialog_turn_ids = persisted_turn_ids; + } + + debug!( + "Session view restored: session_id={}, session_name={}, turn_count={}, total_duration_ms={}", + session_id, + session.session_name, + persisted_turns.len(), + elapsed_ms_u64(restore_started_at) + ); + + Ok((session, persisted_turns)) + } + + /// Restore session and return the persisted turns read during restore. + pub async fn restore_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + let restore_started_at = Instant::now(); // Check if session is already in memory let session_already_in_memory = self.sessions.contains_key(session_id); + let storage_path_started_at = Instant::now(); let session_storage_path = { let ws = workspace_path.to_string_lossy().to_string(); let tmp_config = SessionConfig { @@ -1713,7 +2008,13 @@ impl SessionManager { .await .unwrap_or_else(|| workspace_path.to_path_buf()) }; + debug!( + "Session restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", + session_id, + elapsed_ms_u64(storage_path_started_at) + ); + let metadata_started_at = Instant::now(); if self .persistence_manager .load_session_metadata(&session_storage_path, session_id) @@ -1725,12 +2026,24 @@ impl SessionManager { session_id ))); } + debug!( + "Session restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", + session_id, + elapsed_ms_u64(metadata_started_at) + ); - // 1. Load session from storage - let mut session = self + // 1. Load session and turns from storage in one pass + let session_started_at = Instant::now(); + let (mut session, persisted_turns) = self .persistence_manager - .load_session(&session_storage_path, session_id) + .load_session_with_turns(&session_storage_path, session_id) .await?; + debug!( + "Session restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", + session_id, + persisted_turns.len(), + elapsed_ms_u64(session_started_at) + ); // Lazy migration: if the persisted model_id is no longer usable // (model deleted or disabled while the session was on disk), repoint @@ -1791,15 +2104,12 @@ impl SessionManager { // // This compensates for the fact that persistence is not transactional across // `session.json`, `turns/*.json`, and `snapshots/context-*.json`. - let persisted_turns = self - .persistence_manager - .load_session_turns(&session_storage_path, session_id) - .await?; let persisted_turn_ids: Vec = persisted_turns .iter() .map(|turn| turn.turn_id.clone()) .collect(); let mut latest_turn_index: Option = None; + let context_snapshot_started_at = Instant::now(); let mut messages = match self .persistence_manager .load_latest_turn_context_snapshot(&session_storage_path, session_id) @@ -1811,6 +2121,13 @@ impl SessionManager { } None => Self::build_messages_from_turns(&persisted_turns), }; + debug!( + "Session restore phase completed: session_id={}, phase=load_context_snapshot, snapshot_turn_index={:?}, message_count={}, duration_ms={}", + session_id, + latest_turn_index, + messages.len(), + elapsed_ms_u64(context_snapshot_started_at) + ); if let Some(snapshot_turn_index) = latest_turn_index { let delta_start = snapshot_turn_index.saturating_add(1); @@ -1840,8 +2157,15 @@ impl SessionManager { self.context_store.delete_session(session_id); } + let context_replace_started_at = Instant::now(); self.context_store .replace_context(session_id, messages.clone()); + debug!( + "Session restore phase completed: session_id={}, phase=replace_context, message_count={}, duration_ms={}", + session_id, + messages.len(), + elapsed_ms_u64(context_replace_started_at) + ); let recoverable_turn_count = latest_turn_index .map(|turn_index| turn_index + 1) @@ -1885,12 +2209,14 @@ impl SessionManager { let context_msg_count = self.context_store.get_context_messages(session_id).len(); - info!( - "Session restored: session_id={}, session_name={}, messages={}, context_messages={}", + debug!( + "Session restored: session_id={}, session_name={}, messages={}, context_messages={}, turn_count={}, total_duration_ms={}", session_id, session.session_name, messages.len(), - context_msg_count + context_msg_count, + persisted_turns.len(), + elapsed_ms_u64(restore_started_at) ); // Do not infer unread completion from persisted runtime state during restore. @@ -1904,7 +2230,7 @@ impl SessionManager { self.session_workspace_index .insert(session_id.to_string(), session_storage_path.clone()); - Ok(session) + Ok((session, persisted_turns)) } /// Rollback "model context" to before the start of specified turn (i.e., keep 0..target_turn-1) diff --git a/src/crates/core/src/service/snapshot/manager.rs b/src/crates/core/src/service/snapshot/manager.rs index 987bb5fb8c..68d8497de6 100644 --- a/src/crates/core/src/service/snapshot/manager.rs +++ b/src/crates/core/src/service/snapshot/manager.rs @@ -14,7 +14,13 @@ use serde_json::Value; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock, RwLock as StdRwLock}; -use tokio::sync::RwLock; +use std::time::Instant; +use tokio::sync::{Mutex as AsyncMutex, RwLock}; + +#[cfg(test)] +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +#[cfg(test)] +use std::time::Duration; /// Snapshot manager /// @@ -29,6 +35,9 @@ impl SnapshotManager { workspace_dir: PathBuf, config: Option, ) -> SnapshotResult { + #[cfg(test)] + record_snapshot_manager_new_for_test().await; + info!( "Creating snapshot manager: workspace={}", workspace_dir.display() @@ -304,6 +313,57 @@ fn snapshot_managers() -> &'static StdRwLock &'static AsyncMutex>>> { + static SNAPSHOT_MANAGER_INIT_LOCKS: OnceLock< + AsyncMutex>>>, + > = OnceLock::new(); + SNAPSHOT_MANAGER_INIT_LOCKS.get_or_init(|| AsyncMutex::new(HashMap::new())) +} + +async fn snapshot_manager_init_lock(workspace_dir: &Path) -> Arc> { + let mut locks = snapshot_manager_init_locks().lock().await; + locks + .entry(workspace_dir.to_path_buf()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() +} + +#[cfg(test)] +static SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST: AtomicUsize = AtomicUsize::new(0); +#[cfg(test)] +static SNAPSHOT_MANAGER_NEW_DELAY_MS_FOR_TEST: AtomicU64 = AtomicU64::new(0); + +#[cfg(test)] +async fn record_snapshot_manager_new_for_test() { + SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST.fetch_add(1, Ordering::SeqCst); + let delay_ms = SNAPSHOT_MANAGER_NEW_DELAY_MS_FOR_TEST.load(Ordering::SeqCst); + if delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } +} + +#[cfg(test)] +fn reset_snapshot_manager_new_count_for_test() { + SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST.store(0, Ordering::SeqCst); +} + +#[cfg(test)] +fn snapshot_manager_new_count_for_test() -> usize { + SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST.load(Ordering::SeqCst) +} + +#[cfg(test)] +fn set_snapshot_manager_new_delay_for_test(delay: Duration) { + SNAPSHOT_MANAGER_NEW_DELAY_MS_FOR_TEST.store(delay.as_millis() as u64, Ordering::SeqCst); +} + +#[cfg(test)] +fn clear_snapshot_manager_for_test(workspace_dir: &Path) { + if let Ok(mut managers) = snapshot_managers().write() { + managers.remove(workspace_dir); + } +} + /// Ensures the registry always exposes the same tool implementation that will be /// executed at runtime. File-modifying tools are wrapped once at registration time /// so tool definitions, permission checks, and execution all share one source of truth. @@ -672,6 +732,22 @@ pub async fn get_or_create_snapshot_manager( return Ok(existing); } + let init_lock = snapshot_manager_init_lock(&workspace_dir).await; + let _init_guard = init_lock.lock().await; + + if let Some(existing) = get_snapshot_manager_for_workspace(&workspace_dir) { + debug!( + "Snapshot manager initialized by concurrent request: workspace={}", + workspace_dir.display() + ); + return Ok(existing); + } + + let started_at = Instant::now(); + info!( + "Snapshot manager cold initialization started: workspace={}", + workspace_dir.display() + ); let manager = Arc::new(SnapshotManager::new(workspace_dir.clone(), config).await?); { let mut managers = snapshot_managers().write().map_err(|_| { @@ -682,6 +758,10 @@ pub async fn get_or_create_snapshot_manager( } managers.insert(workspace_dir, manager.clone()); } + info!( + "Snapshot manager cold initialization completed: duration_ms={}", + started_at.elapsed().as_millis() + ); Ok(manager) } @@ -710,6 +790,63 @@ pub async fn initialize_snapshot_manager_for_workspace( config: Option, ) -> SnapshotResult<()> { get_or_create_snapshot_manager(workspace_dir, config).await?; - info!("Snapshot manager initialized for workspace"); + debug!("Snapshot manager initialized for workspace"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::{ + clear_snapshot_manager_for_test, get_or_create_snapshot_manager, + reset_snapshot_manager_new_count_for_test, set_snapshot_manager_new_delay_for_test, + snapshot_manager_new_count_for_test, + }; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + use std::time::Duration; + use uuid::Uuid; + + struct TestWorkspace { + path: PathBuf, + } + + impl TestWorkspace { + fn new() -> Self { + let path = std::env::temp_dir() + .join(format!("bitfun-snapshot-manager-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).expect("test workspace should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestWorkspace { + fn drop(&mut self) { + clear_snapshot_manager_for_test(&self.path); + let _ = std::fs::remove_dir_all(&self.path); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_get_or_create_initializes_snapshot_manager_once_per_workspace() { + let workspace = TestWorkspace::new(); + clear_snapshot_manager_for_test(workspace.path()); + reset_snapshot_manager_new_count_for_test(); + set_snapshot_manager_new_delay_for_test(Duration::from_millis(80)); + + let first = get_or_create_snapshot_manager(workspace.path().to_path_buf(), None); + let second = get_or_create_snapshot_manager(workspace.path().to_path_buf(), None); + let (first, second) = tokio::join!(first, second); + + set_snapshot_manager_new_delay_for_test(Duration::ZERO); + + let first = first.expect("first snapshot manager should initialize"); + let second = second.expect("second snapshot manager should initialize"); + + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(snapshot_manager_new_count_for_test(), 1); + } +} diff --git a/src/crates/core/src/service/snapshot/service.rs b/src/crates/core/src/service/snapshot/service.rs index 722c74e856..afcf6821da 100644 --- a/src/crates/core/src/service/snapshot/service.rs +++ b/src/crates/core/src/service/snapshot/service.rs @@ -7,9 +7,10 @@ use crate::service::snapshot::types::{ OperationType, SessionInfo, SnapshotConfig, SnapshotError, SnapshotResult, }; use crate::service::workspace_runtime::WorkspaceRuntimeContext; -use log::info; +use log::{debug, info}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::Instant; use tokio::sync::RwLock; /// Snapshot-based change tracking service (operation-history + file snapshots, git-isolated). @@ -57,29 +58,51 @@ impl SnapshotService { return Ok(()); } + let total_started_at = Instant::now(); info!("Initializing snapshot/operation history service"); { + let step_started_at = Instant::now(); let mut isolation_manager = self.isolation_manager.write().await; isolation_manager.ensure_complete_isolation().await?; + debug!( + "Snapshot service initialize step completed: step=ensure_complete_isolation duration_ms={}", + step_started_at.elapsed().as_millis() + ); } { + let step_started_at = Instant::now(); let mut snapshot_core = self.snapshot_core.write().await; snapshot_core.initialize().await?; + debug!( + "Snapshot service initialize step completed: step=snapshot_core_initialize duration_ms={}", + step_started_at.elapsed().as_millis() + ); } + let step_started_at = Instant::now(); self.file_lock_manager.initialize().await?; + debug!( + "Snapshot service initialize step completed: step=file_lock_manager_initialize duration_ms={}", + step_started_at.elapsed().as_millis() + ); self.initialized = true; + let step_started_at = Instant::now(); let isolation_status = { let isolation_manager = self.isolation_manager.read().await; isolation_manager.check_isolation_status().await? }; + debug!( + "Snapshot service initialize step completed: step=check_isolation_status duration_ms={}", + step_started_at.elapsed().as_millis() + ); info!( - "Snapshot service initialized: git_isolated={} bitfun_dir={}", + "Snapshot service initialized: git_isolated={} bitfun_dir={} duration_ms={}", isolation_status, - self.runtime_context.runtime_root.display() + self.runtime_context.runtime_root.display(), + total_started_at.elapsed().as_millis() ); Ok(()) diff --git a/src/crates/core/src/service/snapshot/snapshot_core.rs b/src/crates/core/src/service/snapshot/snapshot_core.rs index b2ee98220e..f191f5b3a8 100644 --- a/src/crates/core/src/service/snapshot/snapshot_core.rs +++ b/src/crates/core/src/service/snapshot/snapshot_core.rs @@ -8,7 +8,7 @@ use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::time::SystemTime; +use std::time::{Instant, SystemTime}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -113,13 +113,26 @@ impl SnapshotCore { } pub async fn initialize(&mut self) -> SnapshotResult<()> { + let total_started_at = Instant::now(); info!("Initializing operation history system"); + let snapshot_system_started_at = Instant::now(); self.snapshot_system.initialize().await?; + debug!( + "Operation history initialize step completed: step=file_snapshot_system duration_ms={}", + snapshot_system_started_at.elapsed().as_millis() + ); + + let sessions_started_at = Instant::now(); self.load_all_sessions().await?; + debug!( + "Operation history initialize step completed: step=load_sessions duration_ms={}", + sessions_started_at.elapsed().as_millis() + ); info!( - "Operation history system initialized: loaded_sessions={}", - self.sessions.len() + "Operation history system initialized: loaded_sessions={} duration_ms={}", + self.sessions.len(), + total_started_at.elapsed().as_millis() ); Ok(()) } @@ -897,6 +910,7 @@ impl SnapshotCore { } async fn load_all_sessions(&mut self) -> SnapshotResult<()> { + let started_at = Instant::now(); if !self.sessions_dir.exists() { return Ok(()); } @@ -928,7 +942,11 @@ impl SnapshotCore { ), } } - debug!("Loaded {} session files", loaded); + debug!( + "Loaded session files: count={} duration_ms={}", + loaded, + started_at.elapsed().as_millis() + ); self.rebuild_operation_index(); Ok(()) } diff --git a/src/crates/core/src/service/snapshot/snapshot_system.rs b/src/crates/core/src/service/snapshot/snapshot_system.rs index c23eb1812f..7eb0a292c3 100644 --- a/src/crates/core/src/service/snapshot/snapshot_system.rs +++ b/src/crates/core/src/service/snapshot/snapshot_system.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::SystemTime; +use std::time::{Instant, SystemTime}; use tokio::sync::RwLock; use uuid::Uuid; @@ -230,15 +230,27 @@ impl FileSnapshotSystem { /// Initializes the snapshot system. pub async fn initialize(&mut self) -> SnapshotResult<()> { + let total_started_at = Instant::now(); info!("Initializing file snapshot system"); + let directories_started_at = Instant::now(); self.ensure_directories().await?; + debug!( + "File snapshot initialize step completed: step=ensure_directories duration_ms={}", + directories_started_at.elapsed().as_millis() + ); + let index_started_at = Instant::now(); self.load_snapshot_index().await?; + debug!( + "File snapshot initialize step completed: step=load_snapshot_index duration_ms={}", + index_started_at.elapsed().as_millis() + ); info!( - "File snapshot system initialized: loaded_snapshots={}", - self.active_snapshots.len() + "File snapshot system initialized: loaded_snapshots={} duration_ms={}", + self.active_snapshots.len(), + total_started_at.elapsed().as_millis() ); Ok(()) } @@ -266,6 +278,7 @@ impl FileSnapshotSystem { /// Loads the existing snapshot index. async fn load_snapshot_index(&mut self) -> SnapshotResult<()> { + let started_at = Instant::now(); let metadata_dir = self.snapshot_metadata_dir.clone(); if !metadata_dir.exists() { @@ -300,7 +313,11 @@ impl FileSnapshotSystem { } } - debug!("Loaded {} snapshots", loaded_count); + debug!( + "Loaded snapshot metadata files: count={} duration_ms={}", + loaded_count, + started_at.elapsed().as_millis() + ); Ok(()) } diff --git a/src/crates/services-integrations/src/git/service.rs b/src/crates/services-integrations/src/git/service.rs index b0ca1f04cd..3ad46a42fe 100644 --- a/src/crates/services-integrations/src/git/service.rs +++ b/src/crates/services-integrations/src/git/service.rs @@ -58,6 +58,31 @@ impl GitService { }) } + /// Gets lightweight repository information without scanning worktree status. + pub async fn get_repository_basic>(path: P) -> Result { + let repo = + Repository::open(&path).map_err(|e| GitError::RepositoryNotFound(e.to_string()))?; + + let current_branch = get_current_branch(&repo)?; + let is_bare = repo.is_bare(); + let path_str = path.as_ref().to_string_lossy().to_string(); + let name = path + .as_ref() + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + Ok(GitRepository { + path: path_str, + name, + current_branch, + is_bare, + has_changes: false, + remotes: Vec::new(), + }) + } + /// Gets repository status. pub async fn get_status>(path: P) -> Result { let repo = diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 143a40ca90..ae96de50ad 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -53,6 +53,7 @@ function App() { const mountTimeRef = useRef(Date.now()); const mainWindowShownRef = useRef(false); const interactiveShellReadyRef = useRef(false); + const [interactiveShellReady, setInteractiveShellReady] = useState(false); // Once the workspace finishes loading, wait for the remaining min-display // time and then begin the exit animation. @@ -110,6 +111,7 @@ function App() { } interactiveShellReadyRef.current = true; startupTrace.markPhase('interactive_shell_ready'); + setInteractiveShellReady(true); }, [workspaceLoading]); // If the early reveal path fails, keep the old post-splash show as a retry. @@ -179,24 +181,71 @@ function App() { }, []); useEffect(() => { - if (!isTauriRuntime()) return; + if (!isTauriRuntime() || !interactiveShellReady) return; + let disposed = false; + let startupSyncHandle: { promise: Promise; cancel: () => void } | null = null; const emitCurrentAgentCompanionActivity = () => { + if (disposed) { + return; + } void emitAgentCompanionActivity(buildAgentCompanionActivity()); }; void aiExperienceConfigService.getSettingsAsync().then(async settings => { - await syncAgentCompanionDesktopWindow(settings); - emitCurrentAgentCompanionActivity(); - window.setTimeout(emitCurrentAgentCompanionActivity, 250); + if (disposed) { + return; + } + + const { backgroundTaskScheduler, BackgroundTaskCancelledError } = await import('@/shared/utils/backgroundTaskScheduler'); + if (disposed) { + return; + } + + startupTrace.markPhase('agent_companion_sync_scheduled', { + source: 'startup_idle', + }); + startupSyncHandle = backgroundTaskScheduler.schedule(async signal => { + if (signal.aborted || disposed) { + return; + } + startupTrace.markPhase('agent_companion_sync_start', { + source: 'startup_idle', + }); + await syncAgentCompanionDesktopWindow(settings); + if (signal.aborted || disposed) { + return; + } + emitCurrentAgentCompanionActivity(); + window.setTimeout(emitCurrentAgentCompanionActivity, 250); + startupTrace.markPhase('agent_companion_sync_end', { + source: 'startup_idle', + }); + }, { + idle: true, + inFlightKey: 'agent-companion:startup-sync', + priority: 'low', + }); + + startupSyncHandle.promise.catch(error => { + if (!disposed && !(error instanceof BackgroundTaskCancelledError)) { + log.warn('Initial Agent companion sync task failed', error); + } + }); }); - return aiExperienceConfigService.addChangeListener(settings => { + + const removeSettingsListener = aiExperienceConfigService.addChangeListener(settings => { void syncAgentCompanionDesktopWindow(settings).then(() => { emitCurrentAgentCompanionActivity(); window.setTimeout(emitCurrentAgentCompanionActivity, 250); }); }); - }, []); + return () => { + disposed = true; + startupSyncHandle?.cancel(); + removeSettingsListener(); + }; + }, [interactiveShellReady]); useEffect(() => subscribeAgentCompanionActivity(activity => { void emitAgentCompanionActivity(activity); diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index cb7af2dd3c..0d7c9fbfcb 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -177,10 +177,16 @@ const MainNav: React.FC = ({ void flowChatStore.initializeFromDisk( workspace.rootPath, workspace.connectionId ?? undefined, - workspace.sshHost ?? undefined + workspace.sshHost ?? undefined, + 'main_nav_opened_remote_workspace' ); } else { - void flowChatStore.initializeFromDisk(workspace.rootPath); + void flowChatStore.initializeFromDisk( + workspace.rootPath, + undefined, + undefined, + 'main_nav_opened_local_workspace' + ); } }); }, [openedWorkspacesList]); diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx index 6feb635eee..599635952a 100644 --- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx @@ -494,7 +494,8 @@ export const SSHRemoteProvider: React.FC = ({ children } workspace.remotePath, workspace.connectionId, workspace.sshHost?.trim() || - sshHostForRemoteWorkspace(workspace.connectionId, workspace.remotePath) + sshHostForRemoteWorkspace(workspace.connectionId, workspace.remotePath), + 'ssh_remote_auto_restore_existing' ) .catch(() => {}); @@ -544,7 +545,8 @@ export const SSHRemoteProvider: React.FC = ({ children } sshHostForRemoteWorkspace( result.workspace.connectionId, result.workspace.remotePath - ) + ), + 'ssh_remote_auto_restore_reconnected' ) .catch(() => {}); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 8b72929d90..0972c4df26 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -62,6 +62,7 @@ export class FlowChatManager { }), pendingTurnCompletions: new Map(), pendingHistoryLoads: new Map(), + pendingContextRestores: new Map(), contentBuffers: new Map(), activeTextItems: new Map(), saveDebouncers: new Map(), @@ -112,7 +113,8 @@ export class FlowChatManager { await this.context.flowChatStore.initializeFromDisk( workspacePath, remoteConnectionId, - remoteSshHost + remoteSshHost, + 'flow_chat_manager' ); const sessionMatchesWorkspace = (session: { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index 9ba7a0a7b1..37d2932875 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ensureBackendSession, switchChatSession } from './SessionModule'; import type { Session } from '../../types/flow-chat'; @@ -97,12 +97,17 @@ function createContext(session: Session) { context: { flowChatStore, pendingHistoryLoads: new Map>(), + pendingContextRestores: new Map>(), } as any, flowChatStore, }; } describe('SessionModule historical session coordination', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + it('switches to a historical session immediately while hydrating in the background', async () => { const load = createDeferred(); const { context, flowChatStore } = createContext(createSession()); @@ -136,4 +141,48 @@ describe('SessionModule historical session coordination', () => { expect(agentApiMocks.ensureCoordinatorSession).toHaveBeenCalledTimes(1); expect(agentApiMocks.createSession).not.toHaveBeenCalled(); }); + + it('restores pending backend context for a view-restored session before send', async () => { + const { context } = createContext(createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + dialogTurns: [{ id: 'turn-1' } as any], + } as any)); + agentApiMocks.ensureCoordinatorSession.mockResolvedValueOnce(undefined); + + await ensureBackendSession(context, 'history-1'); + + expect(agentApiMocks.ensureCoordinatorSession).toHaveBeenCalledTimes(1); + expect(agentApiMocks.createSession).not.toHaveBeenCalled(); + expect(context.flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + contextRestoreState: 'ready', + }); + }); + + it('dedupes concurrent backend context restore for a view-restored session', async () => { + const { context } = createContext(createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + dialogTurns: [{ id: 'turn-1' } as any], + } as any)); + const restore = createDeferred(); + agentApiMocks.ensureCoordinatorSession.mockReturnValueOnce(restore.promise); + + const firstEnsure = ensureBackendSession(context, 'history-1'); + const secondEnsure = ensureBackendSession(context, 'history-1'); + await Promise.resolve(); + + expect(agentApiMocks.ensureCoordinatorSession).toHaveBeenCalledTimes(1); + + restore.resolve(); + await Promise.all([firstEnsure, secondEnsure]); + + expect(agentApiMocks.createSession).not.toHaveBeenCalled(); + expect(context.pendingContextRestores.size).toBe(0); + expect(context.flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + contextRestoreState: 'ready', + }); + }); }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 171fbfdc5b..8e0a115609 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -655,31 +655,86 @@ export async function ensureBackendSession( const isHistoricalSession = latestSession.isHistorical === true; const isFirstTurn = latestSession.dialogTurns.length <= 1; - const needsBackendSetup = isHistoricalSession || isFirstTurn; + const requiresContextRestore = + latestSession.contextRestoreState === 'pending' || + latestSession.contextRestoreState === 'failed'; + const needsBackendSetup = isHistoricalSession || isFirstTurn || requiresContextRestore; /** Avoid createSession when historical data is already loaded but backend files are missing (e.g. new SSH connection id). */ const allowRecreateOnCoordinatorFailure = - needsBackendSetup && !(isHistoricalSession && session.dialogTurns.length > 1); + needsBackendSetup && + !requiresContextRestore && + !(isHistoricalSession && session.dialogTurns.length > 1); - const clearHistoricalFlag = () => { - if (!isHistoricalSession) return; + const markBackendContextReady = () => { + if (!isHistoricalSession && !requiresContextRestore) return; context.flowChatStore.setState(prev => { const newSessions = new Map(prev.sessions); const sess = newSessions.get(sessionId); if (sess) { - newSessions.set(sessionId, { ...sess, isHistorical: false, historyState: 'ready' }); + newSessions.set(sessionId, { + ...sess, + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + }); } return { ...prev, sessions: newSessions }; }); }; - try { + const markBackendContextFailed = () => { + if (!requiresContextRestore) return; + context.flowChatStore.setState(prev => { + const newSessions = new Map(prev.sessions); + const sess = newSessions.get(sessionId); + if (sess) { + newSessions.set(sessionId, { ...sess, contextRestoreState: 'failed' }); + } + return { ...prev, sessions: newSessions }; + }); + }; + + const ensureCoordinator = async () => { await agentAPI.ensureCoordinatorSession({ sessionId, workspacePath, remoteConnectionId: effectiveConnectionId, remoteSshHost: effectiveSshHost, }); - clearHistoricalFlag(); + markBackendContextReady(); + }; + + if (requiresContextRestore) { + if (!context.pendingContextRestores) { + context.pendingContextRestores = new Map(); + } + const restoreKey = [ + sessionId, + workspacePath, + effectiveConnectionId ?? '', + effectiveSshHost ?? '', + ].join('\u001f'); + const existingRestore = context.pendingContextRestores.get(restoreKey); + if (existingRestore) { + await existingRestore; + return; + } + + const restorePromise = ensureCoordinator().catch(error => { + markBackendContextFailed(); + throw error; + }).finally(() => { + if (context.pendingContextRestores?.get(restoreKey) === restorePromise) { + context.pendingContextRestores.delete(restoreKey); + } + }); + context.pendingContextRestores.set(restoreKey, restorePromise); + await restorePromise; + return; + } + + try { + await ensureCoordinator(); } catch (e: any) { if (!allowRecreateOnCoordinatorFailure) { const raw = typeof e?.message === 'string' ? e.message : String(e); @@ -708,7 +763,7 @@ export async function ensureBackendSession( remoteSshHost: effectiveSshHost, } }); - clearHistoricalFlag(); + markBackendContextReady(); } } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts index 82242ea937..4a6862a32a 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts @@ -23,6 +23,8 @@ export interface FlowChatContext { }>; /** In-flight historical session hydration: sessionId -> promise */ pendingHistoryLoads: Map>; + /** In-flight backend context restore for view-restored historical sessions. */ + pendingContextRestores?: Map>; /** Content buffers: sessionId -> (roundId -> content) */ contentBuffers: Map>; /** Active text items: sessionId -> (roundId -> textItemId) */ diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 95b91cd83e..eaba2ed49d 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -7,6 +7,8 @@ const apiMocks = vi.hoisted(() => ({ loadSessionTurns: vi.fn(), saveSessionTurn: vi.fn(), restoreSession: vi.fn(), + restoreSessionView: vi.fn(), + restoreSessionWithTurns: vi.fn(), })); const configManagerMock = vi.hoisted(() => ({ @@ -30,6 +32,10 @@ vi.mock('@/infrastructure/api', () => ({ }, agentAPI: { restoreSession: apiMocks.restoreSession, + get restoreSessionView() { + return apiMocks.restoreSessionView; + }, + restoreSessionWithTurns: apiMocks.restoreSessionWithTurns, }, })); @@ -42,6 +48,16 @@ vi.mock('../state-machine', () => ({ })); const resetStore = () => { + const metadataListRequests = (flowChatStore as any).metadataListRequests as + | Map }> + | undefined; + metadataListRequests?.forEach(request => { + if (request.cleanupTimer) { + clearTimeout(request.cleanupTimer); + } + }); + metadataListRequests?.clear(); + ((flowChatStore as any).unsupportedRestoreCommands as Set | undefined)?.clear(); flowChatStore.setState((): FlowChatState => ({ sessions: new Map(), activeSessionId: null, @@ -225,6 +241,9 @@ describe('FlowChatStore local usage reports', () => { describe('FlowChatStore historical session hydration state', () => { afterEach(() => { resetStore(); + if (typeof apiMocks.restoreSessionView !== 'function') { + (apiMocks as any).restoreSessionView = vi.fn(); + } vi.clearAllMocks(); }); @@ -316,10 +335,81 @@ describe('FlowChatStore historical session hydration state', () => { }); }); + it('reuses an in-flight metadata list for the same workspace and remote identity', async () => { + const sessions = createDeferred(); + apiMocks.listSessions.mockReturnValueOnce(sessions.promise); + + const firstLoad = flowChatStore.initializeFromDisk( + 'D:/workspace/BitFun', + undefined, + undefined, + 'first-source' + ); + const secondLoad = flowChatStore.initializeFromDisk( + 'D:/workspace/BitFun', + undefined, + undefined, + 'second-source' + ); + + await vi.waitFor(() => { + expect(apiMocks.listSessions).toHaveBeenCalledTimes(1); + }); + + sessions.resolve([ + { + sessionId: 'history-1', + title: 'Saved session', + agentType: 'agentic', + createdAt: 10, + lastActiveAt: 20, + }, + ]); + + await Promise.all([firstLoad, secondLoad]); + + expect(apiMocks.listSessions).toHaveBeenCalledTimes(1); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + sessionId: 'history-1', + historyState: 'metadata-only', + }); + }); + + it('reuses a recently completed metadata list for the same workspace', async () => { + apiMocks.listSessions.mockResolvedValueOnce([ + { + sessionId: 'history-1', + title: 'Saved session', + agentType: 'agentic', + createdAt: 10, + lastActiveAt: 20, + }, + ]); + + await flowChatStore.initializeFromDisk('D:/workspace/BitFun', undefined, undefined, 'first-source'); + await flowChatStore.initializeFromDisk('D:/workspace/BitFun', undefined, undefined, 'second-source'); + + expect(apiMocks.listSessions).toHaveBeenCalledTimes(1); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + sessionId: 'history-1', + historyState: 'metadata-only', + }); + }); + it('marks historical sessions hydrating while turns are loading and ready after completion', async () => { const turns = createDeferred(); - apiMocks.restoreSession.mockResolvedValueOnce(undefined); - apiMocks.loadSessionTurns.mockReturnValueOnce(turns.promise); + apiMocks.restoreSessionView.mockImplementationOnce(async () => ({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 0, + createdAt: 1, + }, + turns: await turns.promise, + contextRestoreState: 'pending', + })); flowChatStore.setState(() => ({ sessions: new Map([ ['history-1', createSession({ @@ -347,7 +437,7 @@ describe('FlowChatStore historical session hydration state', () => { }); it('marks historical sessions failed when hydrate fails', async () => { - apiMocks.restoreSession.mockResolvedValueOnce(undefined); + apiMocks.restoreSessionView.mockRejectedValueOnce(new Error('restore failed')); apiMocks.loadSessionTurns.mockRejectedValueOnce(new Error('turn load failed')); flowChatStore.setState(() => ({ sessions: new Map([ @@ -364,6 +454,7 @@ describe('FlowChatStore historical session hydration state', () => { flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun') ).rejects.toThrow('turn load failed'); + expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ isHistorical: true, historyState: 'failed', @@ -371,8 +462,18 @@ describe('FlowChatStore historical session hydration state', () => { }); it('does not change the active session when an older hydrate completes', async () => { - apiMocks.restoreSession.mockResolvedValueOnce(undefined); - apiMocks.loadSessionTurns.mockResolvedValueOnce([]); + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 0, + createdAt: 1, + }, + turns: [], + contextRestoreState: 'pending', + }); flowChatStore.setState(() => ({ sessions: new Map([ ['history-1', createSession({ @@ -416,5 +517,344 @@ describe('FlowChatStore historical session hydration state', () => { await flowChatStore.loadSessionHistory('acp-1', 'D:/workspace/BitFun'); expect(apiMocks.restoreSession).not.toHaveBeenCalled(); + expect(apiMocks.restoreSessionView).not.toHaveBeenCalled(); + expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); + }); + + it('uses view-restored turns without reading the turn files a second time', async () => { + const visibleOutput = 'complete visible output '.repeat(64); + const restoredTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'hello', timestamp: 1 }, + modelRounds: [ + { + id: 'round-1', + turnId: 'turn-1', + roundIndex: 0, + timestamp: 1, + textItems: [], + toolItems: [ + { + id: 'tool-1', + toolName: 'Bash', + toolCall: { id: 'call-1', input: { command: 'printf output' } }, + toolResult: { + result: { + stdout: visibleOutput, + nested: { stderr: 'also visible' }, + }, + success: true, + durationMs: 1, + }, + startTime: 1, + endTime: 2, + durationMs: 1, + status: 'completed', + }, + ], + thinkingItems: [], + startTime: 1, + endTime: 2, + durationMs: 1, + status: 'completed', + }, + ], + startTime: 1, + status: 'completed', + }; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn], + contextRestoreState: 'pending', + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + }); + const toolItem = flowChatStore + .getState() + .sessions.get('history-1') + ?.dialogTurns[0] + ?.modelRounds[0] + ?.items.find(item => item.type === 'tool') as any; + expect(toolItem?.toolResult?.result?.stdout).toBe(visibleOutput); + expect(toolItem?.toolResult?.result?.nested?.stderr).toBe('also visible'); + expect(toolItem?.toolResult?.resultForAssistant).toBeUndefined(); + }); + + it('falls back to restoreSessionWithTurns when view restore is unavailable', async () => { + (apiMocks as any).restoreSessionView = undefined; + const restoredTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }; + apiMocks.restoreSessionWithTurns.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn], + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionWithTurns).toHaveBeenCalledTimes(1); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + }); + }); + + it('falls back to restoreSessionWithTurns when the view restore command is unavailable on the backend', async () => { + const restoredTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }; + apiMocks.restoreSessionView.mockRejectedValueOnce( + new Error('unknown command restore_session_view') + ); + apiMocks.restoreSessionWithTurns.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn], + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + expect(apiMocks.restoreSessionWithTurns).toHaveBeenCalledTimes(1); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + }); + }); + + it('does not retry an unsupported view restore command for later sessions in the same runtime', async () => { + const restoredTurn = (sessionId: string) => ({ + turnId: `${sessionId}-turn-1`, + turnIndex: 0, + sessionId, + timestamp: 1, + userMessage: { id: `${sessionId}-user-1`, content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }); + apiMocks.restoreSessionView.mockRejectedValueOnce( + new Error('unknown command restore_session_view') + ); + apiMocks.restoreSessionWithTurns + .mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn('history-1')], + }) + .mockResolvedValueOnce({ + session: { + sessionId: 'history-2', + sessionName: 'History 2', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn('history-2')], + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ['history-2', createSession({ + sessionId: 'history-2', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + await flowChatStore.loadSessionHistory('history-2', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + expect(apiMocks.restoreSessionWithTurns).toHaveBeenCalledTimes(2); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + }); + + it('falls back to legacy restore and turn loading when restoreSessionWithTurns is unavailable on the backend', async () => { + (apiMocks as any).restoreSessionView = undefined; + const restoredTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }; + apiMocks.restoreSessionWithTurns.mockRejectedValueOnce( + new Error('unknown command restore_session_with_turns') + ); + apiMocks.restoreSession.mockResolvedValueOnce({ + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }); + apiMocks.loadSessionTurns.mockResolvedValueOnce([restoredTurn]); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionWithTurns).toHaveBeenCalledTimes(1); + expect(apiMocks.restoreSession).toHaveBeenCalledTimes(1); + expect(apiMocks.loadSessionTurns).toHaveBeenCalledTimes(1); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + dialogTurns: expect.arrayContaining([ + expect.objectContaining({ id: 'turn-1' }), + ]), + }); + }); + + it('uses view restore when available and marks backend context pending', async () => { + const restoredTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn], + contextRestoreState: 'pending', + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + }); }); }); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index e10a15728e..f3d214a9ef 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -14,13 +14,18 @@ import { ImageAnalysisResult, AnyFlowItem, SessionConfig, + SessionContextRestoreState, SessionHistoryState, } from '../types/flow-chat'; import { createLogger } from '@/shared/utils/logger'; -import { isRemoteTraceContext, startupTrace } from '@/shared/utils/startupTrace'; +import { + isRemoteTraceContext, + markPhaseAfterAnimationFrames, + startupTrace, +} from '@/shared/utils/startupTrace'; import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { i18nService } from '@/infrastructure/i18n/core/I18nService'; -import type { LocalCommandMetadata, SessionKind } from '@/shared/types/session-history'; +import type { DialogTurnData, LocalCommandMetadata, SessionKind } from '@/shared/types/session-history'; import { deriveLastFinishedAtFromMetadata, deriveSessionRelationshipFromMetadata, @@ -48,6 +53,41 @@ import { sessionMatchesWorkspace } from '../utils/workspaceScope'; const log = createLogger('FlowChatStore'); const VALID_AGENT_TYPES = new Set(['agentic', 'debug', 'Plan', 'Cowork', 'Claw', 'Team', 'DeepResearch']); +const METADATA_LIST_RECENT_DEDUPE_TTL_MS = 1000; + +interface MetadataListRequest { + promise: Promise; + completedAtMs?: number; + cleanupTimer?: ReturnType; +} + +function isUnsupportedTauriCommandError(error: unknown, command: string): boolean { + const anyError = error as any; + const originalError = anyError?.context?.originalError; + const messageParts = [ + anyError?.message, + typeof originalError === 'string' ? originalError : originalError?.message, + ].filter((part): part is string => typeof part === 'string'); + const normalizedMessage = messageParts.join(' ').toLowerCase(); + const normalizedCommand = command.toLowerCase(); + const contextCommand = + typeof anyError?.context?.command === 'string' + ? anyError.context.command.toLowerCase() + : ''; + const mentionsCommand = + contextCommand === normalizedCommand || + normalizedMessage.includes(normalizedCommand); + + if (!mentionsCommand) { + return false; + } + + return normalizedMessage.includes('unknown command') || + normalizedMessage.includes('command not found') || + (normalizedMessage.includes('command') && normalizedMessage.includes('not found')) || + normalizedMessage.includes('not registered') || + normalizedMessage.includes('is not a function'); +} function isValidPersistedAgentType(agentType: string): boolean { return VALID_AGENT_TYPES.has(agentType) || agentType.startsWith('acp:'); @@ -58,6 +98,8 @@ export class FlowChatStore { private state: FlowChatState; private listeners: Set<(state: FlowChatState) => void> = new Set(); private silentMode = false; + private metadataListRequests = new Map(); + private unsupportedRestoreCommands = new Set(); private onPersistUnreadCompletion?: (sessionId: string, value: 'completed' | 'error' | 'interrupted' | undefined) => void; private constructor() { @@ -104,6 +146,18 @@ export class FlowChatStore { return this.state; } + private getMetadataListRequestKey( + workspacePath: string, + remoteConnectionId?: string, + remoteSshHost?: string, + ): string { + return JSON.stringify([ + workspacePath, + remoteConnectionId || '', + remoteSshHost || '', + ]); + } + public setState(updater: (prevState: FlowChatState) => FlowChatState): void { const newState = updater(this.state); this.state = newState; @@ -1734,17 +1788,93 @@ export class FlowChatStore { public async initializeFromDisk( workspacePath: string, remoteConnectionId?: string, - remoteSshHost?: string + remoteSshHost?: string, + traceSource = 'unknown' ): Promise { + const requestKey = this.getMetadataListRequestKey(workspacePath, remoteConnectionId, remoteSshHost); + const existingRequest = this.metadataListRequests.get(requestKey); + const remote = isRemoteTraceContext(remoteConnectionId, remoteSshHost); + if (existingRequest) { + const completedAtMs = existingRequest.completedAtMs; + const isRecentCompletedRequest = + completedAtMs !== undefined && + elapsedMs(completedAtMs) <= METADATA_LIST_RECENT_DEDUPE_TTL_MS; + + if (completedAtMs === undefined || isRecentCompletedRequest) { + startupTrace.markPhase('session_metadata_list_deduped', { + remote, + source: traceSource, + dedupeState: completedAtMs === undefined ? 'in-flight' : 'recent', + }); + return existingRequest.promise; + } + + if (existingRequest.cleanupTimer) { + clearTimeout(existingRequest.cleanupTimer); + } + this.metadataListRequests.delete(requestKey); + } + + let succeeded = false; + const loadPromise = this.initializeFromDiskUncached( + workspacePath, + remoteConnectionId, + remoteSshHost, + traceSource, + ).then(result => { + succeeded = result; + }); + + const request: MetadataListRequest = { promise: loadPromise }; + this.metadataListRequests.set(requestKey, request); + + loadPromise.finally(() => { + const currentRequest = this.metadataListRequests.get(requestKey); + if (currentRequest !== request) { + return; + } + + if (!succeeded) { + this.metadataListRequests.delete(requestKey); + return; + } + + request.completedAtMs = nowMs(); + request.cleanupTimer = setTimeout(() => { + if (this.metadataListRequests.get(requestKey) === request) { + this.metadataListRequests.delete(requestKey); + } + }, METADATA_LIST_RECENT_DEDUPE_TTL_MS); + }); + + return loadPromise; + } + + private async initializeFromDiskUncached( + workspacePath: string, + remoteConnectionId?: string, + remoteSshHost?: string, + traceSource = 'unknown' + ): Promise { const traceStartedAt = nowMs(); const remote = isRemoteTraceContext(remoteConnectionId, remoteSshHost); + const metadataListTraceId = `metadata-${Math.random().toString(36).slice(2, 8)}`; let sessionCount = 0; - startupTrace.markPhase('session_metadata_list_start', { remote }); + startupTrace.markPhase('session_metadata_list_start', { + remote, + source: traceSource, + metadataListTraceId, + }); try { const { sessionAPI } = await import('@/infrastructure/api'); const sessions = await sessionAPI.listSessions(workspacePath, remoteConnectionId, remoteSshHost); sessionCount = sessions.length; - startupTrace.markPhase('session_metadata_list_loaded', { remote, sessionCount }); + startupTrace.markPhase('session_metadata_list_loaded', { + remote, + source: traceSource, + metadataListTraceId, + sessionCount, + }); const { stateMachineManager } = await import('../state-machine'); @@ -1874,16 +2004,22 @@ export class FlowChatStore { await Promise.all(sessions.map(processSession)); startupTrace.markPhase('session_metadata_list_end', { remote, + source: traceSource, + metadataListTraceId, sessionCount, durationMs: elapsedMs(traceStartedAt), }); + return true; } catch (error) { startupTrace.markPhase('session_metadata_list_failed', { remote, + source: traceSource, + metadataListTraceId, sessionCount, durationMs: elapsedMs(traceStartedAt), }); log.error('Failed to load persisted sessions', error); + return false; } } @@ -1907,6 +2043,29 @@ export class FlowChatStore { }); } + public setSessionContextRestoreState( + sessionId: string, + contextRestoreState: SessionContextRestoreState + ): void { + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if (!session || session.contextRestoreState === contextRestoreState) { + return prev; + } + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, { + ...session, + contextRestoreState, + }); + + return { + ...prev, + sessions: newSessions, + }; + }); + } + /** * Lazy load session history (convert historical data to FlowChat format) */ @@ -1919,7 +2078,8 @@ export class FlowChatStore { ): Promise { const traceStartedAt = nowMs(); const remote = isRemoteTraceContext(remoteConnectionId, remoteSshHost); - startupTrace.markPhase('historical_session_hydrate_start', { remote }); + const sessionTraceId = `${sessionId.slice(0, 8)}-${Math.random().toString(36).slice(2, 8)}`; + startupTrace.markPhase('historical_session_hydrate_start', { remote, sessionTraceId }); this.setSessionHistoryState(sessionId, 'hydrating'); try { const { stateMachineManager } = await import('../state-machine'); @@ -1928,30 +2088,138 @@ export class FlowChatStore { const existingSession = this.state.sessions.get(sessionId); const isAcpSession = existingSession?.mode?.startsWith('acp:') || existingSession?.config.agentType?.startsWith('acp:'); + let turns: DialogTurnData[] | undefined; + let contextRestoreState: SessionContextRestoreState = 'ready'; if (!isAcpSession) { + const restoreStartedAt = nowMs(); + startupTrace.markPhase('historical_session_restore_start', { remote, sessionTraceId }); try { const { agentAPI } = await import('@/infrastructure/api'); - await agentAPI.restoreSession(sessionId, workspacePath, remoteConnectionId, remoteSshHost); + const restoreWithTurnsOrSession = async (): Promise => { + if ( + typeof agentAPI.restoreSessionWithTurns === 'function' && + !this.unsupportedRestoreCommands.has('restore_session_with_turns') + ) { + try { + const restored = await agentAPI.restoreSessionWithTurns( + sessionId, + workspacePath, + remoteConnectionId, + remoteSshHost, + sessionTraceId + ); + turns = restored.turns; + contextRestoreState = 'ready'; + return; + } catch (error) { + if (!isUnsupportedTauriCommandError(error, 'restore_session_with_turns')) { + throw error; + } + this.unsupportedRestoreCommands.add('restore_session_with_turns'); + startupTrace.markPhase('historical_session_restore_fallback', { + remote, + sessionTraceId, + from: 'restore_session_with_turns', + to: 'restore_session', + reason: 'unsupported-command', + }); + } + } + + await agentAPI.restoreSession( + sessionId, + workspacePath, + remoteConnectionId, + remoteSshHost, + sessionTraceId + ); + contextRestoreState = 'ready'; + }; + + if ( + typeof agentAPI.restoreSessionView === 'function' && + !this.unsupportedRestoreCommands.has('restore_session_view') + ) { + try { + const restored = await agentAPI.restoreSessionView( + sessionId, + workspacePath, + remoteConnectionId, + remoteSshHost, + sessionTraceId + ); + turns = restored.turns; + contextRestoreState = + restored.contextRestoreState === 'ready' ? 'ready' : 'pending'; + } catch (error) { + if (!isUnsupportedTauriCommandError(error, 'restore_session_view')) { + throw error; + } + this.unsupportedRestoreCommands.add('restore_session_view'); + startupTrace.markPhase('historical_session_restore_fallback', { + remote, + sessionTraceId, + from: 'restore_session_view', + to: 'restore_session_with_turns', + reason: 'unsupported-command', + }); + await restoreWithTurnsOrSession(); + } + } else { + await restoreWithTurnsOrSession(); + } + startupTrace.markPhase('historical_session_restore_end', { + remote, + sessionTraceId, + turnCount: Array.isArray(turns) ? turns.length : 0, + contextRestoreState, + durationMs: elapsedMs(restoreStartedAt), + }); } catch (error) { + contextRestoreState = 'pending'; + startupTrace.markPhase('historical_session_restore_failed', { + remote, + sessionTraceId, + durationMs: elapsedMs(restoreStartedAt), + }); log.warn('Backend session restore failed (may be new session)', { sessionId, error }); } } - const { sessionAPI } = await import('@/infrastructure/api'); - const turns = await sessionAPI.loadSessionTurns( - sessionId, - workspacePath, - limit, - remoteConnectionId, - remoteSshHost - ); + if (!turns) { + const turnsLoadStartedAt = nowMs(); + startupTrace.markPhase('historical_session_turns_load_start', { remote, sessionTraceId }); + const { sessionAPI } = await import('@/infrastructure/api'); + turns = await sessionAPI.loadSessionTurns( + sessionId, + workspacePath, + limit, + remoteConnectionId, + remoteSshHost + ); + startupTrace.markPhase('historical_session_turns_load_end', { + remote, + sessionTraceId, + turnCount: Array.isArray(turns) ? turns.length : 0, + durationMs: elapsedMs(turnsLoadStartedAt), + }); + } startupTrace.markPhase('historical_session_turns_loaded', { remote, + sessionTraceId, turnCount: Array.isArray(turns) ? turns.length : 0, }); + const convertStartedAt = nowMs(); const dialogTurns = this.convertToDialogTurns(turns); + startupTrace.markPhase('historical_session_convert_end', { + remote, + sessionTraceId, + turnCount: dialogTurns.length, + durationMs: elapsedMs(convertStartedAt), + }); + const stateCommitStartedAt = nowMs(); this.setState(prev => { const session = prev.sessions.get(sessionId); if (!session) return prev; @@ -1961,6 +2229,7 @@ export class FlowChatStore { dialogTurns, isHistorical: false, historyState: 'ready' as const, + contextRestoreState, error: null, }; @@ -1972,12 +2241,26 @@ export class FlowChatStore { sessions: newSessions, }; }); + startupTrace.markPhase('historical_session_state_commit_end', { + remote, + sessionTraceId, + turnCount: dialogTurns.length, + durationMs: elapsedMs(stateCommitStartedAt), + }); + markPhaseAfterAnimationFrames(startupTrace, 'historical_session_after_state_commit_frame', { + remote, + sessionTraceId, + turnCount: dialogTurns.length, + }, { + frameCount: 2, + }); // Reset state machine to IDLE after loading history // This handles the case where restoreSession triggered events that left the state machine in PROCESSING stateMachineManager.reset(sessionId); startupTrace.markPhase('historical_session_hydrate_end', { remote, + sessionTraceId, turnCount: dialogTurns.length, durationMs: elapsedMs(traceStartedAt), }); @@ -2000,6 +2283,7 @@ export class FlowChatStore { }); startupTrace.markPhase('historical_session_hydrate_failed', { remote, + sessionTraceId, durationMs: elapsedMs(traceStartedAt), }); log.error('Failed to load session history', { sessionId, error }); diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 5732899fbd..d1235585d4 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -239,6 +239,11 @@ export type SessionHistoryState = | 'ready' | 'failed'; +export type SessionContextRestoreState = + | 'ready' + | 'pending' + | 'failed'; + // Session state. export interface Session { sessionId: string; @@ -280,6 +285,13 @@ export interface Session { * - 'failed': hydrate failed and the UI should offer retry instead of showing a new session. */ historyState?: SessionHistoryState; + + /** + * Backend runtime-context lifecycle for sessions restored through the fast + * history view path. Turns may be visible while model context is still loaded + * lazily; message sending must ensure this becomes 'ready' first. + */ + contextRestoreState?: SessionContextRestoreState; todos?: TodoItem[]; diff --git a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts new file mode 100644 index 0000000000..bc7b0af8e8 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { isExpectedTauriRequestError } from './tauri-adapter'; + +describe('Tauri adapter expected errors', () => { + it('classifies optional get_config not found as expected', () => { + expect(isExpectedTauriRequestError( + 'get_config', + { + request: { + path: 'font', + skipRetryOnNotFound: true, + }, + }, + new Error("Config path not found: 'font'") + )).toBe(true); + }); + + it('does not hide non-optional get_config failures', () => { + expect(isExpectedTauriRequestError( + 'get_config', + { + request: { + path: 'font', + }, + }, + new Error("Config path not found: 'font'") + )).toBe(false); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts index 73c63a9c43..92ecfbdede 100644 --- a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts @@ -7,6 +7,25 @@ import { sanitizeErrorForLog } from '../logSanitizer'; const log = createLogger('TauriAdapter'); +export function isExpectedTauriRequestError(action: string, params: unknown, error: unknown): boolean { + if (action !== 'get_config') { + return false; + } + + const request = (params as { request?: unknown } | undefined)?.request; + if (!request || typeof request !== 'object') { + return false; + } + + if (!(request as Record).skipRetryOnNotFound) { + return false; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + const normalized = errorMessage.toLowerCase(); + return normalized.includes('not found') && normalized.includes('config path'); +} + export class TauriTransportAdapter implements ITransportAdapter { private unlistenFunctions: UnlistenFn[] = []; private connected: boolean = false; @@ -69,7 +88,9 @@ export class TauriTransportAdapter implements ITransportAdapter { return result as T; } catch (error) { - log.error('Request failed', { action, error: sanitizeErrorForLog(error) }); + if (!isExpectedTauriRequestError(action, params, error)) { + log.error('Request failed', { action, error: sanitizeErrorForLog(error) }); + } throw error; } } diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index ed318f9887..dd40b7f0b6 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -2,6 +2,7 @@ import { api } from './ApiClient'; import { createTauriCommandError } from '../errors/TauriCommandError'; +import type { DialogTurnData } from '@/shared/types/session-history'; import type { ImageContextData as ImageInputContextData } from './ImageContextTypes'; @@ -83,6 +84,17 @@ export interface SessionInfo { createdAt: number; } +export interface RestoreSessionWithTurnsResponse { + session: SessionInfo; + turns: DialogTurnData[]; +} + +export interface RestoreSessionViewResponse { + session: SessionInfo; + turns: DialogTurnData[]; + contextRestoreState: 'ready' | 'pending'; +} + export interface EnsureAssistantBootstrapRequest { sessionId: string; workspacePath: string; @@ -359,17 +371,50 @@ export class AgentAPI { sessionId: string, workspacePath: string, remoteConnectionId?: string, - remoteSshHost?: string + remoteSshHost?: string, + traceId?: string ): Promise { try { return await api.invoke('restore_session', { - request: { sessionId, workspacePath, remoteConnectionId, remoteSshHost }, + request: { sessionId, workspacePath, remoteConnectionId, remoteSshHost, traceId }, }); } catch (error) { throw createTauriCommandError('restore_session', error, { sessionId, workspacePath }); } } + async restoreSessionWithTurns( + sessionId: string, + workspacePath: string, + remoteConnectionId?: string, + remoteSshHost?: string, + traceId?: string + ): Promise { + try { + return await api.invoke('restore_session_with_turns', { + request: { sessionId, workspacePath, remoteConnectionId, remoteSshHost, traceId }, + }); + } catch (error) { + throw createTauriCommandError('restore_session_with_turns', error, { sessionId, workspacePath }); + } + } + + async restoreSessionView( + sessionId: string, + workspacePath: string, + remoteConnectionId?: string, + remoteSshHost?: string, + traceId?: string + ): Promise { + try { + return await api.invoke('restore_session_view', { + request: { sessionId, workspacePath, remoteConnectionId, remoteSshHost, traceId }, + }); + } catch (error) { + throw createTauriCommandError('restore_session_view', error, { sessionId, workspacePath }); + } + } + /** * No-op if the session is already in the coordinator; otherwise loads it from disk * using the same workspace path resolution as restore_session (required for SSH remote workspaces). diff --git a/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts b/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts new file mode 100644 index 0000000000..f7599fcf8f --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiClient } from './ApiClient'; + +const adapterMocks = vi.hoisted(() => ({ + request: vi.fn(), + listen: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn(() => true), +})); + +const traceMocks = vi.hoisted(() => ({ + estimateJsonBytes: vi.fn(() => 1), + recordApiCall: vi.fn(), +})); + +const loggerMocks = vi.hoisted(() => ({ + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../adapters', () => ({ + getTransportAdapter: () => adapterMocks, +})); + +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => loggerMocks, +})); + +vi.mock('@/shared/utils/startupTrace', () => ({ + estimateJsonBytes: traceMocks.estimateJsonBytes, + isRemoteTraceRequest: vi.fn(() => false), + startupTrace: traceMocks, +})); + +describe('ApiClient startup trace classification', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not record optional get_config not found as a startup failure', async () => { + adapterMocks.request.mockRejectedValueOnce(new Error("Config path not found: 'font'")); + const client = new ApiClient({ enableLogging: true, retries: 0 }); + + await expect( + client.invoke('get_config', { + request: { + path: 'font', + skipRetryOnNotFound: true, + }, + }) + ).rejects.toThrow(); + + expect(traceMocks.recordApiCall).toHaveBeenCalledWith(expect.objectContaining({ + command: 'get_config', + outcome: 'success', + })); + expect(client.getStats()).toMatchObject({ + successfulRequests: 1, + failedRequests: 0, + }); + expect(loggerMocks.error).not.toHaveBeenCalled(); + }); + + it('uses the large response estimate cap for session view restore', async () => { + adapterMocks.request.mockResolvedValueOnce({ turns: [] }); + const client = new ApiClient({ enableLogging: false, retries: 0 }); + + await client.invoke('restore_session_view', { + request: { + sessionId: 'history-1', + workspacePath: 'D:/workspace/BitFun', + }, + }); + + expect(traceMocks.estimateJsonBytes).toHaveBeenCalledWith( + { turns: [] }, + 32 * 1024 * 1024 + ); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts index 8c2e0fe4c0..7f707fe8ac 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts @@ -20,6 +20,42 @@ import { sanitizeErrorForLog, sanitizeLogValue, sanitizeTextForLog } from '../lo const log = createLogger('ApiClient'); const sanitizeForLog = sanitizeLogValue; +const LARGE_SESSION_RESPONSE_ESTIMATE_MAX_BYTES = 32 * 1024 * 1024; + +function responseEstimateMaxBytes(command: string): number | undefined { + return command === 'restore_session_view' || + command === 'restore_session_with_turns' || + command === 'load_session_turns' + ? LARGE_SESSION_RESPONSE_ESTIMATE_MAX_BYTES + : undefined; +} + +function isOptionalConfigNotFoundCommand(config: TauriCommandConfig, error: unknown): boolean { + if (config.command !== 'get_config') { + return false; + } + + const requestPayload = config.args?.request; + if (!requestPayload || typeof requestPayload !== 'object') { + return false; + } + + if (!(requestPayload as Record).skipRetryOnNotFound) { + return false; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + const normalized = errorMessage.toLowerCase(); + return normalized.includes('not found') && normalized.includes('config path'); +} + +function isOptionalConfigNotFound(request: ApiRequest, error: unknown): boolean { + if (request.type !== 'tauri') { + return false; + } + + return isOptionalConfigNotFoundCommand(request.config as TauriCommandConfig, error); +} export class ApiClient implements IApiClient { private config: ApiConfig; @@ -172,7 +208,10 @@ export class ApiClient implements IApiClient { const durationMs = elapsedMs(startedAt); const responseEstimateStartedAt = nowMs(); - const responseBytes = estimateJsonBytes(response.data); + const responseBytes = estimateJsonBytes( + response.data, + responseEstimateMaxBytes(traceCommand) + ); const responseEstimateDurationMs = elapsedMs(responseEstimateStartedAt); startupTrace.recordApiCall({ type: request.type, @@ -202,19 +241,22 @@ export class ApiClient implements IApiClient { this.activeRequests.delete(request.id); } } catch (error) { - this.updateStats({ failedRequests: this.stats.failedRequests + 1 }); + const optionalConfigNotFound = isOptionalConfigNotFound(request, error); + this.updateStats(optionalConfigNotFound + ? { successfulRequests: this.stats.successfulRequests + 1 } + : { failedRequests: this.stats.failedRequests + 1 }); startupTrace.recordApiCall({ type: request.type, command: traceCommand, durationMs: elapsedMs(startedAt), - outcome: 'failure', + outcome: optionalConfigNotFound ? 'success' : 'failure', requestBytes, payloadEstimateDurationMs: requestPayloadEstimateDurationMs, remote, }); - if (request.retryCount < (request.config.retries || this.config.retries)) { + if (!optionalConfigNotFound && request.retryCount < (request.config.retries || this.config.retries)) { const delay = (request.config.retryDelay || this.config.retryDelay) * Math.pow(2, request.retryCount); @@ -233,7 +275,7 @@ export class ApiClient implements IApiClient { } - if (this.config.enableLogging) { + if (this.config.enableLogging && !optionalConfigNotFound) { log.error('Request failed after retries', { requestId: request.id, retryCount: request.retryCount, @@ -264,20 +306,23 @@ export class ApiClient implements IApiClient { const isExpectedError = errorMessage.includes('not found') || errorMessage.includes('Config path') || errorMessage.includes('Configuration error'); + const optionalConfigNotFound = isOptionalConfigNotFoundCommand(config, error); - if (isExpectedError && this.config.enableLogging) { - log.debug('Command returned expected result', { - command: config.command, - message: sanitizeTextForLog(errorMessage) - }); - } else { - log.error('Command failed', { - command: config.command, - args: sanitizeForLog(config.args), - error: sanitizeTextForLog(errorMessage), - rawError: sanitizeErrorForLog(error) - }); + if (this.config.enableLogging) { + if (isExpectedError && !optionalConfigNotFound) { + log.debug('Command returned expected result', { + command: config.command, + message: sanitizeTextForLog(errorMessage) + }); + } else if (!isExpectedError) { + log.error('Command failed', { + command: config.command, + args: sanitizeForLog(config.args), + error: sanitizeTextForLog(errorMessage), + rawError: sanitizeErrorForLog(error) + }); + } } throw this.createApiError('COMMAND_FAILED', errorMessage, error); diff --git a/src/web-ui/src/infrastructure/api/service-api/GitAPI.ts b/src/web-ui/src/infrastructure/api/service-api/GitAPI.ts index fd5591faec..ea9218a98a 100644 --- a/src/web-ui/src/infrastructure/api/service-api/GitAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/GitAPI.ts @@ -200,6 +200,17 @@ export class GitAPI { } } + + async getRepositoryBasic(repositoryPath: string): Promise { + try { + return await api.invoke('git_get_repository_basic', { + request: { repositoryPath } + }); + } catch (error) { + throw createTauriCommandError('git_get_repository_basic', error, { repositoryPath }); + } + } + async getStatus(repositoryPath: string): Promise { try { diff --git a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.test.ts new file mode 100644 index 0000000000..7f9a0fd6dc --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SnapshotAPI } from './SnapshotAPI'; + +const invokeMock = vi.hoisted(() => vi.fn()); + +vi.mock('./ApiClient', () => ({ + api: { + invoke: invokeMock, + }, +})); + +describe('SnapshotAPI request dedupe', () => { + let snapshotAPI: SnapshotAPI; + + beforeEach(() => { + snapshotAPI = new SnapshotAPI(); + invokeMock.mockReset(); + }); + + it('deduplicates concurrent session stats requests for the same session and workspace', async () => { + const stats = { + session_id: 'session-1', + total_files: 2, + total_turns: 3, + total_changes: 4, + }; + invokeMock.mockResolvedValueOnce(stats); + + const first = snapshotAPI.getSessionStats('session-1', 'D:/workspace/BitFun'); + const second = snapshotAPI.getSessionStats('session-1', 'D:/workspace/BitFun'); + + await expect(Promise.all([first, second])).resolves.toEqual([stats, stats]); + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith('get_session_stats', { + request: { + session_id: 'session-1', + workspacePath: 'D:/workspace/BitFun', + }, + }); + }); + + it('allows a new session stats request after the in-flight request settles', async () => { + invokeMock + .mockResolvedValueOnce({ + session_id: 'session-1', + total_files: 1, + total_turns: 1, + total_changes: 1, + }) + .mockResolvedValueOnce({ + session_id: 'session-1', + total_files: 2, + total_turns: 2, + total_changes: 2, + }); + + await snapshotAPI.getSessionStats('session-1', 'D:/workspace/BitFun'); + await snapshotAPI.getSessionStats('session-1', 'D:/workspace/BitFun'); + + expect(invokeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts index d5903a575d..1c772884d9 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts @@ -137,6 +137,23 @@ export interface CleanupSandboxDataRequest { } export class SnapshotAPI { + private readonly inFlightRequests = new Map>(); + + private dedupeInFlight(key: string, load: () => Promise): Promise { + const existing = this.inFlightRequests.get(key) as Promise | undefined; + if (existing) { + return existing; + } + + const request = load().finally(() => { + if (this.inFlightRequests.get(key) === request) { + this.inFlightRequests.delete(key); + } + }); + this.inFlightRequests.set(key, request); + return request; + } + async getSessionStats(sessionId: string, workspacePath?: string): Promise<{ session_id: string; @@ -146,9 +163,10 @@ export class SnapshotAPI { }> { try { const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); - return await api.invoke('get_session_stats', { - request: { session_id: sessionId, workspacePath: resolvedWorkspacePath } - }); + const key = `get_session_stats:${resolvedWorkspacePath}:${sessionId}`; + return await this.dedupeInFlight(key, () => api.invoke('get_session_stats', { + request: { session_id: sessionId, workspacePath: resolvedWorkspacePath } + })); } catch (error) { throw createTauriCommandError('get_session_stats', error, { sessionId, workspacePath }); } @@ -158,9 +176,10 @@ export class SnapshotAPI { async getSessionFiles(sessionId: string, workspacePath?: string): Promise { try { const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); - return await api.invoke('get_session_files', { - request: { session_id: sessionId, workspacePath: resolvedWorkspacePath } - }); + const key = `get_session_files:${resolvedWorkspacePath}:${sessionId}`; + return await this.dedupeInFlight(key, () => api.invoke('get_session_files', { + request: { session_id: sessionId, workspacePath: resolvedWorkspacePath } + })); } catch (error) { throw createTauriCommandError('get_session_files', error, { sessionId, workspacePath }); } @@ -195,9 +214,10 @@ export class SnapshotAPI { ): Promise { try { const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); - return await api.invoke('get_session_file_diff_stats', { + const key = `get_session_file_diff_stats:${resolvedWorkspacePath}:${sessionId}:${filePath}`; + return await this.dedupeInFlight(key, () => api.invoke('get_session_file_diff_stats', { request: { sessionId, filePath, workspacePath: resolvedWorkspacePath }, - }); + })); } catch (error) { throw createTauriCommandError('get_session_file_diff_stats', error, { sessionId, @@ -214,9 +234,10 @@ export class SnapshotAPI { ): Promise { try { const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); - return await api.invoke('get_operation_summary', { + const key = `get_operation_summary:${resolvedWorkspacePath}:${sessionId}:${operationId}`; + return await this.dedupeInFlight(key, () => api.invoke('get_operation_summary', { request: { sessionId, operationId, workspacePath: resolvedWorkspacePath } - }); + })); } catch (error) { throw createTauriCommandError('get_operation_summary', error, { sessionId, diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts index e2cef02bc8..942d9892f4 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts @@ -15,20 +15,33 @@ export async function syncAgentCompanionDesktopWindow( if (!isTauriRuntime()) return; const run = async (): Promise => { + const startedAt = performance.now(); const command = settings.enable_agent_companion && settings.agent_companion_display_mode === 'desktop' ? 'show_agent_companion_desktop_pet' : 'hide_agent_companion_desktop_pet'; try { + log.debug('Agent companion desktop window sync started', { + command, + displayMode: settings.agent_companion_display_mode, + }); const { invoke } = await import('@tauri-apps/api/core'); await invoke(command); if (command === 'show_agent_companion_desktop_pet') { const { emit } = await import('@tauri-apps/api/event'); await emit('agent-companion://settings-updated', settings); } + log.debug('Agent companion desktop window sync completed', { + command, + durationMs: Number((performance.now() - startedAt).toFixed(1)), + }); } catch (error) { - log.error('Failed to sync Agent companion desktop window', { command, error }); + log.error('Failed to sync Agent companion desktop window', { + command, + durationMs: Number((performance.now() - startedAt).toFixed(1)), + error, + }); } }; diff --git a/src/web-ui/src/shared/utils/startupTrace.test.ts b/src/web-ui/src/shared/utils/startupTrace.test.ts index a35cbd5c3f..874d6c20e9 100644 --- a/src/web-ui/src/shared/utils/startupTrace.test.ts +++ b/src/web-ui/src/shared/utils/startupTrace.test.ts @@ -4,6 +4,7 @@ import { estimateJsonBytes, isRemoteTraceContext, isRemoteTraceRequest, + markPhaseAfterAnimationFrames, } from './startupTrace'; import type { LoggerLike } from './timing'; @@ -222,6 +223,50 @@ describe('startupTrace', () => { expect(logger.info).not.toHaveBeenCalled(); }); + it('marks deferred phases only after the requested animation frames', () => { + const logger = createTestLogger(); + let now = 100; + const callbacks: Array<(time: number) => void> = []; + const trace = createStartupTrace({ + logger, + traceId: 'trace-test', + now: () => now, + }); + + markPhaseAfterAnimationFrames(trace, 'historical_session_first_paint', { + sessionTraceId: 'session-trace', + remote: false, + }, { + frameCount: 2, + now: () => now, + requestAnimationFrame: callback => { + callbacks.push(callback); + return callbacks.length; + }, + }); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(callbacks).toHaveLength(1); + + now = 116; + callbacks.shift()?.(now); + expect(logger.debug).not.toHaveBeenCalled(); + expect(callbacks).toHaveLength(1); + + now = 132; + callbacks.shift()?.(now); + + expect(logger.debug).toHaveBeenCalledTimes(1); + const [, payload] = logger.debug.mock.calls[0]; + expect(payload).toMatchObject({ + traceId: 'trace-test', + phase: 'historical_session_first_paint', + sessionTraceId: 'session-trace', + remote: false, + durationMs: 32, + }); + }); + it('uses the desktop injected trace id when available', () => { const previousTraceId = (globalThis as { __BITFUN_STARTUP_TRACE_ID__?: string }) .__BITFUN_STARTUP_TRACE_ID__; diff --git a/src/web-ui/src/shared/utils/startupTrace.ts b/src/web-ui/src/shared/utils/startupTrace.ts index 6e3eece770..744656537e 100644 --- a/src/web-ui/src/shared/utils/startupTrace.ts +++ b/src/web-ui/src/shared/utils/startupTrace.ts @@ -26,6 +26,12 @@ export interface StartupTraceOptions { maxPhaseEvents?: number; } +export interface DeferredAnimationFrameTraceOptions { + frameCount?: number; + now?: NowFn; + requestAnimationFrame?: (callback: (time: number) => void) => number; +} + interface CommandAggregate { command: string; count: number; @@ -368,3 +374,42 @@ export function createStartupTrace(options: StartupTraceOptions = {}): StartupTr } export const startupTrace = createStartupTrace(); + +export function markPhaseAfterAnimationFrames( + trace: StartupTrace, + phase: string, + data?: TraceData, + options: DeferredAnimationFrameTraceOptions = {} +): void { + const frameCount = Math.max(1, Math.floor(options.frameCount ?? 2)); + const now = options.now ?? (() => globalThis.performance?.now?.() ?? Date.now()); + const requestFrame = options.requestAnimationFrame ?? globalThis.requestAnimationFrame?.bind(globalThis); + const startedAt = now(); + + if (!requestFrame) { + trace.markPhase(phase, { + ...(data ?? {}), + frameCount: 0, + durationMs: 0, + }); + return; + } + + let remainingFrames = frameCount; + const scheduleNextFrame = () => { + requestFrame(() => { + remainingFrames -= 1; + if (remainingFrames <= 0) { + trace.markPhase(phase, { + ...(data ?? {}), + frameCount, + durationMs: roundDurationMs(now() - startedAt), + }); + return; + } + scheduleNextFrame(); + }); + }; + + scheduleNextFrame(); +} diff --git a/src/web-ui/src/tools/git/state/GitStateManager.test.ts b/src/web-ui/src/tools/git/state/GitStateManager.test.ts index 24c1790ea7..67276b6860 100644 --- a/src/web-ui/src/tools/git/state/GitStateManager.test.ts +++ b/src/web-ui/src/tools/git/state/GitStateManager.test.ts @@ -3,6 +3,7 @@ import { GitStateManager } from './GitStateManager'; const gitApiMocks = vi.hoisted(() => ({ isGitRepository: vi.fn(), + getRepositoryBasic: vi.fn(), getRepository: vi.fn(), getStatus: vi.fn(), getBranches: vi.fn(), @@ -60,6 +61,14 @@ describe('GitStateManager refresh performance guards', () => { manager.setCacheConfig({ basic: 0, status: 0, detailed: 0 }); gitApiMocks.isGitRepository.mockResolvedValue(true); + gitApiMocks.getRepositoryBasic.mockResolvedValue({ + path: repositoryPath, + name: 'BitFun', + current_branch: 'main', + is_bare: false, + has_changes: false, + remotes: [], + }); gitApiMocks.getRepository.mockResolvedValue({ path: repositoryPath, name: 'BitFun', @@ -97,12 +106,13 @@ describe('GitStateManager refresh performance guards', () => { await refresh; expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1); - expect(gitApiMocks.getRepository).toHaveBeenCalledTimes(1); + expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(1); + expect(gitApiMocks.getRepository).not.toHaveBeenCalled(); expect(gitApiMocks.getStatus).not.toHaveBeenCalled(); expect(manager.getState(repositoryPath)).toMatchObject({ isRepository: true, currentBranch: 'main', - hasChanges: true, + hasChanges: false, }); }); diff --git a/src/web-ui/src/tools/git/state/GitStateManager.ts b/src/web-ui/src/tools/git/state/GitStateManager.ts index 4bcb326e93..7275de98f2 100644 --- a/src/web-ui/src/tools/git/state/GitStateManager.ts +++ b/src/web-ui/src/tools/git/state/GitStateManager.ts @@ -478,11 +478,12 @@ export class GitStateManager { const shouldRefreshStatus = layersToRefresh.includes('status'); if (!shouldRefreshStatus) { - const repository = await gitAPI.getRepository(repositoryPath); + const repository = await gitAPI.getRepositoryBasic(repositoryPath); + const currentState = this.getOrCreateState(repositoryPath); this.updateState(repositoryPath, { isRepository: true, currentBranch: repository.current_branch || repository.branch || null, - hasChanges: Boolean(repository.has_changes), + hasChanges: currentState.hasChanges, }); return; } diff --git a/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.test.ts b/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.test.ts new file mode 100644 index 0000000000..ddc47c3a56 --- /dev/null +++ b/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { shouldRefreshSnapshotForSession } from './snapshotRefreshPolicy'; + +describe('snapshot refresh policy', () => { + it('defers snapshot refresh while persisted history is not ready', () => { + expect(shouldRefreshSnapshotForSession({ + isHistorical: true, + historyState: 'metadata-only', + })).toBe(false); + expect(shouldRefreshSnapshotForSession({ + isHistorical: true, + historyState: 'hydrating', + })).toBe(false); + expect(shouldRefreshSnapshotForSession({ + isHistorical: true, + historyState: 'failed', + })).toBe(false); + }); + + it('keeps snapshot refresh enabled for ready, new, and unknown sessions', () => { + expect(shouldRefreshSnapshotForSession({ + isHistorical: true, + historyState: 'ready', + })).toBe(true); + expect(shouldRefreshSnapshotForSession({ + isHistorical: false, + historyState: 'new', + })).toBe(true); + expect(shouldRefreshSnapshotForSession(null)).toBe(true); + }); +}); diff --git a/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.ts b/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.ts new file mode 100644 index 0000000000..8a64e3eebe --- /dev/null +++ b/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.ts @@ -0,0 +1,15 @@ +import type { Session } from '@/flow_chat/types/flow-chat'; + +type SnapshotRefreshSession = Pick; + +export function shouldRefreshSnapshotForSession( + session?: SnapshotRefreshSession | null +): boolean { + if (!session || !session.isHistorical) { + return true; + } + + return session.historyState === undefined || + session.historyState === 'new' || + session.historyState === 'ready'; +} diff --git a/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts b/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts index e2679244a8..06a8bce442 100644 --- a/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts +++ b/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts @@ -5,6 +5,8 @@ import { SnapshotEventBus, SNAPSHOT_EVENTS } from '../core/SnapshotEventBus'; import { DiffDisplayEngine, CompactDiffResult, FullDiffResult } from '../core/DiffDisplayEngine'; import SnapshotLazyLoader from '../core/SnapshotLazyLoader'; import { createLogger } from '@/shared/utils/logger'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { shouldRefreshSnapshotForSession } from './snapshotRefreshPolicy'; const log = createLogger('useSnapshotState'); @@ -37,6 +39,7 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => // Track the active session to avoid applying stale events after session switches. const activeSessionIdRef = useRef(sessionId); + const refreshGenerationRef = useRef(0); const stateManager = SnapshotStateManager.getInstance(); const eventBus = SnapshotEventBus.getInstance(); @@ -45,23 +48,57 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => const refreshSession = useCallback(async () => { if (!sessionId) return; + const session = flowChatStore.getState().sessions.get(sessionId); + if (!shouldRefreshSnapshotForSession(session)) { + refreshGenerationRef.current += 1; + setLoading(false); + setError(null); + setSessionState(null); + setFiles([]); + return; + } + + const refreshGeneration = refreshGenerationRef.current + 1; + refreshGenerationRef.current = refreshGeneration; + setLoading(true); setError(null); try { await SnapshotLazyLoader.ensureInitialized(); + + if ( + refreshGenerationRef.current !== refreshGeneration || + activeSessionIdRef.current !== sessionId || + !shouldRefreshSnapshotForSession(flowChatStore.getState().sessions.get(sessionId)) + ) { + return; + } await stateManager.refreshSessionState(sessionId); + + if ( + refreshGenerationRef.current !== refreshGeneration || + activeSessionIdRef.current !== sessionId || + !shouldRefreshSnapshotForSession(flowChatStore.getState().sessions.get(sessionId)) + ) { + return; + } + const newSessionState = stateManager.getSessionState(sessionId); const newFiles = stateManager.getSessionFiles(sessionId); setSessionState(newSessionState); setFiles(newFiles); } catch (err) { - log.error('Failed to refresh session state', { sessionId, error: err }); - setError(t('snapshotSystem.errors.refreshSessionFailed')); + if (refreshGenerationRef.current === refreshGeneration && activeSessionIdRef.current === sessionId) { + log.error('Failed to refresh session state', { sessionId, error: err }); + setError(t('snapshotSystem.errors.refreshSessionFailed')); + } } finally { - setLoading(false); + if (refreshGenerationRef.current === refreshGeneration && activeSessionIdRef.current === sessionId) { + setLoading(false); + } } }, [sessionId, stateManager, t]); @@ -192,6 +229,7 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => } activeSessionIdRef.current = sessionId; + refreshGenerationRef.current += 1; setFiles([]); setSessionState(null); @@ -221,11 +259,36 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => } }); - refreshSession(); + let canRefresh = shouldRefreshSnapshotForSession( + flowChatStore.getState().sessions.get(sessionId) + ); + + if (canRefresh) { + refreshSession(); + } + + const unsubscribeFlowChat = flowChatStore.subscribe((state) => { + const nextCanRefresh = shouldRefreshSnapshotForSession(state.sessions.get(sessionId)); + if (nextCanRefresh && !canRefresh) { + canRefresh = true; + refreshSession(); + return; + } + + if (!nextCanRefresh && canRefresh) { + canRefresh = false; + refreshGenerationRef.current += 1; + setLoading(false); + setFiles([]); + setSessionState(null); + } + }); return () => { + refreshGenerationRef.current += 1; unsubscribeSession(); unsubscribeFile(); + unsubscribeFlowChat(); }; }, [sessionId, stateManager, refreshSession]); From ce824a23c61dfb3edcf153ce8269e5a8735bc588 Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 17 May 2026 14:59:47 +0800 Subject: [PATCH 10/76] perf(session): reduce startup config and turn save overhead Batch startup config reads with legacy fallbacks to reduce cold-start IPC overhead. Update dialog-turn metadata incrementally on the normal save path while serializing per-session metadata writes and retaining full-scan fallback for inconsistent storage. Defer snapshot refresh work while historical session context restore is pending, bound session response trace estimation, and preserve compatibility fallbacks across mixed remote identities. --- src/apps/desktop/src/api/agentic_api.rs | 129 ++++++- src/apps/desktop/src/api/config_api.rs | 44 +++ src/apps/desktop/src/lib.rs | 1 + .../core/src/agentic/persistence/manager.rs | 365 +++++++++++++++++- .../flow-chat-manager/SessionModule.test.ts | 41 ++ .../flow-chat-manager/SessionModule.ts | 15 +- .../src/flow_chat/store/FlowChatStore.test.ts | 66 ++++ .../src/flow_chat/store/FlowChatStore.ts | 30 +- .../api/service-api/ApiClient.test.ts | 4 +- .../api/service-api/ApiClient.ts | 4 +- .../api/service-api/ConfigAPI.test.ts | 74 ++++ .../api/service-api/ConfigAPI.ts | 29 ++ .../config/services/ConfigManager.test.ts | 22 ++ .../config/services/ConfigManager.ts | 28 +- .../hooks/snapshotRefreshPolicy.test.ts | 18 + .../hooks/snapshotRefreshPolicy.ts | 8 +- 16 files changed, 841 insertions(+), 37 deletions(-) create mode 100644 src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 8e1cbb6aef..ded19c5e6f 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -20,6 +20,12 @@ use bitfun_core::agentic::deep_review_policy::{ use bitfun_core::agentic::image_analysis::ImageContextData; use bitfun_core::agentic::tools::image_context::get_image_context; use bitfun_core::service::session::DialogTurnData; + +const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024; +const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024; +const SESSION_VIEW_TRUNCATED_MARKER: &str = "\n...[truncated for session view]"; +const SESSION_VIEW_OMITTED_MARKER: &str = "[truncated for session view]"; + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { @@ -301,18 +307,75 @@ fn restore_turn_payload_stats(turns: &[DialogTurnData]) -> RestoreTurnPayloadSta stats } -fn omit_assistant_only_tool_results_for_session_view(turns: &mut [DialogTurnData]) { +fn truncate_string_for_session_view(text: &str, remaining_budget: &mut usize) -> Option { + let char_count = text.chars().count(); + let available = SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT.min(*remaining_budget); + + if char_count <= available { + *remaining_budget = remaining_budget.saturating_sub(char_count); + return None; + } + + let omitted_chars = SESSION_VIEW_OMITTED_MARKER.chars().count(); + if available <= omitted_chars { + *remaining_budget = remaining_budget.saturating_sub(omitted_chars.min(*remaining_budget)); + return Some(SESSION_VIEW_OMITTED_MARKER.to_string()); + } + + let suffix_chars = SESSION_VIEW_TRUNCATED_MARKER.chars().count(); + if available <= suffix_chars { + *remaining_budget = remaining_budget.saturating_sub(omitted_chars.min(*remaining_budget)); + return Some(SESSION_VIEW_OMITTED_MARKER.to_string()); + } + + let keep_chars = available - suffix_chars; + let mut preview = text.chars().take(keep_chars).collect::(); + preview.push_str(SESSION_VIEW_TRUNCATED_MARKER); + let preview_chars = preview.chars().count(); + *remaining_budget = remaining_budget.saturating_sub(preview_chars); + Some(preview) +} + +fn compact_json_for_session_view(value: &mut serde_json::Value, remaining_budget: &mut usize) { + match value { + serde_json::Value::String(text) => { + if let Some(preview) = truncate_string_for_session_view(text, remaining_budget) { + *text = preview; + } + } + serde_json::Value::Array(items) => { + for item in items { + compact_json_for_session_view(item, remaining_budget); + } + } + serde_json::Value::Object(map) => { + for item in map.values_mut() { + compact_json_for_session_view(item, remaining_budget); + } + } + _ => {} + } +} + +fn compact_tool_results_for_session_view(turns: &mut [DialogTurnData]) { + let mut remaining_budget = SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET; for turn in turns { for round in &mut turn.model_rounds { for tool in &mut round.tool_items { if let Some(result) = tool.tool_result.as_mut() { result.result_for_assistant = None; + compact_json_for_session_view(&mut result.result, &mut remaining_budget); } } } } } +#[cfg(test)] +fn omit_assistant_only_tool_results_for_session_view(turns: &mut [DialogTurnData]) { + compact_tool_results_for_session_view(turns); +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CancelDialogTurnRequest { @@ -1089,7 +1152,7 @@ pub async fn restore_session_view( } } - omit_assistant_only_tool_results_for_session_view(&mut turns); + compact_tool_results_for_session_view(&mut turns); debug!( "restore_session_view completed: trace_id={}, session_id={}, turn_count={}, context_restore_state=pending, duration_ms={}", @@ -1513,4 +1576,66 @@ mod tests { assert_eq!(tool_result.result["output"], "visible output"); assert_eq!(tool_result.result_for_assistant, None); } + + #[test] + fn session_view_tool_result_compaction_truncates_large_visible_results() { + let large_output = "x".repeat(80 * 1024); + let mut turns = vec![DialogTurnData { + turn_id: "turn-1".to_string(), + turn_index: 0, + session_id: "session-1".to_string(), + timestamp: 1, + kind: Default::default(), + user_message: UserMessageData { + id: "user-1".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + model_rounds: vec![ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![tool_item( + "Bash", + json!({ "output": large_output, "exit_code": 0 }), + Some("assistant-only payload"), + )], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + status: TurnStatus::Completed, + }]; + + omit_assistant_only_tool_results_for_session_view(&mut turns); + + let tool_result = turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .expect("tool result should remain present"); + let output = tool_result.result["output"] + .as_str() + .expect("output should remain a visible string preview"); + assert!(output.len() < 80 * 1024); + assert!(output.contains("truncated for session view")); + assert_eq!(tool_result.result["exit_code"], 0); + assert_eq!(tool_result.result_for_assistant, None); + } } diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index 8a0297d759..a72c8e6111 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -5,6 +5,7 @@ use bitfun_core::util::errors::BitFunError; use log::{error, info}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::BTreeMap; use tauri::State; #[derive(Debug, Deserialize)] @@ -15,6 +16,14 @@ pub struct GetConfigRequest { pub skip_retry_on_not_found: bool, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetConfigsRequest { + pub paths: Vec, + #[serde(default)] + pub skip_retry_on_not_found: bool, +} + #[derive(Debug, Deserialize)] pub struct SetConfigRequest { pub path: String, @@ -66,6 +75,41 @@ pub async fn get_config( } } +#[tauri::command] +pub async fn get_configs( + state: State<'_, AppState>, + request: GetConfigsRequest, +) -> Result, String> { + let config_service = &state.config_service; + let mut configs = BTreeMap::new(); + + for path in request.paths { + if configs.contains_key(&path) { + continue; + } + + match config_service + .get_config::(Some(path.as_str())) + .await + { + Ok(config) => { + configs.insert(path, config); + } + Err(e) => { + if request.skip_retry_on_not_found + && is_expected_config_path_not_found(&e, Some(path.as_str())) + { + return Err(format!("Failed to get config: {}", e)); + } + error!("Failed to get config: path={}, error={}", path, e); + return Err(format!("Failed to get config: {}", e)); + } + } + } + + Ok(configs) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 8be80fcdee..0eb2ca632b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -671,6 +671,7 @@ pub async fn run() { get_clipboard_files, paste_files, get_config, + get_configs, computer_use_get_status, computer_use_request_permissions, computer_use_open_system_settings, diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 2b6010cfa0..546e7398db 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -35,6 +35,8 @@ const SESSION_TRANSCRIPT_PREVIEW_CHAR_LIMIT: usize = 120; static JSON_FILE_WRITE_LOCKS: OnceLock>>>> = OnceLock::new(); static SESSION_INDEX_LOCKS: OnceLock>>>> = OnceLock::new(); +static SESSION_METADATA_UPDATE_LOCKS: OnceLock>>>> = + OnceLock::new(); #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredDialogTurnFile { @@ -548,6 +550,20 @@ impl PersistenceManager { .clone() } + async fn get_session_metadata_update_lock( + &self, + workspace_path: &Path, + session_id: &str, + ) -> Arc> { + let metadata_path = self.metadata_path(workspace_path, session_id); + let registry = SESSION_METADATA_UPDATE_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut registry_guard = registry.lock().await; + registry_guard + .entry(metadata_path) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + fn build_temp_json_path(path: &Path, attempt: usize) -> BitFunResult { let parent = path.parent().ok_or_else(|| { BitFunError::io(format!( @@ -1928,12 +1944,74 @@ impl PersistenceManager { 1 + assistant_text_count } + fn refresh_metadata_from_turns( + metadata: &mut SessionMetadata, + workspace_path: &Path, + turns: &[DialogTurnData], + last_active_at: u64, + ) { + metadata.turn_count = turns.len(); + metadata.message_count = turns.iter().map(Self::estimate_turn_message_count).sum(); + metadata.tool_call_count = turns.iter().map(DialogTurnData::count_tool_calls).sum(); + metadata.last_active_at = last_active_at; + if metadata.workspace_path.is_none() { + metadata.workspace_path = Some(workspace_path.to_string_lossy().to_string()); + } + } + + fn try_refresh_metadata_for_saved_turn( + metadata: &mut SessionMetadata, + workspace_path: &Path, + previous_turn: Option<&DialogTurnData>, + turn: &DialogTurnData, + last_active_at: u64, + ) -> bool { + let new_message_count = Self::estimate_turn_message_count(turn); + let new_tool_call_count = turn.count_tool_calls(); + + match previous_turn { + Some(previous) + if previous.session_id == turn.session_id + && previous.turn_index == turn.turn_index + && turn.turn_index < metadata.turn_count => + { + metadata.message_count = metadata + .message_count + .saturating_sub(Self::estimate_turn_message_count(previous)) + .saturating_add(new_message_count); + metadata.tool_call_count = metadata + .tool_call_count + .saturating_sub(previous.count_tool_calls()) + .saturating_add(new_tool_call_count); + } + None if turn.turn_index == metadata.turn_count => { + metadata.turn_count += 1; + metadata.message_count = metadata.message_count.saturating_add(new_message_count); + metadata.tool_call_count = + metadata.tool_call_count.saturating_add(new_tool_call_count); + } + _ => return false, + } + + metadata.last_active_at = last_active_at; + if metadata.workspace_path.is_none() { + metadata.workspace_path = Some(workspace_path.to_string_lossy().to_string()); + } + + true + } + pub async fn save_dialog_turn( &self, workspace_path: &Path, turn: &DialogTurnData, ) -> BitFunResult<()> { + let save_started_at = Instant::now(); self.ensure_runtime_for_write(workspace_path).await?; + let metadata_update_lock = self + .get_session_metadata_update_lock(workspace_path, &turn.session_id) + .await; + let _metadata_update_guard = metadata_update_lock.lock().await; let mut metadata = self .load_session_metadata(workspace_path, &turn.session_id) .await? @@ -1944,32 +2022,81 @@ impl PersistenceManager { self.ensure_turns_dir(workspace_path, &turn.session_id) .await?; + let previous_turn = match self + .load_dialog_turn(workspace_path, &turn.session_id, turn.turn_index) + .await + { + Ok(turn) => turn, + Err(error) => { + warn!( + "Failed to load existing dialog turn before save; falling back to full metadata refresh: session_id={} turn_index={} error={}", + turn.session_id, + turn.turn_index, + error + ); + None + } + }; + let previous_turn_load_failed = previous_turn.is_none() + && self + .turn_path(workspace_path, &turn.session_id, turn.turn_index) + .exists(); + let file = StoredDialogTurnFile { schema_version: SESSION_STORAGE_SCHEMA_VERSION, turn: turn.clone(), }; + let write_started_at = Instant::now(); self.write_json_atomic( &self.turn_path(workspace_path, &turn.session_id, turn.turn_index), &file, ) .await?; + let write_duration = write_started_at.elapsed(); - let turns = self - .load_session_turns(workspace_path, &turn.session_id) - .await?; - metadata.turn_count = turns.len(); - metadata.message_count = turns.iter().map(Self::estimate_turn_message_count).sum(); - metadata.tool_call_count = turns.iter().map(DialogTurnData::count_tool_calls).sum(); - metadata.last_active_at = turn + let last_active_at = turn .end_time .unwrap_or_else(|| Self::system_time_to_unix_ms(SystemTime::now())); - metadata.workspace_path = metadata.workspace_path.clone().or_else(|| { - turns - .first() - .and(None::) - .or_else(|| Some(workspace_path.to_string_lossy().to_string())) - }); - self.save_session_metadata(workspace_path, &metadata).await + let mut metadata_refresh_mode = "incremental"; + if previous_turn_load_failed + || !Self::try_refresh_metadata_for_saved_turn( + &mut metadata, + workspace_path, + previous_turn.as_ref(), + turn, + last_active_at, + ) + { + metadata_refresh_mode = "full_scan"; + let turns = self + .load_session_turns(workspace_path, &turn.session_id) + .await?; + Self::refresh_metadata_from_turns( + &mut metadata, + workspace_path, + &turns, + last_active_at, + ); + } + + let metadata_started_at = Instant::now(); + self.save_session_metadata(workspace_path, &metadata) + .await?; + let metadata_duration = metadata_started_at.elapsed(); + let total_duration = save_started_at.elapsed(); + if total_duration >= Duration::from_millis(80) || metadata_refresh_mode == "full_scan" { + debug!( + "Saved dialog turn: session_id={} turn_index={} metadata_refresh={} write_duration_ms={} metadata_duration_ms={} total_duration_ms={}", + turn.session_id, + turn.turn_index, + metadata_refresh_mode, + write_duration.as_millis(), + metadata_duration.as_millis(), + total_duration.as_millis() + ); + } + + Ok(()) } pub async fn load_dialog_turn( @@ -2422,7 +2549,8 @@ mod tests { use crate::agentic::core::{Message, Session, SessionConfig, SessionKind, ToolResult}; use crate::infrastructure::PathManager; use crate::service::session::{ - DialogTurnData, SessionMetadata, SessionTranscriptExportOptions, UserMessageData, + DialogTurnData, ModelRoundData, SessionMetadata, SessionTranscriptExportOptions, + TextItemData, UserMessageData, }; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -2585,6 +2713,213 @@ mod tests { assert_eq!(loaded_turns[0].turn_id, "turn-1"); } + fn user_message(content: &str) -> UserMessageData { + UserMessageData { + id: format!("user-{}", content), + content: content.to_string(), + timestamp: 0, + metadata: None, + } + } + + fn text_item(id: &str, content: &str) -> TextItemData { + TextItemData { + id: id.to_string(), + content: content.to_string(), + is_streaming: false, + timestamp: 0, + is_markdown: true, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + status: None, + } + } + + fn round_with_text(turn_id: &str, text_items: Vec) -> ModelRoundData { + ModelRoundData { + id: format!("round-{}", turn_id), + turn_id: turn_id.to_string(), + round_index: 0, + timestamp: 0, + text_items, + tool_items: Vec::new(), + thinking_items: Vec::new(), + start_time: 0, + end_time: Some(0), + duration_ms: Some(0), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + } + } + + #[tokio::test] + async fn save_dialog_turn_updates_metadata_without_scanning_unrelated_turn_files() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Incremental metadata".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let mut turn_0 = DialogTurnData::new( + "turn-0".to_string(), + 0, + session_id.clone(), + user_message("first"), + ); + turn_0.model_rounds.push(round_with_text( + "turn-0", + vec![text_item("text-0", "first response")], + )); + turn_0.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn_0) + .await + .expect("first turn should save"); + + let mut turn_1 = DialogTurnData::new( + "turn-1".to_string(), + 1, + session_id.clone(), + user_message("second"), + ); + turn_1.model_rounds.push(round_with_text( + "turn-1", + vec![text_item("text-1", "second response")], + )); + turn_1.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn_1) + .await + .expect("second turn should save"); + + std::fs::write( + manager.turn_path(workspace.path(), &session_id, 0), + "{ not valid json", + ) + .expect("old turn file should be replaceable for test"); + + turn_1.model_rounds[0] + .text_items + .push(text_item("text-2", "additional response")); + manager + .save_dialog_turn(workspace.path(), &turn_1) + .await + .expect("saving current turn should not scan unrelated old turn files"); + + let metadata = manager + .load_session_metadata(workspace.path(), &session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.turn_count, 2); + assert_eq!(metadata.message_count, 5); + } + + #[tokio::test] + async fn concurrent_dialog_turn_saves_keep_metadata_counts_consistent() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Concurrent metadata".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let mut turn_0 = DialogTurnData::new( + "turn-0".to_string(), + 0, + session_id.clone(), + user_message("first"), + ); + turn_0.model_rounds.push(round_with_text( + "turn-0", + vec![text_item("text-0", "first response")], + )); + turn_0.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn_0) + .await + .expect("first turn should save"); + + let mut turn_1 = DialogTurnData::new( + "turn-1".to_string(), + 1, + session_id.clone(), + user_message("second"), + ); + turn_1.model_rounds.push(round_with_text( + "turn-1", + vec![text_item("text-1", "second response")], + )); + turn_1.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn_1) + .await + .expect("second turn should save"); + + let mut updated_turn_0 = turn_0.clone(); + updated_turn_0.model_rounds[0] + .text_items + .push(text_item("text-0b", "first follow-up")); + + let mut updated_turn_1 = turn_1.clone(); + updated_turn_1.model_rounds[0] + .text_items + .push(text_item("text-1b", "second follow-up")); + updated_turn_1.model_rounds[0] + .text_items + .push(text_item("text-1c", "second final")); + + let (first_result, second_result) = tokio::join!( + manager.save_dialog_turn(workspace.path(), &updated_turn_0), + manager.save_dialog_turn(workspace.path(), &updated_turn_1) + ); + first_result.expect("first concurrent save should succeed"); + second_result.expect("second concurrent save should succeed"); + + let metadata = manager + .load_session_metadata(workspace.path(), &session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.turn_count, 2); + assert_eq!(metadata.message_count, 7); + } + #[test] fn context_snapshot_payload_stats_counts_tool_result_payloads_without_contents() { let messages = vec![ diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index 37d2932875..477ebda31a 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -185,4 +185,45 @@ describe('SessionModule historical session coordination', () => { contextRestoreState: 'ready', }); }); + + it('does not recreate a view-restored session with loaded turns when context restore fails', async () => { + const { context } = createContext(createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + dialogTurns: [{ id: 'turn-1' } as any], + } as any)); + agentApiMocks.ensureCoordinatorSession.mockRejectedValueOnce( + new Error('Session metadata not found') + ); + + await expect(ensureBackendSession(context, 'history-1')).rejects.toThrow(); + + expect(agentApiMocks.ensureCoordinatorSession).toHaveBeenCalledTimes(1); + expect(agentApiMocks.createSession).not.toHaveBeenCalled(); + expect(context.flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + contextRestoreState: 'failed', + }); + }); + + it('keeps recreate fallback for empty pending context sessions', async () => { + const { context } = createContext(createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + dialogTurns: [], + } as any)); + agentApiMocks.ensureCoordinatorSession.mockRejectedValueOnce( + new Error('Session metadata not found') + ); + agentApiMocks.createSession.mockResolvedValueOnce(undefined); + + await ensureBackendSession(context, 'history-1'); + + expect(agentApiMocks.ensureCoordinatorSession).toHaveBeenCalledTimes(1); + expect(agentApiMocks.createSession).toHaveBeenCalledTimes(1); + expect(context.flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + contextRestoreState: 'ready', + }); + }); }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 8e0a115609..01d9ea814a 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -659,11 +659,12 @@ export async function ensureBackendSession( latestSession.contextRestoreState === 'pending' || latestSession.contextRestoreState === 'failed'; const needsBackendSetup = isHistoricalSession || isFirstTurn || requiresContextRestore; + const hasLoadedTurns = latestSession.dialogTurns.length > 0; /** Avoid createSession when historical data is already loaded but backend files are missing (e.g. new SSH connection id). */ const allowRecreateOnCoordinatorFailure = needsBackendSetup && - !requiresContextRestore && - !(isHistoricalSession && session.dialogTurns.length > 1); + !(requiresContextRestore && hasLoadedTurns) && + !(isHistoricalSession && hasLoadedTurns); const markBackendContextReady = () => { if (!isHistoricalSession && !requiresContextRestore) return; @@ -704,7 +705,7 @@ export async function ensureBackendSession( markBackendContextReady(); }; - if (requiresContextRestore) { + const restorePendingBackendContext = async () => { if (!context.pendingContextRestores) { context.pendingContextRestores = new Map(); } @@ -730,10 +731,14 @@ export async function ensureBackendSession( }); context.pendingContextRestores.set(restoreKey, restorePromise); await restorePromise; - return; - } + }; try { + if (requiresContextRestore) { + await restorePendingBackendContext(); + return; + } + await ensureCoordinator(); } catch (e: any) { if (!allowRecreateOnCoordinatorFailure) { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index eaba2ed49d..305fcf3d89 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -762,6 +762,72 @@ describe('FlowChatStore historical session hydration state', () => { expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); }); + it('scopes unsupported restore command caching by remote identity', async () => { + const restoredTurn = (sessionId: string) => ({ + turnId: `${sessionId}-turn-1`, + turnIndex: 0, + sessionId, + timestamp: 1, + userMessage: { id: `${sessionId}-user-1`, content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }); + apiMocks.restoreSessionView + .mockRejectedValueOnce(new Error('unknown command restore_session_view')) + .mockResolvedValueOnce({ + session: { + sessionId: 'history-2', + sessionName: 'History 2', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn('history-2')], + contextRestoreState: 'pending', + }); + apiMocks.restoreSessionWithTurns.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn('history-1')], + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ['history-2', createSession({ + sessionId: 'history-2', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory( + 'history-1', + '/remote/workspace', + undefined, + 'remote-1', + 'old.example' + ); + await flowChatStore.loadSessionHistory('history-2', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(2); + expect(apiMocks.restoreSessionWithTurns).toHaveBeenCalledTimes(1); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + }); + it('falls back to legacy restore and turn loading when restoreSessionWithTurns is unavailable on the backend', async () => { (apiMocks as any).restoreSessionView = undefined; const restoredTurn = { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index f3d214a9ef..a0ec4e20d7 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -89,6 +89,18 @@ function isUnsupportedTauriCommandError(error: unknown, command: string): boolea normalizedMessage.includes('is not a function'); } +function restoreCommandSupportKey( + command: string, + remoteConnectionId?: string, + remoteSshHost?: string +): string { + return JSON.stringify([ + command, + remoteConnectionId?.trim() || 'local', + remoteSshHost?.trim().toLowerCase() || '', + ]); +} + function isValidPersistedAgentType(agentType: string): boolean { return VALID_AGENT_TYPES.has(agentType) || agentType.startsWith('acp:'); } @@ -2095,10 +2107,20 @@ export class FlowChatStore { startupTrace.markPhase('historical_session_restore_start', { remote, sessionTraceId }); try { const { agentAPI } = await import('@/infrastructure/api'); + const restoreSessionViewSupportKey = restoreCommandSupportKey( + 'restore_session_view', + remoteConnectionId, + remoteSshHost + ); + const restoreSessionWithTurnsSupportKey = restoreCommandSupportKey( + 'restore_session_with_turns', + remoteConnectionId, + remoteSshHost + ); const restoreWithTurnsOrSession = async (): Promise => { if ( typeof agentAPI.restoreSessionWithTurns === 'function' && - !this.unsupportedRestoreCommands.has('restore_session_with_turns') + !this.unsupportedRestoreCommands.has(restoreSessionWithTurnsSupportKey) ) { try { const restored = await agentAPI.restoreSessionWithTurns( @@ -2115,7 +2137,7 @@ export class FlowChatStore { if (!isUnsupportedTauriCommandError(error, 'restore_session_with_turns')) { throw error; } - this.unsupportedRestoreCommands.add('restore_session_with_turns'); + this.unsupportedRestoreCommands.add(restoreSessionWithTurnsSupportKey); startupTrace.markPhase('historical_session_restore_fallback', { remote, sessionTraceId, @@ -2138,7 +2160,7 @@ export class FlowChatStore { if ( typeof agentAPI.restoreSessionView === 'function' && - !this.unsupportedRestoreCommands.has('restore_session_view') + !this.unsupportedRestoreCommands.has(restoreSessionViewSupportKey) ) { try { const restored = await agentAPI.restoreSessionView( @@ -2155,7 +2177,7 @@ export class FlowChatStore { if (!isUnsupportedTauriCommandError(error, 'restore_session_view')) { throw error; } - this.unsupportedRestoreCommands.add('restore_session_view'); + this.unsupportedRestoreCommands.add(restoreSessionViewSupportKey); startupTrace.markPhase('historical_session_restore_fallback', { remote, sessionTraceId, diff --git a/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts b/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts index f7599fcf8f..677940a1f5 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts @@ -63,7 +63,7 @@ describe('ApiClient startup trace classification', () => { expect(loggerMocks.error).not.toHaveBeenCalled(); }); - it('uses the large response estimate cap for session view restore', async () => { + it('uses a bounded response estimate cap for session view restore', async () => { adapterMocks.request.mockResolvedValueOnce({ turns: [] }); const client = new ApiClient({ enableLogging: false, retries: 0 }); @@ -76,7 +76,7 @@ describe('ApiClient startup trace classification', () => { expect(traceMocks.estimateJsonBytes).toHaveBeenCalledWith( { turns: [] }, - 32 * 1024 * 1024 + 2 * 1024 * 1024 ); }); }); diff --git a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts index 7f707fe8ac..9f95cd58b8 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts @@ -20,13 +20,13 @@ import { sanitizeErrorForLog, sanitizeLogValue, sanitizeTextForLog } from '../lo const log = createLogger('ApiClient'); const sanitizeForLog = sanitizeLogValue; -const LARGE_SESSION_RESPONSE_ESTIMATE_MAX_BYTES = 32 * 1024 * 1024; +const SESSION_RESPONSE_ESTIMATE_MAX_BYTES = 2 * 1024 * 1024; function responseEstimateMaxBytes(command: string): number | undefined { return command === 'restore_session_view' || command === 'restore_session_with_turns' || command === 'load_session_turns' - ? LARGE_SESSION_RESPONSE_ESTIMATE_MAX_BYTES + ? SESSION_RESPONSE_ESTIMATE_MAX_BYTES : undefined; } diff --git a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts new file mode 100644 index 0000000000..d5328ffdc8 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfigAPI } from './ConfigAPI'; + +const invokeMock = vi.hoisted(() => vi.fn()); + +vi.mock('./ApiClient', () => ({ + api: { + invoke: invokeMock, + }, +})); + +describe('ConfigAPI batch config reads', () => { + let configAPI: ConfigAPI; + + beforeEach(() => { + configAPI = new ConfigAPI(); + invokeMock.mockReset(); + }); + + it('reads multiple config paths through one batch command', async () => { + const configs = { + 'ai.models': [], + 'ai.default_models': { chat: 'gpt-5' }, + }; + invokeMock.mockResolvedValueOnce(configs); + + await expect( + configAPI.getConfigs(['ai.models', 'ai.models', 'ai.default_models']) + ).resolves.toEqual(configs); + + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith('get_configs', { + request: { + paths: ['ai.models', 'ai.default_models'], + skipRetryOnNotFound: false, + }, + }); + }); + + it('falls back to existing single-path reads when the batch command fails', async () => { + invokeMock.mockImplementation((command: string, args?: any) => { + if (command === 'get_configs') { + return Promise.reject(new Error('unknown command get_configs')); + } + + return Promise.resolve(`value:${args.request.path}`); + }); + + await expect(configAPI.getConfigs(['ai.models', 'ai.default_models'])).resolves.toEqual({ + 'ai.models': 'value:ai.models', + 'ai.default_models': 'value:ai.default_models', + }); + + expect(invokeMock).toHaveBeenCalledTimes(3); + expect(invokeMock).toHaveBeenNthCalledWith(1, 'get_configs', { + request: { + paths: ['ai.models', 'ai.default_models'], + skipRetryOnNotFound: false, + }, + }); + expect(invokeMock).toHaveBeenNthCalledWith(2, 'get_config', { + request: { + path: 'ai.models', + skipRetryOnNotFound: false, + }, + }, undefined); + expect(invokeMock).toHaveBeenNthCalledWith(3, 'get_config', { + request: { + path: 'ai.default_models', + skipRetryOnNotFound: false, + }, + }, undefined); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts index de87d45294..fd2df70c1b 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts @@ -89,6 +89,35 @@ export class ConfigAPI { } } + async getConfigs( + paths: string[], + options?: { skipRetryOnNotFound?: boolean } + ): Promise> { + const uniquePaths = Array.from(new Set(paths)); + if (uniquePaths.length === 0) { + return {}; + } + + const shouldSkipRetry = options?.skipRetryOnNotFound ?? false; + + try { + return await api.invoke('get_configs', { + request: { + paths: uniquePaths, + skipRetryOnNotFound: shouldSkipRetry, + }, + }); + } catch { + const entries = await Promise.all( + uniquePaths.map(async (path) => [ + path, + await this.getConfig(path, options), + ] as const) + ); + return Object.fromEntries(entries); + } + } + async setConfig(path: string, value: any): Promise { try { diff --git a/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts b/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts index b486579958..15053c7dca 100644 --- a/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts +++ b/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts @@ -3,6 +3,7 @@ import { configManager } from './ConfigManager'; const configApiMocks = vi.hoisted(() => ({ getConfig: vi.fn(), + getConfigs: vi.fn(), setConfig: vi.fn(), resetConfig: vi.fn(), exportConfig: vi.fn(), @@ -54,4 +55,25 @@ describe('ConfigManager', () => { await expect(Promise.all([first, second])).resolves.toEqual(['debug', 'debug']); expect(configApiMocks.getConfig).toHaveBeenCalledTimes(1); }); + + it('reloads startup config paths through one batch call', async () => { + configApiMocks.getConfigs.mockResolvedValueOnce({ + 'ai.models': [], + 'ai.agent_models': { coder: 'gpt-5' }, + 'ai.func_agent_models': { title: 'gpt-5-mini' }, + 'ai.default_models': { chat: 'gpt-5' }, + }); + + await configManager.reload(); + + expect(configApiMocks.getConfigs).toHaveBeenCalledTimes(1); + expect(configApiMocks.getConfigs).toHaveBeenCalledWith([ + 'ai.models', + 'ai.agent_models', + 'ai.func_agent_models', + 'ai.default_models', + ]); + expect(configApiMocks.getConfig).not.toHaveBeenCalled(); + expect(configManager.get('ai.default_models')).toEqual({ chat: 'gpt-5' }); + }); }); diff --git a/src/web-ui/src/infrastructure/config/services/ConfigManager.ts b/src/web-ui/src/infrastructure/config/services/ConfigManager.ts index 2d2f4d3773..ee05b0eb09 100644 --- a/src/web-ui/src/infrastructure/config/services/ConfigManager.ts +++ b/src/web-ui/src/infrastructure/config/services/ConfigManager.ts @@ -85,6 +85,22 @@ class ConfigManagerImpl implements IConfigManager { return resolvedConfig as T; } + private async readConfigs(paths: string[]): Promise> { + const configs = await configAPI.getConfigs(paths); + const resolvedConfigs: Record = {}; + + for (const path of paths) { + const resolvedConfig = path === 'ai.models' + ? await this.migrateLegacyAiModelsIfNeeded(configs[path]) + : configs[path]; + + this.configCache.set(path, resolvedConfig); + resolvedConfigs[path] = resolvedConfig; + } + + return resolvedConfigs; + } + async getConfig(path?: string): Promise { try { @@ -286,11 +302,13 @@ class ConfigManagerImpl implements IConfigManager { try { this.configCache.clear(); this.inFlightReads.clear(); - - await this.getConfig('ai.models'); - await this.getConfig('ai.agent_models'); - await this.getConfig('ai.func_agent_models'); - await this.getConfig('ai.default_models'); + + await this.readConfigs([ + 'ai.models', + 'ai.agent_models', + 'ai.func_agent_models', + 'ai.default_models', + ]); } catch (error) { log.error('Failed to reload config', error); throw error; diff --git a/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.test.ts b/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.test.ts index ddc47c3a56..9d8c0356b7 100644 --- a/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.test.ts +++ b/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.test.ts @@ -28,4 +28,22 @@ describe('snapshot refresh policy', () => { })).toBe(true); expect(shouldRefreshSnapshotForSession(null)).toBe(true); }); + + it('defers snapshot refresh while backend context restore is pending', () => { + expect(shouldRefreshSnapshotForSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + })).toBe(false); + expect(shouldRefreshSnapshotForSession({ + isHistorical: true, + historyState: 'ready', + contextRestoreState: 'pending', + })).toBe(false); + expect(shouldRefreshSnapshotForSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + })).toBe(true); + }); }); diff --git a/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.ts b/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.ts index 8a64e3eebe..bf73df98c5 100644 --- a/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.ts +++ b/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.ts @@ -1,12 +1,16 @@ import type { Session } from '@/flow_chat/types/flow-chat'; -type SnapshotRefreshSession = Pick; +type SnapshotRefreshSession = Pick; export function shouldRefreshSnapshotForSession( session?: SnapshotRefreshSession | null ): boolean { if (!session || !session.isHistorical) { - return true; + return session?.contextRestoreState !== 'pending'; + } + + if (session.contextRestoreState === 'pending') { + return false; } return session.historyState === undefined || From ce52d499ae22f85e351f51c89b93de606d3bce90 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 18 May 2026 10:32:00 +0800 Subject: [PATCH 11/76] fix ACP file path resolution --- src/crates/acp/src/client/manager.rs | 13 +- src/crates/acp/src/client/stream.rs | 214 +++++++++++++++--- .../tool-cards/FileOperationToolCard.test.tsx | 50 ++++ .../tool-cards/FileOperationToolCard.tsx | 14 ++ 4 files changed, 256 insertions(+), 35 deletions(-) diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index e3c9be926d..780b052d02 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -52,7 +52,10 @@ use super::requirements::{ use super::session_options::{model_config_id, session_options_from_state, AcpSessionOptions}; use super::session_persistence::AcpSessionPersistence; pub use super::session_persistence::CreateAcpFlowSessionRecordResponse; -use super::stream::{acp_dispatch_to_stream_events, AcpClientStreamEvent, AcpStreamRoundTracker}; +use super::stream::{ + acp_dispatch_to_stream_events_with_tracker, AcpClientStreamEvent, AcpStreamRoundTracker, + AcpToolCallTracker, +}; use super::tool::AcpAgentTool; const CONFIG_PATH: &str = "acp_clients"; @@ -1018,11 +1021,17 @@ impl AcpClientService { .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; active.send_prompt(prompt).map_err(protocol_error)?; let mut round_tracker = AcpStreamRoundTracker::new(); + let mut tool_call_tracker = AcpToolCallTracker::new(); loop { match active.read_update().await.map_err(protocol_error)? { SessionMessage::SessionMessage(dispatch) => { - for event in acp_dispatch_to_stream_events(dispatch).await? { + for event in acp_dispatch_to_stream_events_with_tracker( + dispatch, + &mut tool_call_tracker, + ) + .await? + { for event in round_tracker.apply(event) { on_event(event)?; } diff --git a/src/crates/acp/src/client/stream.rs b/src/crates/acp/src/client/stream.rs index acf80f176d..024fcfb204 100644 --- a/src/crates/acp/src/client/stream.rs +++ b/src/crates/acp/src/client/stream.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use agent_client_protocol::schema::{ ContentBlock, ContentChunk, SessionNotification, SessionUpdate, ToolCall, ToolCallContent, ToolCallStatus, ToolCallUpdate, @@ -34,6 +36,19 @@ pub(super) struct AcpStreamRoundTracker { last_item_kind: Option, } +#[derive(Debug, Default)] +pub(super) struct AcpToolCallTracker { + calls: HashMap, +} + +#[derive(Debug, Clone)] +struct AcpToolCallSnapshot { + title: String, + tool_name: String, + raw_input: Option, + kind: Option, +} + impl AcpStreamRoundTracker { pub(super) fn new() -> Self { Self::default() @@ -82,8 +97,9 @@ impl AcpStreamRoundTracker { } } -pub async fn acp_dispatch_to_stream_events( +pub(super) async fn acp_dispatch_to_stream_events_with_tracker( dispatch: agent_client_protocol::Dispatch, + tracker: &mut AcpToolCallTracker, ) -> BitFunResult> { let mut events = Vec::new(); MatchDispatch::new(dispatch) @@ -100,12 +116,10 @@ pub async fn acp_dispatch_to_stream_events( } } SessionUpdate::ToolCall(tool_call) => { - events.extend(acp_tool_call_events(tool_call)); + events.extend(acp_tool_call_events(tool_call, tracker)); } SessionUpdate::ToolCallUpdate(tool_call_update) => { - if let Some(event) = acp_tool_call_update_event(tool_call_update) { - events.push(event); - } + events.extend(acp_tool_call_update_events(tool_call_update, tracker)); } _ => {} } @@ -124,16 +138,73 @@ fn content_chunk_text(chunk: ContentChunk) -> Option { } } -fn acp_tool_call_events(tool_call: ToolCall) -> Vec { +impl AcpToolCallTracker { + pub(super) fn new() -> Self { + Self::default() + } + + fn upsert_from_call( + &mut self, + tool_id: String, + title: String, + raw_input: Option, + kind: Option, + ) -> AcpToolCallSnapshot { + let tool_name = acp_tool_name(&title, raw_input.as_ref(), kind.as_ref()); + let snapshot = AcpToolCallSnapshot { + title, + tool_name, + raw_input, + kind, + }; + self.calls.insert(tool_id, snapshot.clone()); + snapshot + } + + fn update_from_fields( + &mut self, + tool_id: &str, + title: Option, + raw_input: Option, + kind: Option, + ) -> AcpToolCallSnapshot { + let previous = self.calls.get(tool_id).cloned(); + let title = title + .or_else(|| previous.as_ref().map(|snapshot| snapshot.title.clone())) + .unwrap_or_else(|| tool_id.to_string()); + let raw_input = raw_input.or_else(|| { + previous + .as_ref() + .and_then(|snapshot| snapshot.raw_input.clone()) + }); + let kind = kind.or_else(|| previous.as_ref().and_then(|snapshot| snapshot.kind.clone())); + let tool_name = acp_tool_name(&title, raw_input.as_ref(), kind.as_ref()); + let snapshot = AcpToolCallSnapshot { + title, + tool_name, + raw_input, + kind, + }; + self.calls.insert(tool_id.to_string(), snapshot.clone()); + snapshot + } +} + +fn acp_tool_call_events( + tool_call: ToolCall, + tracker: &mut AcpToolCallTracker, +) -> Vec { let tool_id = tool_call.tool_call_id.to_string(); - let tool_name = acp_tool_name( - &tool_call.title, - tool_call.raw_input.as_ref(), - Some(&tool_call.kind), + let snapshot = tracker.upsert_from_call( + tool_id.clone(), + tool_call.title.clone(), + tool_call.raw_input.clone(), + Some(tool_call.kind.clone()), ); + let tool_name = snapshot.tool_name; let params = normalize_tool_params( &tool_name, - tool_call.raw_input.clone().unwrap_or_else(|| { + snapshot.raw_input.clone().unwrap_or_else(|| { serde_json::json!({ "title": tool_call.title, "kind": format!("{:?}", tool_call.kind), @@ -185,18 +256,31 @@ fn acp_tool_call_events(tool_call: ToolCall) -> Vec { events } -fn acp_tool_call_update_event(update: ToolCallUpdate) -> Option { +fn acp_tool_call_update_events( + update: ToolCallUpdate, + tracker: &mut AcpToolCallTracker, +) -> Vec { let tool_id = update.tool_call_id.to_string(); - let title = update.fields.title.unwrap_or_else(|| tool_id.clone()); - let tool_name = acp_tool_name( - &title, - update.fields.raw_input.as_ref(), - update.fields.kind.as_ref(), + let snapshot = tracker.update_from_fields( + &tool_id, + update.fields.title.clone(), + update.fields.raw_input.clone(), + update.fields.kind.clone(), ); + let tool_name = snapshot.tool_name.clone(); match update.fields.status { Some(ToolCallStatus::Completed) => { - Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { + let mut events = Vec::new(); + if let Some(raw_input) = snapshot.raw_input { + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + params: normalize_tool_params(&tool_name, raw_input), + timeout_seconds: None, + })); + } + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { tool_id, tool_name, result: acp_tool_result_value( @@ -210,10 +294,20 @@ fn acp_tool_call_update_event(update: ToolCallUpdate) -> Option { - Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { + let mut events = Vec::new(); + if let Some(raw_input) = snapshot.raw_input { + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + params: normalize_tool_params(&tool_name, raw_input), + timeout_seconds: None, + })); + } + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { tool_id, tool_name, error: acp_tool_error_text( @@ -225,33 +319,37 @@ fn acp_tool_call_update_event(update: ToolCallUpdate) -> Option { let params = normalize_tool_params( &tool_name, - update.fields.raw_input.unwrap_or_else(|| { + snapshot.raw_input.unwrap_or_else(|| { serde_json::json!({ - "title": title, + "title": snapshot.title, }) }), ); - Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + vec![AcpClientStreamEvent::ToolEvent(ToolEventData::Started { tool_id, tool_name, params, timeout_seconds: None, - })) + })] } - None => update.fields.raw_input.map(|params| { - let params = normalize_tool_params(&tool_name, params); - AcpClientStreamEvent::ToolEvent(ToolEventData::Started { - tool_id, - tool_name, - params, - timeout_seconds: None, + None => snapshot + .raw_input + .map(|params| { + let params = normalize_tool_params(&tool_name, params); + vec![AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id, + tool_name, + params, + timeout_seconds: None, + })] }) - }), + .unwrap_or_default(), } } @@ -305,6 +403,7 @@ fn protocol_error(error: impl std::fmt::Display) -> BitFunError { #[cfg(test)] mod tests { use super::*; + use agent_client_protocol::schema::{ToolCallUpdateFields, ToolKind}; use serde_json::json; fn tool_event(id: &str) -> AcpClientStreamEvent { @@ -375,4 +474,53 @@ mod tests { assert_eq!(event_kinds(&events), vec!["round", "text", "text"]); } + + #[test] + fn tool_call_tracker_replays_cached_input_before_completed_update() { + let mut tracker = AcpToolCallTracker::new(); + let in_progress = ToolCallUpdate::new( + "tool-1", + ToolCallUpdateFields::new() + .title("Edit file") + .kind(ToolKind::Edit) + .status(ToolCallStatus::InProgress) + .raw_input(json!({ + "path": "src/lib.rs", + "oldString": "before", + "newString": "after" + })), + ); + let completed = ToolCallUpdate::new( + "tool-1", + ToolCallUpdateFields::new() + .status(ToolCallStatus::Completed) + .raw_output(json!({ "success": true })), + ); + + let first = acp_tool_call_update_events(in_progress, &mut tracker); + assert!(matches!( + first.first(), + Some(AcpClientStreamEvent::ToolEvent( + ToolEventData::Started { .. } + )) + )); + + let second = acp_tool_call_update_events(completed, &mut tracker); + assert_eq!(second.len(), 2); + match &second[0] { + AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_name, params, .. + }) => { + assert_eq!(tool_name, "Edit"); + assert_eq!(params["file_path"], "src/lib.rs"); + assert_eq!(params["old_string"], "before"); + assert_eq!(params["new_string"], "after"); + } + other => panic!("expected cached Started event, got {other:?}"), + } + assert!(matches!( + second[1], + AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { .. }) + )); + } } diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx index 680b14d153..8bb2bbac13 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx @@ -314,4 +314,54 @@ describe('FileOperationToolCard', () => { }, ); }); + + it('renders completed ACP file cards from result locations when input has no path', async () => { + const toolItem: FlowToolItem = { + id: 'tool-1', + type: 'tool', + toolName: 'Write', + status: 'completed', + toolCall: { + id: 'call-1', + name: 'Write', + input: { + title: 'Run Write', + }, + }, + toolResult: { + success: true, + result: { + content: [], + locations: [ + { + path: 'src/from-acp-location.ts', + }, + ], + }, + }, + } as FlowToolItem; + + const config: ToolCardConfig = { + toolName: 'Write', + displayName: 'Write', + icon: 'WRITE', + requiresConfirmation: false, + resultDisplayType: 'detailed', + description: 'Write a file', + displayMode: 'standard', + }; + + await act(async () => { + root.render( + + ); + }); + + expect(container.textContent).toContain('from-acp-location.ts'); + expect(container.textContent).not.toContain('toolCards.file.parsingPath'); + }); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx index 372c19f6bd..56b5fdf198 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -72,6 +72,16 @@ function firstStringValue(source: Record | null | undefined, ke return ''; } +function pathFromAcpLocations(locations: unknown): string { + if (!Array.isArray(locations)) return ''; + for (const location of locations) { + const object = objectValue(location); + const filePath = firstStringValue(object, ['path', 'file_path', 'filePath', 'uri']); + if (filePath) return filePath; + } + return ''; +} + function isWindowsAbsolutePath(filePath: string): boolean { return /^[A-Za-z]:[\\/]/.test(filePath); } @@ -143,6 +153,10 @@ export const FileOperationToolCard: React.FC = ({ if (resultPath) { return resultPath; } + const resultLocationPath = pathFromAcpLocations(result?.locations); + if (resultLocationPath) { + return resultLocationPath; + } const params = objectValue(partialParams || toolCall?.input); if (!params) return ''; From ee0f52d8e550068219b437c43d72cc4e3fb280b8 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Fri, 15 May 2026 10:47:55 +0800 Subject: [PATCH 12/76] feat(core): streamline session workspace resolution - add a shared session workspace resolver in the coordinator and session manager - let cron, session control, and session history infer workspace from session_id when possible - allow SessionMessage to create a new target session when session_id is omitted - tighten tool input validation and add regression tests for the new session workflows --- .../src/agentic/coordination/coordinator.rs | 47 + .../src/agentic/session/session_manager.rs | 4278 +++++++++-------- .../tools/implementations/cron_tool.rs | 168 +- .../implementations/session_control_tool.rs | 289 +- .../implementations/session_history_tool.rs | 98 +- .../implementations/session_message_tool.rs | 585 ++- .../service/remote_connect/remote_server.rs | 60 +- .../core/src/service/workspace/manager.rs | 27 +- .../core/src/service/workspace/service.rs | 109 +- 9 files changed, 3204 insertions(+), 2457 deletions(-) diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index c86bd837c8..e327f68ead 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -27,6 +27,9 @@ use crate::service::bootstrap::{ ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, }; use crate::service::config::global::GlobalConfigManager; +use crate::service::workspace::{ + get_global_workspace_service, WorkspaceCreateOptions, WorkspaceKind, +}; use crate::util::errors::{BitFunError, BitFunResult}; use dashmap::DashMap; use log::{debug, error, info, warn}; @@ -311,6 +314,38 @@ pub struct ConversationCoordinator { } impl ConversationCoordinator { + async fn track_session_workspace_activity_best_effort(config: &SessionConfig, reason: &str) { + let Some(workspace_path) = config.workspace_path.as_ref() else { + return; + }; + + let Some(workspace_service) = get_global_workspace_service() else { + return; + }; + + let mut options = WorkspaceCreateOptions { + auto_set_current: false, + add_to_recent: true, + ..Default::default() + }; + + if config.remote_connection_id.is_some() { + options.workspace_kind = WorkspaceKind::Remote; + options.remote_connection_id = config.remote_connection_id.clone(); + options.remote_ssh_host = config.remote_ssh_host.clone(); + } + + if let Err(error) = workspace_service + .track_workspace_activity(PathBuf::from(workspace_path), options) + .await + { + warn!( + "Failed to track session workspace activity: reason={}, workspace_path={}, error={}", + reason, workspace_path, error + ); + } + } + /// Build a workspace binding that is remote-aware. /// If the global remote workspace is active and matches the session path, /// returns a `WorkspaceBinding` with remote metadata and correct local @@ -878,6 +913,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; + Self::track_session_workspace_activity_best_effort(&session.config, "session_created") + .await; + // SessionManager::create_session_with_id_and_creator already persists the // session into the effective workspace session storage path. Avoid writing // a second copy here using the raw workspace path, because remote workspaces @@ -1530,6 +1568,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }; let effective_agent_type = Self::normalize_agent_type(&provisional_agent_type); + Self::track_session_workspace_activity_best_effort(&session.config, "dialog_started") + .await; + debug!( "Resolved dialog turn agent type: session_id={}, turn_id={}, requested_agent_type={}, session_agent_type={}, effective_agent_type={}, trigger_source={:?}, queue_priority={:?}, skip_tool_confirmation={}", session_id, @@ -2495,6 +2536,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.session_manager.list_sessions(workspace_path).await } + pub async fn resolve_session_workspace_path(&self, session_id: &str) -> Option { + self.session_manager + .resolve_session_workspace_path(session_id) + .await + } + /// Get a best-effort message view for a session. pub async fn get_messages(&self, session_id: &str) -> BitFunResult> { self.session_manager.get_messages(session_id).await diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 0888b60dd5..554e0359fa 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -22,6 +22,7 @@ use crate::service::session::{ UserMessageData, }; use crate::service::snapshot::ensure_snapshot_manager_for_workspace; +use crate::service::workspace::get_global_workspace_service; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::sanitize_plain_model_output; use crate::util::timing::elapsed_ms_u64; @@ -75,2221 +76,1671 @@ pub struct ResolvedSessionTitle { pub title: String, pub method: SessionTitleMethod, } +/// Session manager +pub struct SessionManager { + /// Active sessions in memory + sessions: Arc>, -#[cfg(test)] -mod tests { - use super::{SessionManager, SessionManagerConfig}; - use crate::agentic::core::{Message, ProcessingPhase, Session, SessionConfig, SessionState}; - use crate::agentic::persistence::PersistenceManager; - use crate::agentic::session::SessionContextStore; - use crate::infrastructure::PathManager; - use crate::service::session::{ - DialogTurnData, ModelRoundData, ToolCallData, ToolItemData, ToolResultData, UserMessageData, - }; - use serde_json::json; - use std::path::{Path, PathBuf}; - use std::sync::Arc; - use std::time::Duration; - use uuid::Uuid; + /// Runtime cache of session_id -> effective workspace path. + /// Populated on session create/restore and used to restore evicted sessions + /// or resolve workspace-bound operations that only receive a session_id. + /// This cache is intentionally retained across memory eviction, but should + /// be cleared when a session is explicitly deleted. + session_workspace_index: Arc>, - struct TestWorkspace { - path: PathBuf, - } + /// Sub-components + context_store: Arc, + evidence_ledger: Arc, + persistence_manager: Arc, - impl TestWorkspace { - fn new() -> Self { - let path = std::env::temp_dir() - .join(format!("bitfun-session-restore-test-{}", Uuid::new_v4())); - std::fs::create_dir_all(&path).expect("test workspace should be created"); - Self { path } - } + /// Configuration + config: SessionManagerConfig, +} - fn path(&self) -> &Path { - &self.path +impl SessionManager { + async fn resolve_model_context_window(model_id: &str) -> Option { + let trimmed = model_id.trim(); + if trimmed.is_empty() || trimmed == "auto" || trimmed == "default" { + return None; } + + let config_service = get_global_config_service().await.ok()?; + let ai_config: crate::service::config::types::AIConfig = + config_service.get_config(Some("ai")).await.ok()?; + let resolved_model_id = ai_config.resolve_model_selection(trimmed)?; + + ai_config + .models + .iter() + .find(|model| model.id == resolved_model_id) + .and_then(|model| model.context_window) + .map(|tokens| tokens as usize) } - impl Drop for TestWorkspace { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.path); + fn normalize_session_title_input(title: &str) -> BitFunResult { + let trimmed = title.trim(); + if trimmed.is_empty() { + return Err(BitFunError::validation( + "Session title must not be empty".to_string(), + )); } - } - fn test_manager(persistence_manager: Arc) -> SessionManager { - SessionManager::new( - Arc::new(SessionContextStore::new()), - persistence_manager, - SessionManagerConfig { - max_active_sessions: 100, - session_idle_timeout: Duration::from_secs(3600), - auto_save_interval: Duration::from_secs(300), - enable_persistence: true, - }, - ) + Ok(trimmed.to_string()) } - fn in_memory_test_manager() -> SessionManager { - let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), - ); - SessionManager::new( - Arc::new(SessionContextStore::new()), - persistence_manager, - SessionManagerConfig { - max_active_sessions: 100, - session_idle_timeout: Duration::from_secs(3600), - auto_save_interval: Duration::from_secs(300), - enable_persistence: false, - }, - ) + fn normalize_whitespace(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") } - #[tokio::test] - async fn reset_session_state_if_processing_ignores_a_newer_turn() { - let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), - ); - session.state = SessionState::Processing { - current_turn_id: "turn-2".to_string(), - phase: ProcessingPhase::Thinking, - }; - manager.sessions.insert(session_id.clone(), session); + fn truncate_chars(value: &str, max_length: usize) -> String { + value.chars().take(max_length).collect() + } - manager.reset_session_state_if_processing(&session_id, "turn-1"); + fn fallback_session_title(user_message: &str, max_length: usize) -> String { + let max_length = max_length.max(1); + let normalized = Self::normalize_whitespace(user_message); - let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(matches!( - session.state, - SessionState::Processing { - ref current_turn_id, - .. - } if current_turn_id == "turn-2" - )); - } + if normalized.is_empty() { + return Self::truncate_chars("New Session", max_length); + } - #[tokio::test] - async fn reset_session_state_if_processing_resets_the_matching_turn() { - let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), - ); - session.state = SessionState::Processing { - current_turn_id: "turn-1".to_string(), - phase: ProcessingPhase::Thinking, - }; - manager.sessions.insert(session_id.clone(), session); + let truncated_chars: Vec = normalized.chars().take(max_length).collect(); + if normalized.chars().count() <= max_length { + return truncated_chars.iter().collect(); + } - manager.reset_session_state_if_processing(&session_id, "turn-1"); + let sentence_break_chars = ['。', '!', '?', ';', '.', '!', '?']; + let break_chars = ['。', '!', '?', ';', '.', '!', '?', ',', ',', ' ']; + let min_break_index = max_length / 2; + let mut best_break_index: Option = None; - let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(matches!(session.state, SessionState::Idle)); - } + for (idx, ch) in truncated_chars.iter().enumerate() { + if break_chars.contains(ch) && idx > min_break_index { + best_break_index = Some(idx); + } + } - #[tokio::test] - async fn update_session_state_for_turn_if_processing_ignores_a_newer_turn() { - let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), - ); - session.state = SessionState::Processing { - current_turn_id: "turn-2".to_string(), - phase: ProcessingPhase::Thinking, - }; - manager.sessions.insert(session_id.clone(), session); + if let Some(idx) = best_break_index { + let candidate: String = truncated_chars[..=idx].iter().collect(); + if candidate + .chars() + .last() + .map(|ch| sentence_break_chars.contains(&ch)) + .unwrap_or(false) + { + return candidate; + } - let updated = manager - .update_session_state_for_turn_if_processing(&session_id, "turn-1", SessionState::Idle) - .await - .expect("conditional state update should not fail"); + return format!("{}...", candidate.trim_end()); + } - let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(!updated); - assert!(matches!( - session.state, - SessionState::Processing { - ref current_turn_id, - .. - } if current_turn_id == "turn-2" - )); + let truncated: String = truncated_chars.iter().collect(); + format!("{truncated}...") } - #[tokio::test] - async fn update_session_state_for_turn_if_processing_updates_matching_turn() { - let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), - ); - session.state = SessionState::Processing { - current_turn_id: "turn-1".to_string(), - phase: ProcessingPhase::Thinking, + fn paginate_messages( + messages: &[Message], + limit: usize, + before_message_id: Option<&str>, + ) -> (Vec, bool) { + if messages.is_empty() { + return (vec![], false); + } + + let end_idx = if let Some(before_id) = before_message_id { + messages.iter().position(|m| m.id == before_id).unwrap_or(0) + } else { + messages.len() }; - manager.sessions.insert(session_id.clone(), session); - let updated = manager - .update_session_state_for_turn_if_processing(&session_id, "turn-1", SessionState::Idle) - .await - .expect("conditional state update should not fail"); + if end_idx == 0 { + return (vec![], false); + } - let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(updated); - assert!(matches!(session.state, SessionState::Idle)); + let start_idx = end_idx.saturating_sub(limit); + let has_more = start_idx > 0; + + (messages[start_idx..end_idx].to_vec(), has_more) } - #[tokio::test] - async fn restore_session_resets_processing_state_without_marking_unread_completion() { - let workspace = TestWorkspace::new(); - let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), - ); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Legacy processing session".to_string(), - "agent".to_string(), - SessionConfig { - workspace_path: Some(workspace.path().to_string_lossy().to_string()), - ..Default::default() - }, - ); - session.state = SessionState::Processing { - current_turn_id: "turn-1".to_string(), - phase: ProcessingPhase::Thinking, - }; + fn session_workspace_from_config(config: &SessionConfig) -> Option { + config.workspace_path.as_ref().map(PathBuf::from) + } - persistence_manager - .save_session(workspace.path(), &session) - .await - .expect("session should save"); - persistence_manager - .save_session_state(workspace.path(), &session_id, &session.state) - .await - .expect("processing state should save"); + fn should_persist_session_kind(kind: SessionKind) -> bool { + !matches!(kind, SessionKind::Subagent) + } - let manager = test_manager(persistence_manager.clone()); - let restored = manager - .restore_session(workspace.path(), &session_id) - .await - .expect("session should restore"); - let metadata = persistence_manager - .load_session_metadata(workspace.path(), &session_id) - .await - .expect("metadata should load") - .expect("metadata should exist"); + fn should_persist_session(session: &Session) -> bool { + Self::should_persist_session_kind(session.kind) + } - assert!(matches!(restored.state, SessionState::Idle)); - assert_eq!(metadata.unread_completion, None); + pub fn should_persist_session_id(&self, session_id: &str) -> bool { + self.config.enable_persistence + && self + .sessions + .get(session_id) + .map(|session| Self::should_persist_session(&session)) + .unwrap_or(true) } - #[tokio::test] - async fn restore_session_view_loads_turns_without_restoring_runtime_context() { - let workspace = TestWorkspace::new(); - let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), - ); - let manager = test_manager(persistence_manager.clone()); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Large history".to_string(), - "agent".to_string(), - SessionConfig { - workspace_path: Some(workspace.path().to_string_lossy().to_string()), - ..Default::default() - }, - ); - session.dialog_turn_ids = vec!["turn-1".to_string()]; + /// Resolve the effective storage path for a session's workspace. + async fn effective_workspace_path_from_config(config: &SessionConfig) -> Option { + let workspace_path = config.workspace_path.as_ref()?; + let identity = + crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( + workspace_path, + config.remote_connection_id.as_deref(), + config.remote_ssh_host.as_deref(), + ) + .await?; - persistence_manager - .save_session(workspace.path(), &session) - .await - .expect("session should save"); - let turn = DialogTurnData::new( - "turn-1".to_string(), - 0, - session_id.clone(), - UserMessageData { - id: "turn-1-user".to_string(), - content: "hello".to_string(), - timestamp: 1, - metadata: None, - }, - ); - persistence_manager - .save_dialog_turn(workspace.path(), &turn) - .await - .expect("turn should save"); - persistence_manager - .save_turn_context_snapshot( - workspace.path(), - &session_id, - 0, - &[Message::user("snapshot prompt".to_string())], + if identity.hostname + == crate::service::remote_ssh::workspace_state::LOCAL_WORKSPACE_SSH_HOST + { + Some(PathBuf::from(identity.logical_workspace_path())) + } else if identity.hostname == "_unresolved" { + Some( + crate::service::remote_ssh::workspace_state::unresolved_remote_session_storage_dir( + identity.remote_connection_id.as_deref().unwrap_or_default(), + identity.logical_workspace_path(), + ), ) - .await - .expect("context snapshot should save"); + } else { + Some(identity.session_storage_path()) + } + } - let (view_session, turns) = manager - .restore_session_view(workspace.path(), &session_id) - .await - .expect("session view should restore"); + #[allow(dead_code)] + fn session_workspace_path(&self, session_id: &str) -> Option { + self.sessions + .get(session_id) + .and_then(|session| Self::session_workspace_from_config(&session.config)) + } - assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); - assert_eq!(turns.len(), 1); - assert!(manager.get_session(&session_id).is_none()); - assert!(manager - .context_store - .get_context_messages(&session_id) - .is_empty()); + /// Resolve the effective storage path for a session by ID. + /// For remote workspaces, maps the remote path to a local session storage path. + async fn effective_session_workspace_path(&self, session_id: &str) -> Option { + let config = self.sessions.get(session_id)?.config.clone(); + Self::effective_workspace_path_from_config(&config).await } - #[tokio::test] - async fn restore_session_view_preserves_full_visible_tool_result_payload() { - let workspace = TestWorkspace::new(); - let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), - ); - let manager = test_manager(persistence_manager.clone()); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "History with tool output".to_string(), - "agent".to_string(), - SessionConfig { - workspace_path: Some(workspace.path().to_string_lossy().to_string()), - ..Default::default() - }, - ); - session.dialog_turn_ids = vec!["turn-1".to_string()]; + /// Resolve the logical workspace path bound to a session. + /// + /// This prefers the in-memory session config, then the persisted metadata + /// reachable via the session workspace index, and finally scans tracked + /// workspaces known to the global workspace service ordered by recent access. + pub async fn resolve_session_workspace_path(&self, session_id: &str) -> Option { + if let Some(workspace_path) = self + .get_session(session_id) + .and_then(|session| session.config.workspace_path) + .filter(|path| !path.is_empty()) + { + return Some(PathBuf::from(workspace_path)); + } - persistence_manager - .save_session(workspace.path(), &session) - .await - .expect("session should save"); + let indexed_workspace_path = self + .session_workspace_index + .get(session_id) + .map(|entry| entry.clone()); + if let Some(workspace_path) = indexed_workspace_path { + match self + .persistence_manager + .load_session_metadata(&workspace_path, session_id) + .await + { + Ok(Some(metadata)) => { + if let Some(bound_workspace) = + metadata.workspace_path.filter(|path| !path.is_empty()) + { + return Some(PathBuf::from(bound_workspace)); + } + return Some(workspace_path); + } + Ok(None) => {} + Err(err) => { + debug!( + "Failed to load indexed session metadata while resolving workspace: session_id={} workspace={} error={}", + session_id, + workspace_path.display(), + err + ); + } + } + } - let visible_output = "complete visible output ".repeat(128); - let assistant_output = "assistant visible summary ".repeat(16); - let mut turn = DialogTurnData::new( - "turn-1".to_string(), - 0, - session_id.clone(), - UserMessageData { - id: "turn-1-user".to_string(), - content: "show full output".to_string(), - timestamp: 1, - metadata: None, - }, - ); - turn.model_rounds.push(ModelRoundData { - id: "round-1".to_string(), - turn_id: "turn-1".to_string(), - round_index: 0, - timestamp: 1, - text_items: vec![], - tool_items: vec![ToolItemData { - id: "tool-1".to_string(), - tool_name: "Bash".to_string(), - tool_call: ToolCallData { - id: "call-1".to_string(), - input: json!({ "command": "printf output" }), - }, - tool_result: Some(ToolResultData { - result: json!({ - "stdout": visible_output, - "nested": { - "stderr": "also visible", - }, - }), - success: true, - result_for_assistant: Some(assistant_output.clone()), - error: None, - duration_ms: Some(1), - }), - ai_intent: None, - start_time: 1, - end_time: Some(2), - duration_ms: Some(1), - queue_wait_ms: None, - preflight_ms: None, - confirmation_wait_ms: None, - execution_ms: None, - order_index: None, - is_subagent_item: None, - parent_task_tool_id: None, - subagent_session_id: None, - subagent_model_id: None, - subagent_model_alias: None, - status: Some("completed".to_string()), - interruption_reason: None, - }], - thinking_items: vec![], - start_time: 1, - end_time: Some(2), - duration_ms: Some(1), - provider_id: None, - model_id: None, - model_alias: None, - first_chunk_ms: None, - first_visible_output_ms: None, - stream_duration_ms: None, - attempt_count: None, - failure_category: None, - token_details: None, - status: "completed".to_string(), - }); - persistence_manager - .save_dialog_turn(workspace.path(), &turn) - .await - .expect("turn should save"); + let workspace_service = get_global_workspace_service()?; + let mut workspaces = workspace_service.list_workspace_infos().await; + workspaces.sort_by(|left, right| right.last_accessed.cmp(&left.last_accessed)); + let candidates: Vec = workspaces + .into_iter() + .map(|workspace| workspace.root_path) + .collect(); - let (view_session, turns) = manager - .restore_session_view(workspace.path(), &session_id) - .await - .expect("session view should restore"); + for workspace_path in candidates { + match self + .persistence_manager + .load_session_metadata(&workspace_path, session_id) + .await + { + Ok(Some(metadata)) => { + if let Some(bound_workspace) = + metadata.workspace_path.filter(|path| !path.is_empty()) + { + return Some(PathBuf::from(bound_workspace)); + } + return Some(workspace_path); + } + Ok(None) => {} + Err(err) => { + debug!( + "Failed to load session metadata while resolving workspace: session_id={} workspace={} error={}", + session_id, + workspace_path.display(), + err + ); + } + } + } - let restored_result = turns[0].model_rounds[0].tool_items[0] - .tool_result - .as_ref() - .expect("tool result should be preserved"); - assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); - assert_eq!( - restored_result.result["stdout"].as_str(), - Some(visible_output.as_str()) - ); - assert_eq!( - restored_result.result["nested"]["stderr"].as_str(), - Some("also visible") - ); - assert_eq!( - restored_result.result_for_assistant.as_deref(), - Some(assistant_output.as_str()) - ); - assert!(manager.get_session(&session_id).is_none()); + None } - #[tokio::test] - async fn rollback_context_deletes_persisted_turns_from_target() { - let workspace = TestWorkspace::new(); - let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), - ); - let manager = test_manager(persistence_manager.clone()); - let session = manager - .create_session( - "Rollback session".to_string(), - "agent".to_string(), - SessionConfig { - workspace_path: Some(workspace.path().to_string_lossy().to_string()), - ..Default::default() - }, - ) - .await - .expect("session should create"); + fn build_messages_from_turns(turns: &[DialogTurnData]) -> Vec { + let mut messages = Vec::new(); - for index in 0..3 { - let turn = DialogTurnData::new( - format!("turn-{index}"), - index, - session.session_id.clone(), - UserMessageData { - id: format!("turn-{index}-user"), - content: format!("prompt {index}"), - timestamp: index as u64, - metadata: None, - }, + for turn in turns { + if !turn.kind.is_model_visible() { + continue; + } + + let user_message = if let Some(metadata) = &turn.user_message.metadata { + let images = metadata + .get("images") + .and_then(|value| value.as_array()) + .map(|values| { + values + .iter() + .map(|value| ImageContextData { + id: value + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + image_path: value + .get("image_path") + .and_then(|v| v.as_str()) + .map(str::to_string), + data_url: value + .get("data_url") + .and_then(|v| v.as_str()) + .map(str::to_string), + mime_type: value + .get("mime_type") + .and_then(|v| v.as_str()) + .unwrap_or("image/png") + .to_string(), + metadata: Some(value.clone()), + }) + .collect::>() + }) + .unwrap_or_default(); + + if images.is_empty() { + Message::user(turn.user_message.content.clone()) + } else { + Message::user_multimodal(turn.user_message.content.clone(), images) + } + } else { + Message::user(turn.user_message.content.clone()) + }; + messages.push( + user_message + .with_turn_id(turn.turn_id.clone()) + .with_semantic_kind(MessageSemanticKind::ActualUserInput), ); - persistence_manager - .save_dialog_turn(workspace.path(), &turn) - .await - .expect("turn should save"); - } - { - let mut active = manager - .sessions - .get_mut(&session.session_id) - .expect("session should be active"); - active.dialog_turn_ids = vec![ - "turn-0".to_string(), - "turn-1".to_string(), - "turn-2".to_string(), - ]; - } - persistence_manager - .save_turn_context_snapshot( - workspace.path(), - &session.session_id, - 0, - &[crate::agentic::core::Message::user("prompt 0".to_string())], - ) - .await - .expect("snapshot 0 should save"); - persistence_manager - .save_turn_context_snapshot( - workspace.path(), - &session.session_id, - 1, - &[ - crate::agentic::core::Message::user("prompt 0".to_string()), - crate::agentic::core::Message::user("prompt 1".to_string()), - ], - ) - .await - .expect("snapshot 1 should save"); + let assistant_text = turn + .model_rounds + .iter() + .flat_map(|round| round.text_items.iter()) + .map(|item| item.content.clone()) + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join("\n\n"); - manager - .rollback_context_to_turn_start(workspace.path(), &session.session_id, 1) - .await - .expect("rollback should succeed"); + let assistant_thinking = turn + .model_rounds + .iter() + .flat_map(|round| round.thinking_items.iter()) + .map(|item| item.content.clone()) + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join("\n\n"); - let turns = persistence_manager - .load_session_turns(workspace.path(), &session.session_id) - .await - .expect("turns should load"); - assert_eq!(turns.len(), 1); - assert_eq!(turns[0].user_message.content, "prompt 0"); - assert!(persistence_manager - .load_turn_context_snapshot(workspace.path(), &session.session_id, 1) - .await - .expect("snapshot load should succeed") - .is_none()); + let has_text = !assistant_text.trim().is_empty(); + let has_thinking = !assistant_thinking.trim().is_empty(); - manager.sessions.remove(&session.session_id); - let restored = manager - .restore_session(workspace.path(), &session.session_id) - .await - .expect("session should restore"); - assert_eq!(restored.dialog_turn_ids, vec!["turn-0".to_string()]); - assert_eq!( - manager - .context_store - .get_context_messages(&session.session_id) - .len(), - 1 - ); + if has_text || has_thinking { + let reasoning_content = if has_thinking { + Some(assistant_thinking) + } else { + None + }; + messages.push( + Message::assistant_with_reasoning( + reasoning_content, + assistant_text, + Vec::new(), + ) + .with_turn_id(turn.turn_id.clone()), + ); + } + } - let metadata = persistence_manager - .load_session_metadata(workspace.path(), &session.session_id) - .await - .expect("metadata should load") - .expect("metadata should exist"); - assert_eq!(metadata.turn_count, 1); + messages } - #[test] - fn build_messages_from_turns_skips_model_invisible_turns() { - use crate::service::session::{DialogTurnData, DialogTurnKind, UserMessageData}; + async fn rebuild_messages_from_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult> { + let turns = self + .persistence_manager + .load_session_turns(workspace_path, session_id) + .await?; + Ok(Self::build_messages_from_turns(&turns)) + } - let turns = vec![ - DialogTurnData::new( - "turn-1".to_string(), - 0, - "session-1".to_string(), - UserMessageData { - id: "user-1".to_string(), - content: "hello".to_string(), - timestamp: 1, - metadata: None, - }, - ), - DialogTurnData::new_with_kind( - DialogTurnKind::ManualCompaction, - "turn-2".to_string(), - 1, - "session-1".to_string(), - UserMessageData { - id: "user-2".to_string(), - content: "/compact".to_string(), - timestamp: 2, - metadata: None, - }, - ), - DialogTurnData::new_with_kind( - DialogTurnKind::LocalCommand, - "turn-3".to_string(), - 2, - "session-1".to_string(), - UserMessageData { - id: "user-3".to_string(), - content: "# Session Usage Report".to_string(), - timestamp: 3, - metadata: Some(serde_json::json!({ - "localCommandKind": "usage_report", - "modelVisible": false - })), - }, - ), - ]; + /// Persist the current runtime context by overwriting `snapshots/context-{turn_index}.json`. + /// + /// Save timing is intentionally tied to semantic context changes rather than token chunks: + /// - after a turn starts and the user message enters runtime context + /// - after assistant/tool messages are appended to runtime context + /// - after compression replaces runtime context + /// - once more when a turn completes or fails + /// + /// This is still a best-effort multi-file persistence flow, not a transactional commit. + /// `session.json`, `turns/turn-*.json`, and `snapshots/context-*.json` may be briefly out of + /// sync if the process crashes between writes, so restore logic must tolerate partial updates. + async fn persist_context_snapshot_for_turn_best_effort( + &self, + session_id: &str, + turn_index: usize, + reason: &str, + ) { + if !self.should_persist_session_id(session_id) { + return; + } - let messages = SessionManager::build_messages_from_turns(&turns); + let Some(workspace_path) = self.effective_session_workspace_path(session_id).await else { + debug!( + "Skipping context snapshot persistence because workspace path is unavailable: session_id={}, turn_index={}, reason={}", + session_id, turn_index, reason + ); + return; + }; - assert_eq!(messages.len(), 1); - assert!(messages[0].is_actual_user_message()); + let context_messages = self.context_store.get_context_messages(session_id); + if let Err(err) = self + .persistence_manager + .save_turn_context_snapshot(&workspace_path, session_id, turn_index, &context_messages) + .await + { + warn!( + "failed to persist context snapshot: session_id={}, turn_index={}, reason={}, err={}", + session_id, turn_index, reason, err + ); + } } - #[test] - fn fallback_session_title_uses_sentence_break_when_available() { - let title = SessionManager::fallback_session_title( - "Fix the flaky integration test. Add logging for retries.", - 20, - ); + async fn persist_current_turn_context_snapshot_best_effort( + &self, + session_id: &str, + reason: &str, + ) { + let Some(turn_index) = self + .sessions + .get(session_id) + .and_then(|session| session.dialog_turn_ids.len().checked_sub(1)) + else { + debug!( + "Skipping current-turn context snapshot because no turn is active: session_id={}, reason={}", + session_id, reason + ); + return; + }; - assert_eq!(title, "Fix the flaky..."); + self.persist_context_snapshot_for_turn_best_effort(session_id, turn_index, reason) + .await; } - #[test] - fn fallback_session_title_appends_ellipsis_when_truncated_without_sentence_break() { - let title = SessionManager::fallback_session_title( - "Implement session title generation fallback", - 12, - ); + pub fn new( + context_store: Arc, + persistence_manager: Arc, + config: SessionManagerConfig, + ) -> Self { + let enable_persistence = config.enable_persistence; - assert_eq!(title, "Implement..."); - } + let manager = Self { + sessions: Arc::new(DashMap::new()), + session_workspace_index: Arc::new(DashMap::new()), + context_store, + evidence_ledger: Arc::new(SessionEvidenceLedger::new()), + persistence_manager, + config, + }; - #[test] - fn fallback_session_title_uses_default_for_blank_input() { - let title = SessionManager::fallback_session_title(" ", 20); + // Start background tasks + if enable_persistence { + manager.spawn_auto_save_task(); + } + manager.spawn_cleanup_task(); + manager.spawn_model_reconciliation_listener(); - assert_eq!(title, "New Session"); + manager } - #[tokio::test] - async fn records_subagent_partial_timeout_in_evidence_ledger() { - let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), - ); - let manager = test_manager(persistence_manager); - - let event = manager.record_subagent_partial_timeout( - "session-a", - "turn-a", - "ReviewSecurity", - "Found token logging before timeout.", - Some("timeout"), - ); - - assert!(!event.event_id.is_empty()); - let events = manager.evidence_events_for_turn("session-a", "turn-a"); - assert_eq!(events, vec![event.clone()]); - let summary = manager.evidence_summary_for_session("session-a", 10); - assert_eq!(summary.partial_subagent_results.len(), 1); - assert_eq!(summary.partial_subagent_results[0].event_id, event.event_id); + pub fn append_evidence_event(&self, event: EvidenceLedgerEvent) -> EvidenceLedgerEvent { + self.evidence_ledger.append(event) } -} - -/// Session manager -pub struct SessionManager { - /// Active sessions in memory - sessions: Arc>, - - /// Persistent index of session_id -> effective workspace path. - /// Populated on session create/restore; NOT cleared on memory eviction. - /// Allows commands that only receive a session_id (e.g. update_session_model_id) - /// to restore an evicted session without requiring the caller to supply a path. - session_workspace_index: Arc>, - - /// Sub-components - context_store: Arc, - evidence_ledger: Arc, - persistence_manager: Arc, - - /// Configuration - config: SessionManagerConfig, -} - -impl SessionManager { - async fn resolve_model_context_window(model_id: &str) -> Option { - let trimmed = model_id.trim(); - if trimmed.is_empty() || trimmed == "auto" || trimmed == "default" { - return None; - } - - let config_service = get_global_config_service().await.ok()?; - let ai_config: crate::service::config::types::AIConfig = - config_service.get_config(Some("ai")).await.ok()?; - let resolved_model_id = ai_config.resolve_model_selection(trimmed)?; - ai_config - .models - .iter() - .find(|model| model.id == resolved_model_id) - .and_then(|model| model.context_window) - .map(|tokens| tokens as usize) + pub fn record_checkpoint_created( + &self, + session_id: &str, + turn_id: &str, + tool_name: &str, + target: &str, + checkpoint: EvidenceLedgerCheckpoint, + ) -> EvidenceLedgerEvent { + self.append_evidence_event(EvidenceLedgerEvent::checkpoint_created( + session_id, turn_id, tool_name, target, checkpoint, + )) } - fn normalize_session_title_input(title: &str) -> BitFunResult { - let trimmed = title.trim(); - if trimmed.is_empty() { - return Err(BitFunError::validation( - "Session title must not be empty".to_string(), - )); - } - - Ok(trimmed.to_string()) + pub fn evidence_events_for_turn( + &self, + session_id: &str, + turn_id: &str, + ) -> Vec { + self.evidence_ledger.events_for_turn(session_id, turn_id) } - fn normalize_whitespace(value: &str) -> String { - value.split_whitespace().collect::>().join(" ") + pub fn evidence_summary_for_session( + &self, + session_id: &str, + limit: usize, + ) -> EvidenceLedgerSummary { + self.evidence_ledger.summary_for_session(session_id, limit) } - fn truncate_chars(value: &str, max_length: usize) -> String { - value.chars().take(max_length).collect() + pub fn compression_contract_for_session( + &self, + session_id: &str, + limit: usize, + ) -> Option { + let contract: CompressionContract = + self.evidence_summary_for_session(session_id, limit).into(); + (!contract.is_empty()).then_some(contract) } - fn fallback_session_title(user_message: &str, max_length: usize) -> String { - let max_length = max_length.max(1); - let normalized = Self::normalize_whitespace(user_message); + pub fn record_subagent_partial_timeout( + &self, + session_id: &str, + turn_id: &str, + subagent_type: &str, + partial_output: &str, + error_kind: Option<&str>, + ) -> EvidenceLedgerEvent { + let summary = format!( + "Subagent {} timed out after producing partial output.", + subagent_type + ); + let event = EvidenceLedgerEvent::new( + session_id, + turn_id, + "Task", + EvidenceLedgerTargetKind::Subagent, + subagent_type, + EvidenceLedgerEventStatus::PartialTimeout, + summary, + ) + .with_error_kind(error_kind.unwrap_or("timeout")) + .with_partial_output(partial_output); - if normalized.is_empty() { - return Self::truncate_chars("New Session", max_length); - } + self.append_evidence_event(event) + } - let truncated_chars: Vec = normalized.chars().take(max_length).collect(); - if normalized.chars().count() <= max_length { - return truncated_chars.iter().collect(); + /// Decide whether the given session model id is still usable. + /// + /// `model_id` is treated as "usable" when: + /// - it is a special selector (`auto` / `primary` / `fast` / `default` / + /// empty) — these are evaluated again at request time against + /// `default_models`, so their long-term validity is governed elsewhere; + /// - it resolves to a model that exists AND is enabled. + fn is_session_model_id_usable( + ai_config: &crate::service::config::types::AIConfig, + model_id: &str, + ) -> bool { + let trimmed = model_id.trim(); + if trimmed.is_empty() + || trimmed == "auto" + || trimmed == "default" + || trimmed == "primary" + || trimmed == "fast" + { + return true; } + ai_config.is_model_reference_active(trimmed) + } - let sentence_break_chars = ['。', '!', '?', ';', '.', '!', '?']; - let break_chars = ['。', '!', '?', ';', '.', '!', '?', ',', ',', ' ']; - let min_break_index = max_length / 2; - let mut best_break_index: Option = None; + /// Reset every active session whose bound model id is in + /// `invalidated_model_ids` back to `"auto"`. Persists the change and emits + /// `AgenticEvent::SessionModelAutoMigrated` for every migrated session so + /// the UI can refresh its model selector and surface a notice. + async fn migrate_sessions_off_invalidated_models( + &self, + invalidated_model_ids: &[String], + reason: &'static str, + ) { + if invalidated_model_ids.is_empty() { + return; + } + let invalid: HashSet<&str> = invalidated_model_ids.iter().map(String::as_str).collect(); - for (idx, ch) in truncated_chars.iter().enumerate() { - if break_chars.contains(ch) && idx > min_break_index { - best_break_index = Some(idx); - } + // Snapshot affected sessions first to avoid holding DashMap iterators + // across async writes. + let affected: Vec<(String, String)> = self + .sessions + .iter() + .filter_map(|entry| { + let session = entry.value(); + let current = session.config.model_id.as_deref()?.trim().to_string(); + if invalid.contains(current.as_str()) { + Some((session.session_id.clone(), current)) + } else { + None + } + }) + .collect(); + + if affected.is_empty() { + return; } - if let Some(idx) = best_break_index { - let candidate: String = truncated_chars[..=idx].iter().collect(); - if candidate - .chars() - .last() - .map(|ch| sentence_break_chars.contains(&ch)) - .unwrap_or(false) - { - return candidate; + for (session_id, previous_model_id) in affected { + if let Err(e) = self.update_session_model_id(&session_id, "auto").await { + warn!( + "Failed to auto-migrate session model after reconcile: session_id={}, previous={}, error={}", + session_id, previous_model_id, e + ); + continue; } + info!( + "Session model auto-migrated to 'auto': session_id={}, previous_model_id={}, reason={}", + session_id, previous_model_id, reason + ); - return format!("{}...", candidate.trim_end()); + if let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() { + coordinator + .emit_session_model_auto_migrated( + &session_id, + &previous_model_id, + "auto", + reason, + ) + .await; + } } - - let truncated: String = truncated_chars.iter().collect(); - format!("{truncated}...") } - fn paginate_messages( - messages: &[Message], - limit: usize, - before_message_id: Option<&str>, - ) -> (Vec, bool) { - if messages.is_empty() { - return (vec![], false); + /// Best-effort: drop cached AI clients for invalidated models so the next + /// request rebuilds against the reconciled config. + async fn invalidate_ai_clients_for_models(invalidated_model_ids: &[String]) { + if invalidated_model_ids.is_empty() { + return; + } + if let Ok(factory) = get_global_ai_client_factory().await { + for model_id in invalidated_model_ids { + factory.invalidate_model(model_id); + } } + } - let end_idx = if let Some(before_id) = before_message_id { - messages.iter().position(|m| m.id == before_id).unwrap_or(0) - } else { - messages.len() - }; + fn spawn_model_reconciliation_listener(&self) { + let sessions = self.sessions.clone(); + let session_workspace_index = self.session_workspace_index.clone(); + let context_store = self.context_store.clone(); + let evidence_ledger = self.evidence_ledger.clone(); + let persistence_manager = self.persistence_manager.clone(); + let manager_config = self.config.clone(); - if end_idx == 0 { - return (vec![], false); - } + tokio::spawn(async move { + let Some(mut receiver) = subscribe_config_updates() else { + debug!( + "SessionManager: config update subscription unavailable; skipping model reconciliation listener" + ); + return; + }; - let start_idx = end_idx.saturating_sub(limit); - let has_more = start_idx > 0; + // Re-build a thin handle that mirrors `self` for the listener loop. + // We can't move `self` into a 'static task, so we recreate the + // surface area we need from the cloned shared fields above. + let manager = Self { + sessions, + session_workspace_index, + context_store, + evidence_ledger, + persistence_manager, + config: manager_config, + }; - (messages[start_idx..end_idx].to_vec(), has_more) + loop { + match receiver.recv().await { + Ok(ConfigUpdateEvent::ModelsReconciled { + invalidated_model_ids, + .. + }) => { + Self::invalidate_ai_clients_for_models(&invalidated_model_ids).await; + manager + .migrate_sessions_off_invalidated_models( + &invalidated_model_ids, + "model_reconciled", + ) + .await; + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + debug!("SessionManager model reconciliation listener: channel closed"); + break; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + warn!( + "SessionManager model reconciliation listener lagged by {} events; continuing", + n + ); + } + } + } + }); } - fn session_workspace_from_config(config: &SessionConfig) -> Option { - config.workspace_path.as_ref().map(PathBuf::from) - } + // ============ Session CRUD ============ - fn should_persist_session_kind(kind: SessionKind) -> bool { - !matches!(kind, SessionKind::Subagent) + /// Create a new session + pub async fn create_session( + &self, + session_name: String, + agent_type: String, + config: SessionConfig, + ) -> BitFunResult { + self.create_session_with_id_and_details( + None, + session_name, + agent_type, + config, + None, + SessionKind::Standard, + ) + .await } - fn should_persist_session(session: &Session) -> bool { - Self::should_persist_session_kind(session.kind) + /// Create a new session (supports specifying session ID) + pub async fn create_session_with_id( + &self, + session_id: Option, + session_name: String, + agent_type: String, + config: SessionConfig, + ) -> BitFunResult { + self.create_session_with_id_and_details( + session_id, + session_name, + agent_type, + config, + None, + SessionKind::Standard, + ) + .await } - pub fn should_persist_session_id(&self, session_id: &str) -> bool { - self.config.enable_persistence - && self - .sessions - .get(session_id) - .map(|session| Self::should_persist_session(&session)) - .unwrap_or(true) + /// Create a new session (supports specifying session ID and creator identity) + pub async fn create_session_with_id_and_creator( + &self, + session_id: Option, + session_name: String, + agent_type: String, + config: SessionConfig, + created_by: Option, + ) -> BitFunResult { + self.create_session_with_id_and_details( + session_id, + session_name, + agent_type, + config, + created_by, + SessionKind::Standard, + ) + .await } - /// Resolve the effective storage path for a session's workspace. - async fn effective_workspace_path_from_config(config: &SessionConfig) -> Option { - let workspace_path = config.workspace_path.as_ref()?; - let identity = - crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( - workspace_path, - config.remote_connection_id.as_deref(), - config.remote_ssh_host.as_deref(), - ) - .await?; - - if identity.hostname - == crate::service::remote_ssh::workspace_state::LOCAL_WORKSPACE_SSH_HOST - { - Some(PathBuf::from(identity.logical_workspace_path())) - } else if identity.hostname == "_unresolved" { - Some( - crate::service::remote_ssh::workspace_state::unresolved_remote_session_storage_dir( - identity.remote_connection_id.as_deref().unwrap_or_default(), - identity.logical_workspace_path(), - ), - ) - } else { - Some(identity.session_storage_path()) - } - } + /// Create a new session with explicit kind. + pub async fn create_session_with_id_and_details( + &self, + session_id: Option, + session_name: String, + agent_type: String, + config: SessionConfig, + created_by: Option, + kind: SessionKind, + ) -> BitFunResult { + let _workspace_path = Self::session_workspace_from_config(&config).ok_or_else(|| { + BitFunError::Validation("Session workspace_path is required".to_string()) + })?; - #[allow(dead_code)] - fn session_workspace_path(&self, session_id: &str) -> Option { - self.sessions - .get(session_id) - .and_then(|session| Self::session_workspace_from_config(&session.config)) - } + let session_storage_path = Self::effective_workspace_path_from_config(&config) + .await + .ok_or_else(|| { + BitFunError::Validation("Session workspace_path is required".to_string()) + })?; - /// Resolve the effective storage path for a session by ID. - /// For remote workspaces, maps the remote path to a local session storage path. - async fn effective_session_workspace_path(&self, session_id: &str) -> Option { - let config = self.sessions.get(session_id)?.config.clone(); - Self::effective_workspace_path_from_config(&config).await - } + // Check session count limit + if self.sessions.len() >= self.config.max_active_sessions { + return Err(BitFunError::Validation(format!( + "Exceeded maximum session limit: {}", + self.config.max_active_sessions + ))); + } - fn build_messages_from_turns(turns: &[DialogTurnData]) -> Vec { - let mut messages = Vec::new(); + let mut session = if let Some(id) = session_id { + Session::new_with_id(id, session_name, agent_type.clone(), config) + } else { + Session::new(session_name, agent_type.clone(), config) + }; + session.created_by = created_by; + session.kind = kind; + let session_id = session.session_id.clone(); - for turn in turns { - if !turn.kind.is_model_visible() { - continue; - } + // 1. Add to memory + self.sessions.insert(session_id.clone(), session.clone()); + self.session_workspace_index + .insert(session_id.clone(), session_storage_path.clone()); - let user_message = if let Some(metadata) = &turn.user_message.metadata { - let images = metadata - .get("images") - .and_then(|value| value.as_array()) - .map(|values| { - values - .iter() - .map(|value| ImageContextData { - id: value - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - image_path: value - .get("image_path") - .and_then(|v| v.as_str()) - .map(str::to_string), - data_url: value - .get("data_url") - .and_then(|v| v.as_str()) - .map(str::to_string), - mime_type: value - .get("mime_type") - .and_then(|v| v.as_str()) - .unwrap_or("image/png") - .to_string(), - metadata: Some(value.clone()), - }) - .collect::>() - }) - .unwrap_or_default(); + // 2. Initialize the in-memory context cache. + self.context_store.create_session(&session_id); - if images.is_empty() { - Message::user(turn.user_message.content.clone()) - } else { - Message::user_multimodal(turn.user_message.content.clone(), images) - } - } else { - Message::user(turn.user_message.content.clone()) - }; - messages.push( - user_message - .with_turn_id(turn.turn_id.clone()) - .with_semantic_kind(MessageSemanticKind::ActualUserInput), - ); + // 3. Persist to local path (handles remote workspaces correctly) + // Use the local `session` directly -- no need to re-fetch from DashMap, + // which would hold a Ref guard across the async save_session call. + if self.config.enable_persistence && Self::should_persist_session(&session) { + self.persistence_manager + .save_session(&session_storage_path, &session) + .await?; + } - let assistant_text = turn - .model_rounds - .iter() - .flat_map(|round| round.text_items.iter()) - .map(|item| item.content.clone()) - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join("\n\n"); + info!("Session created: session_name={}", session.session_name); - let assistant_thinking = turn - .model_rounds - .iter() - .flat_map(|round| round.thinking_items.iter()) - .map(|item| item.content.clone()) - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join("\n\n"); + Ok(session) + } - let has_text = !assistant_text.trim().is_empty(); - let has_thinking = !assistant_thinking.trim().is_empty(); + /// Get session + pub fn get_session(&self, session_id: &str) -> Option { + self.sessions.get(session_id).map(|s| s.clone()) + } - if has_text || has_thinking { - let reasoning_content = if has_thinking { - Some(assistant_thinking) - } else { - None - }; - messages.push( - Message::assistant_with_reasoning( - reasoning_content, - assistant_text, - Vec::new(), - ) - .with_turn_id(turn.turn_id.clone()), + /// Synchronously reset session state to Idle if it is currently Processing + /// the expected turn. + /// + /// This is an in-memory-only operation intended for RAII-style cleanup in + /// spawn tasks. Because `Drop::drop` is synchronous we cannot do async + /// file I/O here, but that is acceptable: the in-memory state is the + /// source of truth at runtime, and `restore_session` already resets any + /// non-Idle persisted state to Idle on application restart. + pub fn reset_session_state_if_processing(&self, session_id: &str, expected_turn_id: &str) { + if let Some(mut session) = self.sessions.get_mut(session_id) { + if matches!( + &session.state, + SessionState::Processing { + current_turn_id, + .. + } if current_turn_id == expected_turn_id + ) { + debug!( + "RAII guard resetting stuck Processing state to Idle: session_id={}, turn_id={}", + session_id, expected_turn_id ); + session.state = SessionState::Idle; + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); } } - - messages } - async fn rebuild_messages_from_turns( + /// Update session state + pub async fn update_session_state( &self, - workspace_path: &Path, session_id: &str, - ) -> BitFunResult> { - let turns = self - .persistence_manager - .load_session_turns(workspace_path, session_id) - .await?; - Ok(Self::build_messages_from_turns(&turns)) - } + new_state: SessionState, + ) -> BitFunResult<()> { + let effective_path = self.effective_session_workspace_path(session_id).await; - /// Persist the current runtime context by overwriting `snapshots/context-{turn_index}.json`. - /// - /// Save timing is intentionally tied to semantic context changes rather than token chunks: - /// - after a turn starts and the user message enters runtime context - /// - after assistant/tool messages are appended to runtime context - /// - after compression replaces runtime context - /// - once more when a turn completes or fails - /// - /// This is still a best-effort multi-file persistence flow, not a transactional commit. - /// `session.json`, `turns/turn-*.json`, and `snapshots/context-*.json` may be briefly out of - /// sync if the process crashes between writes, so restore logic must tolerate partial updates. - async fn persist_context_snapshot_for_turn_best_effort( - &self, - session_id: &str, - turn_index: usize, - reason: &str, - ) { - if !self.should_persist_session_id(session_id) { - return; - } + // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. + // Collect the data needed for persistence, then release the guard before doing I/O. + let should_persist = if let Some(mut session) = self.sessions.get_mut(session_id) { + session.state = new_state.clone(); + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); - let Some(workspace_path) = self.effective_session_workspace_path(session_id).await else { - debug!( - "Skipping context snapshot persistence because workspace path is unavailable: session_id={}, turn_index={}, reason={}", - session_id, turn_index, reason - ); - return; + let persist = self.config.enable_persistence && Self::should_persist_session(&session); + persist + } else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); }; + // RefMut guard released here -- DashMap shard lock is free. - let context_messages = self.context_store.get_context_messages(session_id); - if let Err(err) = self - .persistence_manager - .save_turn_context_snapshot(&workspace_path, session_id, turn_index, &context_messages) - .await - { - warn!( - "failed to persist context snapshot: session_id={}, turn_index={}, reason={}, err={}", - session_id, turn_index, reason, err - ); - } - } - - async fn persist_current_turn_context_snapshot_best_effort( - &self, + // Persist state changes outside the guard scope. + if should_persist { + if let Some(ref workspace_path) = effective_path { + self.persistence_manager + .save_session_state(workspace_path, session_id, &new_state) + .await?; + } + } + + debug!( + "Updated session state: session_id={}, state={:?}", + session_id, new_state + ); + + Ok(()) + } + + /// Update session state only when the session is still processing the + /// expected turn. Returns `true` when the state was updated. + pub async fn update_session_state_for_turn_if_processing( + &self, session_id: &str, - reason: &str, - ) { - let Some(turn_index) = self - .sessions - .get(session_id) - .and_then(|session| session.dialog_turn_ids.len().checked_sub(1)) - else { - debug!( - "Skipping current-turn context snapshot because no turn is active: session_id={}, reason={}", - session_id, reason + expected_turn_id: &str, + new_state: SessionState, + ) -> BitFunResult { + let effective_path = self.effective_session_workspace_path(session_id).await; + + let should_persist = if let Some(mut session) = self.sessions.get_mut(session_id) { + let owns_processing_turn = matches!( + &session.state, + SessionState::Processing { + current_turn_id, + .. + } if current_turn_id == expected_turn_id ); - return; - }; - self.persist_context_snapshot_for_turn_best_effort(session_id, turn_index, reason) - .await; - } + if !owns_processing_turn { + debug!( + "Skipped session state update for stale turn: session_id={}, expected_turn_id={}, current_state={:?}", + session_id, expected_turn_id, session.state + ); + return Ok(false); + } - pub fn new( - context_store: Arc, - persistence_manager: Arc, - config: SessionManagerConfig, - ) -> Self { - let enable_persistence = config.enable_persistence; + session.state = new_state.clone(); + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); - let manager = Self { - sessions: Arc::new(DashMap::new()), - session_workspace_index: Arc::new(DashMap::new()), - context_store, - evidence_ledger: Arc::new(SessionEvidenceLedger::new()), - persistence_manager, - config, + self.config.enable_persistence && Self::should_persist_session(&session) + } else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); }; - // Start background tasks - if enable_persistence { - manager.spawn_auto_save_task(); + if should_persist { + if let Some(ref workspace_path) = effective_path { + self.persistence_manager + .save_session_state(workspace_path, session_id, &new_state) + .await?; + } } - manager.spawn_cleanup_task(); - manager.spawn_model_reconciliation_listener(); - manager - } + debug!( + "Updated session state for turn: session_id={}, turn_id={}, state={:?}", + session_id, expected_turn_id, new_state + ); - pub fn append_evidence_event(&self, event: EvidenceLedgerEvent) -> EvidenceLedgerEvent { - self.evidence_ledger.append(event) + Ok(true) } - pub fn record_checkpoint_created( - &self, - session_id: &str, - turn_id: &str, - tool_name: &str, - target: &str, - checkpoint: EvidenceLedgerCheckpoint, - ) -> EvidenceLedgerEvent { - self.append_evidence_event(EvidenceLedgerEvent::checkpoint_created( - session_id, turn_id, tool_name, target, checkpoint, - )) - } + /// Update session title (in-memory + persistence) + pub async fn update_session_title(&self, session_id: &str, title: &str) -> BitFunResult<()> { + let normalized_title = Self::normalize_session_title_input(title)?; + let workspace_path = self.effective_session_workspace_path(session_id).await; - pub fn evidence_events_for_turn( - &self, - session_id: &str, - turn_id: &str, - ) -> Vec { - self.evidence_ledger.events_for_turn(session_id, turn_id) - } + { + let Some(mut session) = self.sessions.get_mut(session_id) else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + }; + session.session_name = normalized_title.clone(); + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } - pub fn evidence_summary_for_session( - &self, - session_id: &str, - limit: usize, - ) -> EvidenceLedgerSummary { - self.evidence_ledger.summary_for_session(session_id, limit) + if self.should_persist_session_id(session_id) { + let Some(workspace_path) = workspace_path.as_ref() else { + return Err(BitFunError::Session(format!( + "Workspace path is unavailable for session {}", + session_id + ))); + }; + // Clone the session data out of the DashMap guard before awaiting I/O. + let session_snapshot = { + let Some(session) = self.sessions.get(session_id) else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + }; + session.clone() + }; + // Ref guard released -- DashMap shard lock is free. + self.persistence_manager + .save_session(workspace_path, &session_snapshot) + .await?; + } + + info!( + "Session title updated: session_id={}, title={}", + session_id, normalized_title + ); + + Ok(()) } - pub fn compression_contract_for_session( + pub async fn update_session_title_if_current( &self, session_id: &str, - limit: usize, - ) -> Option { - let contract: CompressionContract = - self.evidence_summary_for_session(session_id, limit).into(); - (!contract.is_empty()).then_some(contract) + expected_current_title: &str, + title: &str, + ) -> BitFunResult { + let Some(session) = self.sessions.get(session_id) else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + }; + + if session.session_name != expected_current_title { + debug!( + "Skipping auto-generated title because current title changed: session_id={}, expected_title={}, current_title={}", + session_id, + expected_current_title, + session.session_name + ); + return Ok(false); + } + drop(session); + + self.update_session_title(session_id, title).await?; + Ok(true) } - pub fn record_subagent_partial_timeout( + /// Update session agent type (in-memory + persistence) + pub async fn update_session_agent_type( &self, session_id: &str, - turn_id: &str, - subagent_type: &str, - partial_output: &str, - error_kind: Option<&str>, - ) -> EvidenceLedgerEvent { - let summary = format!( - "Subagent {} timed out after producing partial output.", - subagent_type + agent_type: &str, + ) -> BitFunResult<()> { + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.agent_type = agent_type.to_string(); + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + } + + if self.should_persist_session_id(session_id) { + let effective_path = self.effective_session_workspace_path(session_id).await; + let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); + // Ref guard released -- DashMap shard lock is free. + if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + } + } + + debug!( + "Session agent type updated: session_id={}, agent_type={}", + session_id, agent_type ); - let event = EvidenceLedgerEvent::new( - session_id, - turn_id, - "Task", - EvidenceLedgerTargetKind::Subagent, - subagent_type, - EvidenceLedgerEventStatus::PartialTimeout, - summary, - ) - .with_error_kind(error_kind.unwrap_or("timeout")) - .with_partial_output(partial_output); - self.append_evidence_event(event) + Ok(()) } - /// Decide whether the given session model id is still usable. - /// - /// `model_id` is treated as "usable" when: - /// - it is a special selector (`auto` / `primary` / `fast` / `default` / - /// empty) — these are evaluated again at request time against - /// `default_models`, so their long-term validity is governed elsewhere; - /// - it resolves to a model that exists AND is enabled. - fn is_session_model_id_usable( - ai_config: &crate::service::config::types::AIConfig, - model_id: &str, - ) -> bool { - let trimmed = model_id.trim(); - if trimmed.is_empty() - || trimmed == "auto" - || trimmed == "default" - || trimmed == "primary" - || trimmed == "fast" - { - return true; - } - ai_config.is_model_reference_active(trimmed) - } - - /// Reset every active session whose bound model id is in - /// `invalidated_model_ids` back to `"auto"`. Persists the change and emits - /// `AgenticEvent::SessionModelAutoMigrated` for every migrated session so - /// the UI can refresh its model selector and surface a notice. - async fn migrate_sessions_off_invalidated_models( + /// Update session model id (in-memory + persistence) + pub async fn update_session_model_id( &self, - invalidated_model_ids: &[String], - reason: &'static str, - ) { - if invalidated_model_ids.is_empty() { - return; - } - let invalid: HashSet<&str> = invalidated_model_ids.iter().map(String::as_str).collect(); - - // Snapshot affected sessions first to avoid holding DashMap iterators - // across async writes. - let affected: Vec<(String, String)> = self - .sessions - .iter() - .filter_map(|entry| { - let session = entry.value(); - let current = session.config.model_id.as_deref()?.trim().to_string(); - if invalid.contains(current.as_str()) { - Some((session.session_id.clone(), current)) - } else { - None - } - }) - .collect(); - - if affected.is_empty() { - return; - } + session_id: &str, + model_id: &str, + ) -> BitFunResult<()> { + let resolved_context_window = Self::resolve_model_context_window(model_id).await; - for (session_id, previous_model_id) in affected { - if let Err(e) = self.update_session_model_id(&session_id, "auto").await { - warn!( - "Failed to auto-migrate session model after reconcile: session_id={}, previous={}, error={}", - session_id, previous_model_id, e + // If the session was evicted from memory (idle > 1h), try to restore it + // using the workspace path recorded when it was first created/restored. + if !self.sessions.contains_key(session_id) && self.config.enable_persistence { + let workspace_path = self + .session_workspace_index + .get(session_id) + .map(|entry| entry.clone()); + if let Some(workspace_path) = workspace_path { + debug!( + "Session evicted from memory, restoring for model update: session_id={}", + session_id ); - continue; + let _ = self.restore_session(&workspace_path, session_id).await; } - info!( - "Session model auto-migrated to 'auto': session_id={}, previous_model_id={}, reason={}", - session_id, previous_model_id, reason - ); + } - if let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() { - coordinator - .emit_session_model_auto_migrated( - &session_id, - &previous_model_id, - "auto", - reason, - ) - .await; + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.config.model_id = Some(model_id.to_string()); + if let Some(context_window) = resolved_context_window { + session.config.max_context_tokens = context_window; } + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); } - } - /// Best-effort: drop cached AI clients for invalidated models so the next - /// request rebuilds against the reconciled config. - async fn invalidate_ai_clients_for_models(invalidated_model_ids: &[String]) { - if invalidated_model_ids.is_empty() { - return; - } - if let Ok(factory) = get_global_ai_client_factory().await { - for model_id in invalidated_model_ids { - factory.invalidate_model(model_id); + if self.should_persist_session_id(session_id) { + let effective_path = self.effective_session_workspace_path(session_id).await; + let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); + // Ref guard released -- DashMap shard lock is free. + if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; } } - } - - fn spawn_model_reconciliation_listener(&self) { - let sessions = self.sessions.clone(); - let session_workspace_index = self.session_workspace_index.clone(); - let context_store = self.context_store.clone(); - let evidence_ledger = self.evidence_ledger.clone(); - let persistence_manager = self.persistence_manager.clone(); - let manager_config = self.config.clone(); - - tokio::spawn(async move { - let Some(mut receiver) = subscribe_config_updates() else { - debug!( - "SessionManager: config update subscription unavailable; skipping model reconciliation listener" - ); - return; - }; - // Re-build a thin handle that mirrors `self` for the listener loop. - // We can't move `self` into a 'static task, so we recreate the - // surface area we need from the cloned shared fields above. - let manager = Self { - sessions, - session_workspace_index, - context_store, - evidence_ledger, - persistence_manager, - config: manager_config, - }; + debug!( + "Session model id updated: session_id={}, model_id={}, max_context_tokens={:?}", + session_id, model_id, resolved_context_window + ); - loop { - match receiver.recv().await { - Ok(ConfigUpdateEvent::ModelsReconciled { - invalidated_model_ids, - .. - }) => { - Self::invalidate_ai_clients_for_models(&invalidated_model_ids).await; - manager - .migrate_sessions_off_invalidated_models( - &invalidated_model_ids, - "model_reconciled", - ) - .await; - } - Ok(_) => {} - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - debug!("SessionManager model reconciliation listener: channel closed"); - break; - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - warn!( - "SessionManager model reconciliation listener lagged by {} events; continuing", - n - ); - } - } - } - }); + Ok(()) } - // ============ Session CRUD ============ - - /// Create a new session - pub async fn create_session( - &self, - session_name: String, - agent_type: String, - config: SessionConfig, - ) -> BitFunResult { - self.create_session_with_id_and_details( - None, - session_name, - agent_type, - config, - None, - SessionKind::Standard, - ) - .await + /// Update session activity time + pub fn touch_session(&self, session_id: &str) { + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.last_activity_at = SystemTime::now(); + } } - /// Create a new session (supports specifying session ID) - pub async fn create_session_with_id( + /// Delete session (cascade delete all resources) + pub async fn delete_session( &self, - session_id: Option, - session_name: String, - agent_type: String, - config: SessionConfig, - ) -> BitFunResult { - self.create_session_with_id_and_details( + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { + let delete_started_at = Instant::now(); + debug!( + "Session deletion started: session_id={}, workspace_path={}, persistence_enabled={}", session_id, - session_name, - agent_type, - config, - None, - SessionKind::Standard, - ) - .await - } + workspace_path.display(), + self.config.enable_persistence + ); - /// Create a new session (supports specifying session ID and creator identity) - pub async fn create_session_with_id_and_creator( - &self, - session_id: Option, - session_name: String, - agent_type: String, - config: SessionConfig, - created_by: Option, - ) -> BitFunResult { - self.create_session_with_id_and_details( + // 1. Clean up snapshot system resources (including physical snapshot files) + let snapshot_stage_started_at = Instant::now(); + debug!( + "Session deletion stage starting: session_id={}, stage=snapshot_cleanup", + session_id + ); + if let Ok(snapshot_manager) = ensure_snapshot_manager_for_workspace(workspace_path) { + let snapshot_service = snapshot_manager.get_snapshot_service(); + let snapshot_service = snapshot_service.read().await; + if let Err(e) = snapshot_service.accept_session(session_id).await { + warn!("Failed to cleanup snapshot system resources: {}", e); + } else { + debug!( + "Snapshot system resources cleaned up: session_id={}", + session_id + ); + } + } + debug!( + "Session deletion stage completed: session_id={}, stage=snapshot_cleanup, duration_ms={}", session_id, - session_name, - agent_type, - config, - created_by, - SessionKind::Standard, - ) - .await - } - - /// Create a new session with explicit kind. - pub async fn create_session_with_id_and_details( - &self, - session_id: Option, - session_name: String, - agent_type: String, - config: SessionConfig, - created_by: Option, - kind: SessionKind, - ) -> BitFunResult { - let _workspace_path = Self::session_workspace_from_config(&config).ok_or_else(|| { - BitFunError::Validation("Session workspace_path is required".to_string()) - })?; + elapsed_ms_u64(snapshot_stage_started_at) + ); - let session_storage_path = Self::effective_workspace_path_from_config(&config) - .await - .ok_or_else(|| { - BitFunError::Validation("Session workspace_path is required".to_string()) - })?; + let context_stage_started_at = Instant::now(); + debug!( + "Session deletion stage starting: session_id={}, stage=context_store_delete", + session_id + ); + self.context_store.delete_session(session_id); + debug!( + "Session deletion stage completed: session_id={}, stage=context_store_delete, duration_ms={}", + session_id, + elapsed_ms_u64(context_stage_started_at) + ); - // Check session count limit - if self.sessions.len() >= self.config.max_active_sessions { - return Err(BitFunError::Validation(format!( - "Exceeded maximum session limit: {}", - self.config.max_active_sessions - ))); + // 2. Delete persisted data + if self.config.enable_persistence { + let persistence_stage_started_at = Instant::now(); + debug!( + "Session deletion stage starting: session_id={}, stage=persistence_delete", + session_id + ); + self.persistence_manager + .delete_session(workspace_path, session_id) + .await?; + debug!( + "Session deletion stage completed: session_id={}, stage=persistence_delete, duration_ms={}", + session_id, + elapsed_ms_u64(persistence_stage_started_at) + ); } - let mut session = if let Some(id) = session_id { - Session::new_with_id(id, session_name, agent_type.clone(), config) - } else { - Session::new(session_name, agent_type.clone(), config) - }; - session.created_by = created_by; - session.kind = kind; - let session_id = session.session_id.clone(); - - // 1. Add to memory - self.sessions.insert(session_id.clone(), session.clone()); - self.session_workspace_index - .insert(session_id.clone(), session_storage_path.clone()); - - // 2. Initialize the in-memory context cache. - self.context_store.create_session(&session_id); + if let Some(cron) = crate::service::cron::get_global_cron_service() { + let cron_stage_started_at = Instant::now(); + debug!( + "Session deletion stage starting: session_id={}, stage=cron_cleanup", + session_id + ); + match cron.delete_jobs_for_session(session_id).await { + Ok(removed) if removed > 0 => { + info!( + "Removed {} scheduled job(s) for deleted session_id={}", + removed, session_id + ); + } + Ok(_) => {} + Err(e) => { + warn!( + "Failed to remove scheduled jobs for deleted session_id={}: {}", + session_id, e + ); + } + } + debug!( + "Session deletion stage completed: session_id={}, stage=cron_cleanup, duration_ms={}", + session_id, + elapsed_ms_u64(cron_stage_started_at) + ); + } - // 3. Persist to local path (handles remote workspaces correctly) - // Use the local `session` directly -- no need to re-fetch from DashMap, - // which would hold a Ref guard across the async save_session call. - if self.config.enable_persistence && Self::should_persist_session(&session) { - self.persistence_manager - .save_session(&session_storage_path, &session) - .await?; + // 3. Clean up associated Terminal session + use crate::service::terminal::TerminalApi; + if let Ok(terminal_api) = TerminalApi::from_singleton() { + let binding = terminal_api.session_manager().binding(); + let terminal_stage_started_at = Instant::now(); + debug!( + "Session deletion stage starting: session_id={}, stage=terminal_binding_cleanup, has_binding={}", + session_id, + binding.has(session_id) + ); + if binding.has(session_id) { + if let Err(e) = binding.remove(session_id).await { + warn!("Failed to cleanup associated Terminal session: {}", e); + } else { + debug!( + "Associated Terminal session cleaned up: session_id={}", + session_id + ); + } + } + debug!( + "Session deletion stage completed: session_id={}, stage=terminal_binding_cleanup, duration_ms={}", + session_id, + elapsed_ms_u64(terminal_stage_started_at) + ); } - info!("Session created: session_name={}", session.session_name); + // 4. Remove from memory + let memory_stage_started_at = Instant::now(); + debug!( + "Session deletion stage starting: session_id={}, stage=in_memory_remove", + session_id + ); + self.sessions.remove(session_id); + debug!( + "Session deletion stage completed: session_id={}, stage=in_memory_remove, duration_ms={}", + session_id, + elapsed_ms_u64(memory_stage_started_at) + ); + self.session_workspace_index.remove(session_id); - Ok(session) - } + info!( + "Session deletion completed: session_id={}, workspace_path={}, duration_ms={}", + session_id, + workspace_path.display(), + elapsed_ms_u64(delete_started_at) + ); - /// Get session - pub fn get_session(&self, session_id: &str) -> Option { - self.sessions.get(session_id).map(|s| s.clone()) + Ok(()) } - /// Synchronously reset session state to Idle if it is currently Processing - /// the expected turn. - /// - /// This is an in-memory-only operation intended for RAII-style cleanup in - /// spawn tasks. Because `Drop::drop` is synchronous we cannot do async - /// file I/O here, but that is acceptable: the in-memory state is the - /// source of truth at runtime, and `restore_session` already resets any - /// non-Idle persisted state to Idle on application restart. - pub fn reset_session_state_if_processing(&self, session_id: &str, expected_turn_id: &str) { - if let Some(mut session) = self.sessions.get_mut(session_id) { - if matches!( - &session.state, - SessionState::Processing { - current_turn_id, - .. - } if current_turn_id == expected_turn_id - ) { - debug!( - "RAII guard resetting stuck Processing state to Idle: session_id={}, turn_id={}", - session_id, expected_turn_id - ); - session.state = SessionState::Idle; - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); - } - } + /// Restore session (from persistent storage) + pub async fn restore_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + let (session, _) = self + .restore_session_with_turns(workspace_path, session_id) + .await?; + Ok(session) } - /// Update session state - pub async fn update_session_state( + /// Restore the persisted session header and turns needed by the UI view + /// without loading runtime context snapshots or inserting the session into + /// the in-memory coordinator state. + pub async fn restore_session_view( &self, + workspace_path: &Path, session_id: &str, - new_state: SessionState, - ) -> BitFunResult<()> { - let effective_path = self.effective_session_workspace_path(session_id).await; - - // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. - // Collect the data needed for persistence, then release the guard before doing I/O. - let should_persist = if let Some(mut session) = self.sessions.get_mut(session_id) { - session.state = new_state.clone(); - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); + ) -> BitFunResult<(Session, Vec)> { + let restore_started_at = Instant::now(); + let storage_path_started_at = Instant::now(); + let session_storage_path = { + let ws = workspace_path.to_string_lossy().to_string(); + let tmp_config = SessionConfig { + workspace_path: Some(ws), + ..Default::default() + }; + Self::effective_workspace_path_from_config(&tmp_config) + .await + .unwrap_or_else(|| workspace_path.to_path_buf()) + }; + debug!( + "Session view restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", + session_id, + elapsed_ms_u64(storage_path_started_at) + ); - let persist = self.config.enable_persistence && Self::should_persist_session(&session); - persist - } else { + let metadata_started_at = Instant::now(); + if self + .persistence_manager + .load_session_metadata(&session_storage_path, session_id) + .await? + .is_some_and(|metadata| metadata.should_hide_from_user_lists()) + { return Err(BitFunError::NotFound(format!( "Session not found: {}", session_id ))); - }; - // RefMut guard released here -- DashMap shard lock is free. - - // Persist state changes outside the guard scope. - if should_persist { - if let Some(ref workspace_path) = effective_path { - self.persistence_manager - .save_session_state(workspace_path, session_id, &new_state) - .await?; - } } - debug!( - "Updated session state: session_id={}, state={:?}", - session_id, new_state + "Session view restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", + session_id, + elapsed_ms_u64(metadata_started_at) ); - Ok(()) - } - - /// Update session state only when the session is still processing the - /// expected turn. Returns `true` when the state was updated. - pub async fn update_session_state_for_turn_if_processing( - &self, - session_id: &str, - expected_turn_id: &str, - new_state: SessionState, - ) -> BitFunResult { - let effective_path = self.effective_session_workspace_path(session_id).await; - - let should_persist = if let Some(mut session) = self.sessions.get_mut(session_id) { - let owns_processing_turn = matches!( - &session.state, - SessionState::Processing { - current_turn_id, - .. - } if current_turn_id == expected_turn_id - ); - - if !owns_processing_turn { - debug!( - "Skipped session state update for stale turn: session_id={}, expected_turn_id={}, current_state={:?}", - session_id, expected_turn_id, session.state - ); - return Ok(false); - } - - session.state = new_state.clone(); - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); - - self.config.enable_persistence && Self::should_persist_session(&session) - } else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); - }; - - if should_persist { - if let Some(ref workspace_path) = effective_path { - self.persistence_manager - .save_session_state(workspace_path, session_id, &new_state) - .await?; - } - } - + let session_started_at = Instant::now(); + let (mut session, persisted_turns) = self + .persistence_manager + .load_session_with_turns(&session_storage_path, session_id) + .await?; debug!( - "Updated session state for turn: session_id={}, turn_id={}, state={:?}", - session_id, expected_turn_id, new_state + "Session view restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", + session_id, + persisted_turns.len(), + elapsed_ms_u64(session_started_at) ); - Ok(true) - } - - /// Update session title (in-memory + persistence) - pub async fn update_session_title(&self, session_id: &str, title: &str) -> BitFunResult<()> { - let normalized_title = Self::normalize_session_title_input(title)?; - let workspace_path = self.effective_session_workspace_path(session_id).await; - - { - let Some(mut session) = self.sessions.get_mut(session_id) else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); - }; - session.session_name = normalized_title.clone(); - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); - } - - if self.should_persist_session_id(session_id) { - let Some(workspace_path) = workspace_path.as_ref() else { - return Err(BitFunError::Session(format!( - "Workspace path is unavailable for session {}", - session_id - ))); - }; - // Clone the session data out of the DashMap guard before awaiting I/O. - let session_snapshot = { - let Some(session) = self.sessions.get(session_id) else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); - }; - session.clone() - }; - // Ref guard released -- DashMap shard lock is free. - self.persistence_manager - .save_session(workspace_path, &session_snapshot) - .await?; + if !matches!(session.state, SessionState::Idle) { + let old_state = session.state.clone(); + session.state = SessionState::Idle; + debug!( + "Resetting session state during view restore: session_id={}, state={:?} -> Idle", + session_id, old_state + ); } - info!( - "Session title updated: session_id={}, title={}", - session_id, normalized_title - ); - - Ok(()) - } - - pub async fn update_session_title_if_current( - &self, - session_id: &str, - expected_current_title: &str, - title: &str, - ) -> BitFunResult { - let Some(session) = self.sessions.get(session_id) else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); - }; - - if session.session_name != expected_current_title { + let persisted_turn_ids: Vec = persisted_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect(); + if session.dialog_turn_ids != persisted_turn_ids { debug!( - "Skipping auto-generated title because current title changed: session_id={}, expected_title={}, current_title={}", + "Session view restore normalized turn ids: session_id={}, session_turn_count={}, persisted_turn_count={}", session_id, - expected_current_title, - session.session_name + session.dialog_turn_ids.len(), + persisted_turn_ids.len() ); - return Ok(false); - } - drop(session); - - self.update_session_title(session_id, title).await?; - Ok(true) - } - - /// Update session agent type (in-memory + persistence) - pub async fn update_session_agent_type( - &self, - session_id: &str, - agent_type: &str, - ) -> BitFunResult<()> { - if let Some(mut session) = self.sessions.get_mut(session_id) { - session.agent_type = agent_type.to_string(); - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); - } else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); - } - - if self.should_persist_session_id(session_id) { - let effective_path = self.effective_session_workspace_path(session_id).await; - let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); - // Ref guard released -- DashMap shard lock is free. - if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { - self.persistence_manager - .save_session(&workspace_path, &session) - .await?; - } + session.dialog_turn_ids = persisted_turn_ids; } debug!( - "Session agent type updated: session_id={}, agent_type={}", - session_id, agent_type + "Session view restored: session_id={}, session_name={}, turn_count={}, total_duration_ms={}", + session_id, + session.session_name, + persisted_turns.len(), + elapsed_ms_u64(restore_started_at) ); - Ok(()) + Ok((session, persisted_turns)) } - /// Update session model id (in-memory + persistence) - pub async fn update_session_model_id( + /// Restore session and return the persisted turns read during restore. + pub async fn restore_session_with_turns( &self, + workspace_path: &Path, session_id: &str, - model_id: &str, - ) -> BitFunResult<()> { - let resolved_context_window = Self::resolve_model_context_window(model_id).await; + ) -> BitFunResult<(Session, Vec)> { + let restore_started_at = Instant::now(); + // Check if session is already in memory + let session_already_in_memory = self.sessions.contains_key(session_id); - // If the session was evicted from memory (idle > 1h), try to restore it - // using the workspace path recorded when it was first created/restored. - if !self.sessions.contains_key(session_id) && self.config.enable_persistence { - let workspace_path = self - .session_workspace_index - .get(session_id) - .map(|entry| entry.clone()); - if let Some(workspace_path) = workspace_path { - debug!( - "Session evicted from memory, restoring for model update: session_id={}", - session_id - ); - let _ = self.restore_session(&workspace_path, session_id).await; - } - } + let storage_path_started_at = Instant::now(); + let session_storage_path = { + let ws = workspace_path.to_string_lossy().to_string(); + let tmp_config = SessionConfig { + workspace_path: Some(ws), + ..Default::default() + }; + Self::effective_workspace_path_from_config(&tmp_config) + .await + .unwrap_or_else(|| workspace_path.to_path_buf()) + }; + debug!( + "Session restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", + session_id, + elapsed_ms_u64(storage_path_started_at) + ); - if let Some(mut session) = self.sessions.get_mut(session_id) { - session.config.model_id = Some(model_id.to_string()); - if let Some(context_window) = resolved_context_window { - session.config.max_context_tokens = context_window; - } - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); - } else { + let metadata_started_at = Instant::now(); + if self + .persistence_manager + .load_session_metadata(&session_storage_path, session_id) + .await? + .is_some_and(|metadata| metadata.should_hide_from_user_lists()) + { return Err(BitFunError::NotFound(format!( "Session not found: {}", session_id ))); } - - if self.should_persist_session_id(session_id) { - let effective_path = self.effective_session_workspace_path(session_id).await; - let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); - // Ref guard released -- DashMap shard lock is free. - if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { - self.persistence_manager - .save_session(&workspace_path, &session) - .await?; - } - } - debug!( - "Session model id updated: session_id={}, model_id={}, max_context_tokens={:?}", - session_id, model_id, resolved_context_window + "Session restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", + session_id, + elapsed_ms_u64(metadata_started_at) ); - Ok(()) - } - - /// Update session activity time - pub fn touch_session(&self, session_id: &str) { - if let Some(mut session) = self.sessions.get_mut(session_id) { - session.last_activity_at = SystemTime::now(); - } - } - - /// Delete session (cascade delete all resources) - pub async fn delete_session( - &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult<()> { - let delete_started_at = Instant::now(); + // 1. Load session and turns from storage in one pass + let session_started_at = Instant::now(); + let (mut session, persisted_turns) = self + .persistence_manager + .load_session_with_turns(&session_storage_path, session_id) + .await?; debug!( - "Session deletion started: session_id={}, workspace_path={}, persistence_enabled={}", + "Session restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", session_id, - workspace_path.display(), - self.config.enable_persistence + persisted_turns.len(), + elapsed_ms_u64(session_started_at) ); - // 1. Clean up snapshot system resources (including physical snapshot files) - let snapshot_stage_started_at = Instant::now(); - debug!( - "Session deletion stage starting: session_id={}, stage=snapshot_cleanup", - session_id - ); - if let Ok(snapshot_manager) = ensure_snapshot_manager_for_workspace(workspace_path) { - let snapshot_service = snapshot_manager.get_snapshot_service(); - let snapshot_service = snapshot_service.read().await; - if let Err(e) = snapshot_service.accept_session(session_id).await { - warn!("Failed to cleanup snapshot system resources: {}", e); + // Lazy migration: if the persisted model_id is no longer usable + // (model deleted or disabled while the session was on disk), repoint + // it to "auto" before the session re-enters memory. The next request + // will pick a model via the normal auto/agent/default pipeline. + if let Some(persisted_model_id) = session.config.model_id.as_deref() { + let trimmed = persisted_model_id.trim(); + let needs_migration = if trimmed.is_empty() { + false + } else if let Ok(config_service) = get_global_config_service().await { + match config_service + .get_config::(Some("ai")) + .await + { + Ok(ai_config) => !Self::is_session_model_id_usable(&ai_config, trimmed), + Err(_) => false, + } } else { - debug!( - "Snapshot system resources cleaned up: session_id={}", - session_id + false + }; + + if needs_migration { + warn!( + "Session restore detected stale model_id; migrating to auto: session_id={}, previous_model_id={}", + session_id, trimmed ); + let previous_model_id = trimmed.to_string(); + session.config.model_id = Some("auto".to_string()); + + if let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() { + coordinator + .emit_session_model_auto_migrated( + session_id, + &previous_model_id, + "auto", + "model_unavailable_on_restore", + ) + .await; + } } } - debug!( - "Session deletion stage completed: session_id={}, stage=snapshot_cleanup, duration_ms={}", - session_id, - elapsed_ms_u64(snapshot_stage_started_at) - ); - let context_stage_started_at = Instant::now(); - debug!( - "Session deletion stage starting: session_id={}, stage=context_store_delete", - session_id - ); - self.context_store.delete_session(session_id); + // Reset session state to Idle + // After application restart, previous Processing state is invalid and must be reset + let previous_state_was_not_idle = !matches!(session.state, SessionState::Idle); + if previous_state_was_not_idle { + let old_state = session.state.clone(); + session.state = SessionState::Idle; + debug!( + "Resetting session state during restore: session_id={}, state={:?} -> Idle", + session_id, old_state + ); + } + + // 2. Restore runtime context with snapshot-first semantics. + // If the latest snapshot lags behind turn persistence, append the missing turn delta + // instead of truncating session history. + // + // This compensates for the fact that persistence is not transactional across + // `session.json`, `turns/*.json`, and `snapshots/context-*.json`. + let persisted_turn_ids: Vec = persisted_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect(); + let mut latest_turn_index: Option = None; + let context_snapshot_started_at = Instant::now(); + let mut messages = match self + .persistence_manager + .load_latest_turn_context_snapshot(&session_storage_path, session_id) + .await? + { + Some((turn_index, msgs)) => { + latest_turn_index = Some(turn_index); + msgs + } + None => Self::build_messages_from_turns(&persisted_turns), + }; debug!( - "Session deletion stage completed: session_id={}, stage=context_store_delete, duration_ms={}", + "Session restore phase completed: session_id={}, phase=load_context_snapshot, snapshot_turn_index={:?}, message_count={}, duration_ms={}", session_id, - elapsed_ms_u64(context_stage_started_at) + latest_turn_index, + messages.len(), + elapsed_ms_u64(context_snapshot_started_at) ); - // 2. Delete persisted data - if self.config.enable_persistence { - let persistence_stage_started_at = Instant::now(); - debug!( - "Session deletion stage starting: session_id={}, stage=persistence_delete", - session_id - ); - self.persistence_manager - .delete_session(workspace_path, session_id) - .await?; - debug!( - "Session deletion stage completed: session_id={}, stage=persistence_delete, duration_ms={}", - session_id, - elapsed_ms_u64(persistence_stage_started_at) - ); - } + if let Some(snapshot_turn_index) = latest_turn_index { + let delta_start = snapshot_turn_index.saturating_add(1); + if delta_start < persisted_turns.len() { + warn!( + "Context snapshot is behind persisted turns, rebuilding delta: session_id={}, snapshot_turn_index={}, persisted_turn_count={}", + session_id, + snapshot_turn_index, + persisted_turns.len() + ); + messages.extend(Self::build_messages_from_turns( + &persisted_turns[delta_start..], + )); + } + }; - if let Some(cron) = crate::service::cron::get_global_cron_service() { - let cron_stage_started_at = Instant::now(); + if messages.is_empty() { debug!( - "Session deletion stage starting: session_id={}, stage=cron_cleanup", + "Session {} has empty persisted messages (may be new session)", session_id ); - match cron.delete_jobs_for_session(session_id).await { - Ok(removed) if removed > 0 => { - info!( - "Removed {} scheduled job(s) for deleted session_id={}", - removed, session_id - ); - } - Ok(_) => {} - Err(e) => { - warn!( - "Failed to remove scheduled jobs for deleted session_id={}: {}", - session_id, e - ); - } - } - debug!( - "Session deletion stage completed: session_id={}, stage=cron_cleanup, duration_ms={}", - session_id, - elapsed_ms_u64(cron_stage_started_at) - ); } - // 3. Clean up associated Terminal session - use crate::service::terminal::TerminalApi; - if let Ok(terminal_api) = TerminalApi::from_singleton() { - let binding = terminal_api.session_manager().binding(); - let terminal_stage_started_at = Instant::now(); - debug!( - "Session deletion stage starting: session_id={}, stage=terminal_binding_cleanup, has_binding={}", - session_id, - binding.has(session_id) - ); - if binding.has(session_id) { - if let Err(e) = binding.remove(session_id).await { - warn!("Failed to cleanup associated Terminal session: {}", e); - } else { - debug!( - "Associated Terminal session cleaned up: session_id={}", - session_id - ); - } - } - debug!( - "Session deletion stage completed: session_id={}, stage=terminal_binding_cleanup, duration_ms={}", - session_id, - elapsed_ms_u64(terminal_stage_started_at) - ); + // 3. Restore the in-memory context cache from the recovered messages. + // If session already exists, delete old one first then create (ensure clean state) + if session_already_in_memory { + self.context_store.delete_session(session_id); } - // 4. Remove from memory - let memory_stage_started_at = Instant::now(); - debug!( - "Session deletion stage starting: session_id={}, stage=in_memory_remove", - session_id - ); - self.sessions.remove(session_id); + let context_replace_started_at = Instant::now(); + self.context_store + .replace_context(session_id, messages.clone()); debug!( - "Session deletion stage completed: session_id={}, stage=in_memory_remove, duration_ms={}", - session_id, - elapsed_ms_u64(memory_stage_started_at) - ); - - info!( - "Session deletion completed: session_id={}, workspace_path={}, duration_ms={}", + "Session restore phase completed: session_id={}, phase=replace_context, message_count={}, duration_ms={}", session_id, - workspace_path.display(), - elapsed_ms_u64(delete_started_at) + messages.len(), + elapsed_ms_u64(context_replace_started_at) ); - Ok(()) - } + let recoverable_turn_count = latest_turn_index + .map(|turn_index| turn_index + 1) + .unwrap_or(0) + .max(persisted_turns.len()); - /// Restore session (from persistent storage) - pub async fn restore_session( - &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult { - let (session, _) = self - .restore_session_with_turns(workspace_path, session_id) - .await?; - Ok(session) - } - - /// Restore the persisted session header and turns needed by the UI view - /// without loading runtime context snapshots or inserting the session into - /// the in-memory coordinator state. - pub async fn restore_session_view( - &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult<(Session, Vec)> { - let restore_started_at = Instant::now(); - let storage_path_started_at = Instant::now(); - let session_storage_path = { - let ws = workspace_path.to_string_lossy().to_string(); - let tmp_config = SessionConfig { - workspace_path: Some(ws), - ..Default::default() - }; - Self::effective_workspace_path_from_config(&tmp_config) - .await - .unwrap_or_else(|| workspace_path.to_path_buf()) - }; - debug!( - "Session view restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", - session_id, - elapsed_ms_u64(storage_path_started_at) - ); - - let metadata_started_at = Instant::now(); - if self - .persistence_manager - .load_session_metadata(&session_storage_path, session_id) - .await? - .is_some_and(|metadata| metadata.should_hide_from_user_lists()) + if session.dialog_turn_ids.len() < persisted_turns.len() { + warn!( + "Session metadata is behind persisted turns, rebuilding dialog_turn_ids: session_id={}, session_turn_count={}, persisted_turn_count={}", + session_id, + session.dialog_turn_ids.len(), + persisted_turns.len() + ); + session.dialog_turn_ids = persisted_turn_ids; + } else if session.dialog_turn_ids.len() > recoverable_turn_count { + warn!( + "Session metadata exceeds recoverable history, truncating: session_id={}, session_turn_count={}, recoverable_turn_count={}", + session_id, + session.dialog_turn_ids.len(), + recoverable_turn_count + ); + session.dialog_turn_ids.truncate(recoverable_turn_count); + } else if persisted_turns.len() == session.dialog_turn_ids.len() + && session.dialog_turn_ids != persisted_turn_ids { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", + warn!( + "Session metadata turn ids diverge from persisted turns, normalizing order: session_id={}", session_id - ))); - } - debug!( - "Session view restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", - session_id, - elapsed_ms_u64(metadata_started_at) - ); - - let session_started_at = Instant::now(); - let (mut session, persisted_turns) = self - .persistence_manager - .load_session_with_turns(&session_storage_path, session_id) - .await?; - debug!( - "Session view restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", - session_id, - persisted_turns.len(), - elapsed_ms_u64(session_started_at) - ); - - if !matches!(session.state, SessionState::Idle) { - let old_state = session.state.clone(); - session.state = SessionState::Idle; - debug!( - "Resetting session state during view restore: session_id={}, state={:?} -> Idle", - session_id, old_state ); + session.dialog_turn_ids = persisted_turn_ids; } - let persisted_turn_ids: Vec = persisted_turns - .iter() - .map(|turn| turn.turn_id.clone()) - .collect(); - if session.dialog_turn_ids != persisted_turn_ids { - debug!( - "Session view restore normalized turn ids: session_id={}, session_turn_count={}, persisted_turn_count={}", - session_id, - session.dialog_turn_ids.len(), - persisted_turn_ids.len() + if recoverable_turn_count == 0 && !session.dialog_turn_ids.is_empty() && messages.is_empty() + { + warn!( + "Session has no available context snapshot and messages are empty, clearing turns: session_id={}", + session_id ); - session.dialog_turn_ids = persisted_turn_ids; + session.dialog_turn_ids.clear(); } + let context_msg_count = self.context_store.get_context_messages(session_id).len(); + debug!( - "Session view restored: session_id={}, session_name={}, turn_count={}, total_duration_ms={}", + "Session restored: session_id={}, session_name={}, messages={}, context_messages={}, turn_count={}, total_duration_ms={}", session_id, session.session_name, + messages.len(), + context_msg_count, persisted_turns.len(), elapsed_ms_u64(restore_started_at) ); + // Do not infer unread completion from persisted runtime state during restore. + // Older IDE versions could leave sessions in non-idle states on disk; treating those + // as completed would surface misleading unread indicators after an upgrade. + // Unread completion is now written only by runtime completion/persist paths. + + // 4. Add to memory (will overwrite if already exists) + self.sessions + .insert(session_id.to_string(), session.clone()); + self.session_workspace_index + .insert(session_id.to_string(), session_storage_path.clone()); + Ok((session, persisted_turns)) } - /// Restore session and return the persisted turns read during restore. - pub async fn restore_session_with_turns( + /// Rollback "model context" to before the start of specified turn (i.e., keep 0..target_turn-1) + pub async fn rollback_context_to_turn_start( &self, workspace_path: &Path, session_id: &str, - ) -> BitFunResult<(Session, Vec)> { - let restore_started_at = Instant::now(); - // Check if session is already in memory - let session_already_in_memory = self.sessions.contains_key(session_id); + target_turn: usize, + ) -> BitFunResult<()> { + // Ensure session is in memory (restore from persistence if necessary) + if !self.sessions.contains_key(session_id) && self.config.enable_persistence { + let _ = self.restore_session(workspace_path, session_id).await; + } - let storage_path_started_at = Instant::now(); - let session_storage_path = { - let ws = workspace_path.to_string_lossy().to_string(); - let tmp_config = SessionConfig { - workspace_path: Some(ws), - ..Default::default() - }; - Self::effective_workspace_path_from_config(&tmp_config) - .await - .unwrap_or_else(|| workspace_path.to_path_buf()) + // 1) Load target context (target_turn == 0 => empty context) + let messages = if target_turn == 0 { + Vec::new() + } else { + self.persistence_manager + .load_turn_context_snapshot(workspace_path, session_id, target_turn - 1) + .await? + .ok_or_else(|| { + BitFunError::NotFound(format!( + "turn context snapshot not found: session_id={} turn={}", + session_id, + target_turn - 1 + )) + })? }; - debug!( - "Session restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", - session_id, - elapsed_ms_u64(storage_path_started_at) - ); - let metadata_started_at = Instant::now(); - if self - .persistence_manager - .load_session_metadata(&session_storage_path, session_id) - .await? - .is_some_and(|metadata| metadata.should_hide_from_user_lists()) - { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); - } - debug!( - "Session restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", - session_id, - elapsed_ms_u64(metadata_started_at) - ); + // 2) Restore the in-memory context cache. + self.context_store.replace_context(session_id, messages); - // 1. Load session and turns from storage in one pass - let session_started_at = Instant::now(); - let (mut session, persisted_turns) = self - .persistence_manager - .load_session_with_turns(&session_storage_path, session_id) - .await?; - debug!( - "Session restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", - session_id, - persisted_turns.len(), - elapsed_ms_u64(session_started_at) - ); + // 3) Truncate session turn list & persist + // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. + let session_snapshot = if let Some(mut session) = self.sessions.get_mut(session_id) { + if session.dialog_turn_ids.len() > target_turn { + session.dialog_turn_ids.truncate(target_turn); + } + session.state = SessionState::Idle; + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); - // Lazy migration: if the persisted model_id is no longer usable - // (model deleted or disabled while the session was on disk), repoint - // it to "auto" before the session re-enters memory. The next request - // will pick a model via the normal auto/agent/default pipeline. - if let Some(persisted_model_id) = session.config.model_id.as_deref() { - let trimmed = persisted_model_id.trim(); - let needs_migration = if trimmed.is_empty() { - false - } else if let Ok(config_service) = get_global_config_service().await { - match config_service - .get_config::(Some("ai")) - .await - { - Ok(ai_config) => !Self::is_session_model_id_usable(&ai_config, trimmed), - Err(_) => false, - } + let should_persist = + Self::should_persist_session(&session) && self.config.enable_persistence; + if should_persist { + Some(session.clone()) } else { - false - }; - - if needs_migration { - warn!( - "Session restore detected stale model_id; migrating to auto: session_id={}, previous_model_id={}", - session_id, trimmed - ); - let previous_model_id = trimmed.to_string(); - session.config.model_id = Some("auto".to_string()); - - if let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() { - coordinator - .emit_session_model_auto_migrated( - session_id, - &previous_model_id, - "auto", - "model_unavailable_on_restore", - ) - .await; - } + None } - } - - // Reset session state to Idle - // After application restart, previous Processing state is invalid and must be reset - let previous_state_was_not_idle = !matches!(session.state, SessionState::Idle); - if previous_state_was_not_idle { - let old_state = session.state.clone(); - session.state = SessionState::Idle; - debug!( - "Resetting session state during restore: session_id={}, state={:?} -> Idle", - session_id, old_state - ); - } - - // 2. Restore runtime context with snapshot-first semantics. - // If the latest snapshot lags behind turn persistence, append the missing turn delta - // instead of truncating session history. - // - // This compensates for the fact that persistence is not transactional across - // `session.json`, `turns/*.json`, and `snapshots/context-*.json`. - let persisted_turn_ids: Vec = persisted_turns - .iter() - .map(|turn| turn.turn_id.clone()) - .collect(); - let mut latest_turn_index: Option = None; - let context_snapshot_started_at = Instant::now(); - let mut messages = match self - .persistence_manager - .load_latest_turn_context_snapshot(&session_storage_path, session_id) - .await? - { - Some((turn_index, msgs)) => { - latest_turn_index = Some(turn_index); - msgs - } - None => Self::build_messages_from_turns(&persisted_turns), - }; - debug!( - "Session restore phase completed: session_id={}, phase=load_context_snapshot, snapshot_turn_index={:?}, message_count={}, duration_ms={}", - session_id, - latest_turn_index, - messages.len(), - elapsed_ms_u64(context_snapshot_started_at) - ); - - if let Some(snapshot_turn_index) = latest_turn_index { - let delta_start = snapshot_turn_index.saturating_add(1); - if delta_start < persisted_turns.len() { - warn!( - "Context snapshot is behind persisted turns, rebuilding delta: session_id={}, snapshot_turn_index={}, persisted_turn_count={}", - session_id, - snapshot_turn_index, - persisted_turns.len() - ); - messages.extend(Self::build_messages_from_turns( - &persisted_turns[delta_start..], - )); - } - }; - - if messages.is_empty() { - debug!( - "Session {} has empty persisted messages (may be new session)", - session_id - ); - } - - // 3. Restore the in-memory context cache from the recovered messages. - // If session already exists, delete old one first then create (ensure clean state) - if session_already_in_memory { - self.context_store.delete_session(session_id); - } - - let context_replace_started_at = Instant::now(); - self.context_store - .replace_context(session_id, messages.clone()); - debug!( - "Session restore phase completed: session_id={}, phase=replace_context, message_count={}, duration_ms={}", - session_id, - messages.len(), - elapsed_ms_u64(context_replace_started_at) - ); - - let recoverable_turn_count = latest_turn_index - .map(|turn_index| turn_index + 1) - .unwrap_or(0) - .max(persisted_turns.len()); - - if session.dialog_turn_ids.len() < persisted_turns.len() { - warn!( - "Session metadata is behind persisted turns, rebuilding dialog_turn_ids: session_id={}, session_turn_count={}, persisted_turn_count={}", - session_id, - session.dialog_turn_ids.len(), - persisted_turns.len() - ); - session.dialog_turn_ids = persisted_turn_ids; - } else if session.dialog_turn_ids.len() > recoverable_turn_count { - warn!( - "Session metadata exceeds recoverable history, truncating: session_id={}, session_turn_count={}, recoverable_turn_count={}", - session_id, - session.dialog_turn_ids.len(), - recoverable_turn_count - ); - session.dialog_turn_ids.truncate(recoverable_turn_count); - } else if persisted_turns.len() == session.dialog_turn_ids.len() - && session.dialog_turn_ids != persisted_turn_ids - { - warn!( - "Session metadata turn ids diverge from persisted turns, normalizing order: session_id={}", - session_id - ); - session.dialog_turn_ids = persisted_turn_ids; - } - - if recoverable_turn_count == 0 && !session.dialog_turn_ids.is_empty() && messages.is_empty() - { - warn!( - "Session has no available context snapshot and messages are empty, clearing turns: session_id={}", - session_id - ); - session.dialog_turn_ids.clear(); - } - - let context_msg_count = self.context_store.get_context_messages(session_id).len(); - - debug!( - "Session restored: session_id={}, session_name={}, messages={}, context_messages={}, turn_count={}, total_duration_ms={}", - session_id, - session.session_name, - messages.len(), - context_msg_count, - persisted_turns.len(), - elapsed_ms_u64(restore_started_at) - ); - - // Do not infer unread completion from persisted runtime state during restore. - // Older IDE versions could leave sessions in non-idle states on disk; treating those - // as completed would surface misleading unread indicators after an upgrade. - // Unread completion is now written only by runtime completion/persist paths. - - // 4. Add to memory (will overwrite if already exists) - self.sessions - .insert(session_id.to_string(), session.clone()); - self.session_workspace_index - .insert(session_id.to_string(), session_storage_path.clone()); - - Ok((session, persisted_turns)) - } - - /// Rollback "model context" to before the start of specified turn (i.e., keep 0..target_turn-1) - pub async fn rollback_context_to_turn_start( - &self, - workspace_path: &Path, - session_id: &str, - target_turn: usize, - ) -> BitFunResult<()> { - // Ensure session is in memory (restore from persistence if necessary) - if !self.sessions.contains_key(session_id) && self.config.enable_persistence { - let _ = self.restore_session(workspace_path, session_id).await; - } - - // 1) Load target context (target_turn == 0 => empty context) - let messages = if target_turn == 0 { - Vec::new() - } else { - self.persistence_manager - .load_turn_context_snapshot(workspace_path, session_id, target_turn - 1) - .await? - .ok_or_else(|| { - BitFunError::NotFound(format!( - "turn context snapshot not found: session_id={} turn={}", - session_id, - target_turn - 1 - )) - })? - }; - - // 2) Restore the in-memory context cache. - self.context_store.replace_context(session_id, messages); - - // 3) Truncate session turn list & persist - // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. - let session_snapshot = if let Some(mut session) = self.sessions.get_mut(session_id) { - if session.dialog_turn_ids.len() > target_turn { - session.dialog_turn_ids.truncate(target_turn); - } - session.state = SessionState::Idle; - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); - - let should_persist = - Self::should_persist_session(&session) && self.config.enable_persistence; - if should_persist { - Some(session.clone()) - } else { - None - } - } else { - None - }; - // RefMut guard released here -- DashMap shard lock is free. - - if let Some(session) = session_snapshot { - self.persistence_manager - .save_session(workspace_path, &session) - .await?; + } else { + None + }; + // RefMut guard released here -- DashMap shard lock is free. + + if let Some(session) = session_snapshot { + self.persistence_manager + .save_session(workspace_path, &session) + .await?; } // 4) Delete persisted turns and snapshots from target_turn (inclusive) onwards. @@ -3179,167 +2630,834 @@ impl SessionManager { BitFunError::AIClient(format!("Failed to get AI client factory: {}", e)) })?; - let ai_client = ai_client_factory - .get_client_by_func_agent("session-title-func-agent") + let ai_client = ai_client_factory + .get_client_by_func_agent("session-title-func-agent") + .await + .map_err(|e| BitFunError::AIClient(format!("Failed to get AI client: {}", e)))?; + + let response = ai_client + .send_message(messages, None) + .await + .map_err(|e| BitFunError::ai(format!("AI call failed: {}", e)))?; + + let title = sanitize_plain_model_output(&response.text); + if title.is_empty() { + return Ok(None); + } + + // Truncate title + let final_title = if title.chars().count() > max_length { + title.chars().take(max_length).collect::() + } else { + title + }; + + Ok(Some(final_title)) + } + + /// Generate a concise session title, using AI first and falling back to a local heuristic. + pub async fn resolve_session_title( + &self, + user_message: &str, + max_length: Option, + allow_ai: bool, + ) -> ResolvedSessionTitle { + let max_length = max_length.unwrap_or(20).max(1); + + if allow_ai { + match self + .try_generate_session_title_with_ai(user_message, max_length) + .await + { + Ok(Some(title)) => { + return ResolvedSessionTitle { + title, + method: SessionTitleMethod::Ai, + }; + } + Ok(None) => { + warn!("AI session title generation returned empty output; using fallback"); + } + Err(error) => { + warn!("AI session title generation failed; using fallback: {error}"); + } + } + } + + ResolvedSessionTitle { + title: Self::fallback_session_title(user_message, max_length), + method: SessionTitleMethod::Fallback, + } + } + + /// Generate session title + /// + /// Generate a concise and accurate session title based on user message content. + pub async fn generate_session_title( + &self, + user_message: &str, + max_length: Option, + ) -> BitFunResult { + Ok(self + .resolve_session_title(user_message, max_length, true) + .await + .title) + } + + // ============ Background Tasks ============ + + /// Start auto-save task + fn spawn_auto_save_task(&self) { + let sessions = self.sessions.clone(); + let persistence = self.persistence_manager.clone(); + let interval = self.config.auto_save_interval; + + tokio::spawn(async move { + let mut ticker = time::interval(interval); + + loop { + ticker.tick().await; + + for entry in sessions.iter() { + let session = entry.value(); + if !Self::should_persist_session(session) { + continue; + } + if let Some(workspace_path) = + Self::effective_workspace_path_from_config(&session.config).await + { + if let Err(e) = persistence.save_session(&workspace_path, session).await { + error!( + "Failed to auto-save session: session_id={}, error={}", + session.session_id, e + ); + } + } + } + } + }); + + debug!("Auto-save task started"); + } + + /// Start cleanup task for expired sessions + fn spawn_cleanup_task(&self) { + let sessions = self.sessions.clone(); + let timeout = self.config.session_idle_timeout; + let persistence = self.persistence_manager.clone(); + let enable_persistence = self.config.enable_persistence; + let context_store = self.context_store.clone(); + + tokio::spawn(async move { + let mut ticker = time::interval(Duration::from_secs(60)); + + loop { + ticker.tick().await; + + let now = SystemTime::now(); + let mut expired_sessions = Vec::new(); + + for entry in sessions.iter() { + let session = entry.value(); + if let Ok(idle_duration) = now.duration_since(session.last_activity_at) { + if idle_duration > timeout { + expired_sessions.push(session.session_id.clone()); + } + } + } + + for session_id in expired_sessions { + debug!("Cleaning up expired session: session_id={}", session_id); + + // Save before deleting + if enable_persistence { + if let Some(session) = sessions.get(&session_id) { + if !Self::should_persist_session(&session) { + context_store.delete_session(&session_id); + sessions.remove(&session_id); + continue; + } + if let Some(workspace_path) = + Self::effective_workspace_path_from_config(&session.config).await + { + let _ = persistence.save_session(&workspace_path, &session).await; + } + } + } + + context_store.delete_session(&session_id); + sessions.remove(&session_id); + } + } + }); + + debug!("Cleanup task started"); + } +} + +#[cfg(test)] +mod tests { + use super::{SessionManager, SessionManagerConfig}; + use crate::agentic::core::{Message, ProcessingPhase, Session, SessionConfig, SessionState}; + use crate::agentic::persistence::PersistenceManager; + use crate::agentic::session::SessionContextStore; + use crate::infrastructure::PathManager; + use crate::service::session::{ + DialogTurnData, ModelRoundData, ToolCallData, ToolItemData, ToolResultData, UserMessageData, + }; + use serde_json::json; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + use std::time::Duration; + use uuid::Uuid; + + struct TestWorkspace { + path: PathBuf, + } + + impl TestWorkspace { + fn new() -> Self { + let path = std::env::temp_dir() + .join(format!("bitfun-session-restore-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).expect("test workspace should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestWorkspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + fn test_manager(persistence_manager: Arc) -> SessionManager { + SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager, + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: true, + }, + ) + } + + fn in_memory_test_manager() -> SessionManager { + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager, + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + }, + ) + } + + #[tokio::test] + async fn reset_session_state_if_processing_ignores_a_newer_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-2".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + manager.reset_session_state_if_processing(&session_id, "turn-1"); + + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(matches!( + session.state, + SessionState::Processing { + ref current_turn_id, + .. + } if current_turn_id == "turn-2" + )); + } + + #[tokio::test] + async fn reset_session_state_if_processing_resets_the_matching_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + manager.reset_session_state_if_processing(&session_id, "turn-1"); + + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(matches!(session.state, SessionState::Idle)); + } + + #[tokio::test] + async fn update_session_state_for_turn_if_processing_ignores_a_newer_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-2".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + let updated = manager + .update_session_state_for_turn_if_processing(&session_id, "turn-1", SessionState::Idle) + .await + .expect("conditional state update should not fail"); + + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(!updated); + assert!(matches!( + session.state, + SessionState::Processing { + ref current_turn_id, + .. + } if current_turn_id == "turn-2" + )); + } + + #[tokio::test] + async fn update_session_state_for_turn_if_processing_updates_matching_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + let updated = manager + .update_session_state_for_turn_if_processing(&session_id, "turn-1", SessionState::Idle) + .await + .expect("conditional state update should not fail"); + + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(updated); + assert!(matches!(session.state, SessionState::Idle)); + } + + #[tokio::test] + async fn restore_session_resets_processing_state_without_marking_unread_completion() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Legacy processing session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }; + + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + persistence_manager + .save_session_state(workspace.path(), &session_id, &session.state) + .await + .expect("processing state should save"); + + let manager = test_manager(persistence_manager.clone()); + let restored = manager + .restore_session(workspace.path(), &session_id) + .await + .expect("session should restore"); + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + + assert!(matches!(restored.state, SessionState::Idle)); + assert_eq!(metadata.unread_completion, None); + } + + #[tokio::test] + async fn restore_session_view_loads_turns_without_restoring_runtime_context() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Large history".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.dialog_turn_ids = vec!["turn-1".to_string()]; + + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + let turn = DialogTurnData::new( + "turn-1".to_string(), + 0, + session_id.clone(), + UserMessageData { + id: "turn-1-user".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + persistence_manager + .save_turn_context_snapshot( + workspace.path(), + &session_id, + 0, + &[Message::user("snapshot prompt".to_string())], + ) + .await + .expect("context snapshot should save"); + + let (view_session, turns) = manager + .restore_session_view(workspace.path(), &session_id) + .await + .expect("session view should restore"); + + assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!(turns.len(), 1); + assert!(manager.get_session(&session_id).is_none()); + assert!(manager + .context_store + .get_context_messages(&session_id) + .is_empty()); + } + + #[tokio::test] + async fn restore_session_view_preserves_full_visible_tool_result_payload() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "History with tool output".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.dialog_turn_ids = vec!["turn-1".to_string()]; + + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let visible_output = "complete visible output ".repeat(128); + let assistant_output = "assistant visible summary ".repeat(16); + let mut turn = DialogTurnData::new( + "turn-1".to_string(), + 0, + session_id.clone(), + UserMessageData { + id: "turn-1-user".to_string(), + content: "show full output".to_string(), + timestamp: 1, + metadata: None, + }, + ); + turn.model_rounds.push(ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![ToolItemData { + id: "tool-1".to_string(), + tool_name: "Bash".to_string(), + tool_call: ToolCallData { + id: "call-1".to_string(), + input: json!({ "command": "printf output" }), + }, + tool_result: Some(ToolResultData { + result: json!({ + "stdout": visible_output, + "nested": { + "stderr": "also visible", + }, + }), + success: true, + result_for_assistant: Some(assistant_output.clone()), + error: None, + duration_ms: Some(1), + }), + ai_intent: None, + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + subagent_model_id: None, + subagent_model_alias: None, + status: Some("completed".to_string()), + interruption_reason: None, + }], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + + let (view_session, turns) = manager + .restore_session_view(workspace.path(), &session_id) + .await + .expect("session view should restore"); + + let restored_result = turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .expect("tool result should be preserved"); + assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!( + restored_result.result["stdout"].as_str(), + Some(visible_output.as_str()) + ); + assert_eq!( + restored_result.result["nested"]["stderr"].as_str(), + Some("also visible") + ); + assert_eq!( + restored_result.result_for_assistant.as_deref(), + Some(assistant_output.as_str()) + ); + assert!(manager.get_session(&session_id).is_none()); + } + + #[tokio::test] + async fn rollback_context_deletes_persisted_turns_from_target() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Rollback session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + for index in 0..3 { + let turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session.session_id.clone(), + UserMessageData { + id: format!("turn-{index}-user"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + } + + { + let mut active = manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active"); + active.dialog_turn_ids = vec![ + "turn-0".to_string(), + "turn-1".to_string(), + "turn-2".to_string(), + ]; + } + persistence_manager + .save_turn_context_snapshot( + workspace.path(), + &session.session_id, + 0, + &[crate::agentic::core::Message::user("prompt 0".to_string())], + ) + .await + .expect("snapshot 0 should save"); + persistence_manager + .save_turn_context_snapshot( + workspace.path(), + &session.session_id, + 1, + &[ + crate::agentic::core::Message::user("prompt 0".to_string()), + crate::agentic::core::Message::user("prompt 1".to_string()), + ], + ) + .await + .expect("snapshot 1 should save"); + + manager + .rollback_context_to_turn_start(workspace.path(), &session.session_id, 1) + .await + .expect("rollback should succeed"); + + let turns = persistence_manager + .load_session_turns(workspace.path(), &session.session_id) + .await + .expect("turns should load"); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].user_message.content, "prompt 0"); + assert!(persistence_manager + .load_turn_context_snapshot(workspace.path(), &session.session_id, 1) + .await + .expect("snapshot load should succeed") + .is_none()); + + manager.sessions.remove(&session.session_id); + let restored = manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("session should restore"); + assert_eq!(restored.dialog_turn_ids, vec!["turn-0".to_string()]); + assert_eq!( + manager + .context_store + .get_context_messages(&session.session_id) + .len(), + 1 + ); + + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) .await - .map_err(|e| BitFunError::AIClient(format!("Failed to get AI client: {}", e)))?; + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.turn_count, 1); + } - let response = ai_client - .send_message(messages, None) + #[tokio::test] + async fn delete_session_removes_workspace_cache_entry() { + let workspace = TestWorkspace::new(); + let manager = in_memory_test_manager(); + let session = manager + .create_session( + "Cached session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) .await - .map_err(|e| BitFunError::ai(format!("AI call failed: {}", e)))?; + .expect("session should create"); - let title = sanitize_plain_model_output(&response.text); - if title.is_empty() { - return Ok(None); - } + assert_eq!( + manager + .session_workspace_index + .get(&session.session_id) + .map(|entry| entry.clone()), + Some(workspace.path().to_path_buf()) + ); - // Truncate title - let final_title = if title.chars().count() > max_length { - title.chars().take(max_length).collect::() - } else { - title - }; + manager + .delete_session(workspace.path(), &session.session_id) + .await + .expect("session should delete"); - Ok(Some(final_title)) + assert!(manager + .session_workspace_index + .get(&session.session_id) + .is_none()); } - /// Generate a concise session title, using AI first and falling back to a local heuristic. - pub async fn resolve_session_title( - &self, - user_message: &str, - max_length: Option, - allow_ai: bool, - ) -> ResolvedSessionTitle { - let max_length = max_length.unwrap_or(20).max(1); + #[test] + fn build_messages_from_turns_skips_model_invisible_turns() { + use crate::service::session::{DialogTurnData, DialogTurnKind, UserMessageData}; - if allow_ai { - match self - .try_generate_session_title_with_ai(user_message, max_length) - .await - { - Ok(Some(title)) => { - return ResolvedSessionTitle { - title, - method: SessionTitleMethod::Ai, - }; - } - Ok(None) => { - warn!("AI session title generation returned empty output; using fallback"); - } - Err(error) => { - warn!("AI session title generation failed; using fallback: {error}"); - } - } - } + let turns = vec![ + DialogTurnData::new( + "turn-1".to_string(), + 0, + "session-1".to_string(), + UserMessageData { + id: "user-1".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + ), + DialogTurnData::new_with_kind( + DialogTurnKind::ManualCompaction, + "turn-2".to_string(), + 1, + "session-1".to_string(), + UserMessageData { + id: "user-2".to_string(), + content: "/compact".to_string(), + timestamp: 2, + metadata: None, + }, + ), + DialogTurnData::new_with_kind( + DialogTurnKind::LocalCommand, + "turn-3".to_string(), + 2, + "session-1".to_string(), + UserMessageData { + id: "user-3".to_string(), + content: "# Session Usage Report".to_string(), + timestamp: 3, + metadata: Some(serde_json::json!({ + "localCommandKind": "usage_report", + "modelVisible": false + })), + }, + ), + ]; - ResolvedSessionTitle { - title: Self::fallback_session_title(user_message, max_length), - method: SessionTitleMethod::Fallback, - } - } + let messages = SessionManager::build_messages_from_turns(&turns); - /// Generate session title - /// - /// Generate a concise and accurate session title based on user message content. - pub async fn generate_session_title( - &self, - user_message: &str, - max_length: Option, - ) -> BitFunResult { - Ok(self - .resolve_session_title(user_message, max_length, true) - .await - .title) + assert_eq!(messages.len(), 1); + assert!(messages[0].is_actual_user_message()); } - // ============ Background Tasks ============ - - /// Start auto-save task - fn spawn_auto_save_task(&self) { - let sessions = self.sessions.clone(); - let persistence = self.persistence_manager.clone(); - let interval = self.config.auto_save_interval; - - tokio::spawn(async move { - let mut ticker = time::interval(interval); - - loop { - ticker.tick().await; - - for entry in sessions.iter() { - let session = entry.value(); - if !Self::should_persist_session(session) { - continue; - } - if let Some(workspace_path) = - Self::effective_workspace_path_from_config(&session.config).await - { - if let Err(e) = persistence.save_session(&workspace_path, session).await { - error!( - "Failed to auto-save session: session_id={}, error={}", - session.session_id, e - ); - } - } - } - } - }); + #[test] + fn fallback_session_title_uses_sentence_break_when_available() { + let title = SessionManager::fallback_session_title( + "Fix the flaky integration test. Add logging for retries.", + 20, + ); - debug!("Auto-save task started"); + assert_eq!(title, "Fix the flaky..."); } - /// Start cleanup task for expired sessions - fn spawn_cleanup_task(&self) { - let sessions = self.sessions.clone(); - let timeout = self.config.session_idle_timeout; - let persistence = self.persistence_manager.clone(); - let enable_persistence = self.config.enable_persistence; - let context_store = self.context_store.clone(); - - tokio::spawn(async move { - let mut ticker = time::interval(Duration::from_secs(60)); - - loop { - ticker.tick().await; + #[test] + fn fallback_session_title_appends_ellipsis_when_truncated_without_sentence_break() { + let title = SessionManager::fallback_session_title( + "Implement session title generation fallback", + 12, + ); - let now = SystemTime::now(); - let mut expired_sessions = Vec::new(); + assert_eq!(title, "Implement..."); + } - for entry in sessions.iter() { - let session = entry.value(); - if let Ok(idle_duration) = now.duration_since(session.last_activity_at) { - if idle_duration > timeout { - expired_sessions.push(session.session_id.clone()); - } - } - } + #[test] + fn fallback_session_title_uses_default_for_blank_input() { + let title = SessionManager::fallback_session_title(" ", 20); - for session_id in expired_sessions { - debug!("Cleaning up expired session: session_id={}", session_id); + assert_eq!(title, "New Session"); + } - // Save before deleting - if enable_persistence { - if let Some(session) = sessions.get(&session_id) { - if !Self::should_persist_session(&session) { - context_store.delete_session(&session_id); - sessions.remove(&session_id); - continue; - } - if let Some(workspace_path) = - Self::effective_workspace_path_from_config(&session.config).await - { - let _ = persistence.save_session(&workspace_path, &session).await; - } - } - } + #[tokio::test] + async fn records_subagent_partial_timeout_in_evidence_ledger() { + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let manager = test_manager(persistence_manager); - context_store.delete_session(&session_id); - sessions.remove(&session_id); - } - } - }); + let event = manager.record_subagent_partial_timeout( + "session-a", + "turn-a", + "ReviewSecurity", + "Found token logging before timeout.", + Some("timeout"), + ); - debug!("Cleanup task started"); + assert!(!event.event_id.is_empty()); + let events = manager.evidence_events_for_turn("session-a", "turn-a"); + assert_eq!(events, vec![event.clone()]); + let summary = manager.evidence_summary_for_session("session-a", 10); + assert_eq!(summary.partial_subagent_results.len(), 1); + assert_eq!(summary.partial_subagent_results[0].event_id, event.event_id); } } diff --git a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs index 4a629c83f1..768640e51e 100644 --- a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs @@ -117,15 +117,29 @@ impl CronTool { self.resolve_workspace(workspace.to_string_lossy().as_ref(), Some(context)) } - fn resolve_effective_workspace( + async fn resolve_effective_workspace_for_session( &self, - workspace: Option<&str>, + session_id: &str, context: &ToolUseContext, ) -> BitFunResult { - match workspace { - Some(workspace) => self.resolve_workspace(workspace, Some(context)), - None => self.resolve_workspace_from_context(context), + if let Some(coordinator) = get_global_coordinator() { + if let Some(resolved) = coordinator + .resolve_session_workspace_path(session_id) + .await + .map(|path| path.to_string_lossy().to_string()) + { + return Ok(resolved); + } + } + + if context.session_id.as_deref() == Some(session_id) { + return self.resolve_workspace_from_context(context); } + + Err(BitFunError::tool(format!( + "Unable to resolve workspace for session '{}'", + session_id + ))) } fn resolve_effective_session_id( @@ -353,9 +367,9 @@ impl CronToolJobPatchInput { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] struct CronToolInput { action: CronAction, - workspace: Option, session_id: Option, job: Option, patch: Option, @@ -550,12 +564,11 @@ impl Tool for CronTool { Ok(r#"Manage scheduled jobs for agent sessions. Defaults: -- "workspace": defaults to the current workspace. - "session_id": defaults to the current session for "list" and "add". Actions: - "get_time": Return the current local time including timezone information. -- "list": List all jobs for the effective workspace + session scope. +- "list": List all jobs for the effective session scope. - "add": Create a job. Requires "job". When "job.name" is omitted, uses "Cron job". - "update": Update a job. Requires "job_id" and "patch". - "remove": Delete a job. Requires "job_id". @@ -597,10 +610,6 @@ Patch schema for "update": json!({ "type": "object", "properties": { - "workspace": { - "type": "string", - "description": "Optional absolute workspace path. Defaults to the current workspace." - }, "session_id": { "type": "string", "description": "Optional target session ID. Defaults to the current session for list/add." @@ -699,17 +708,6 @@ Patch schema for "update": } }; - if let Some(workspace) = parsed.workspace.as_deref() { - if let Err(message) = Self::validate_workspace_format(workspace, context) { - return ValidationResult { - result: false, - message: Some(message), - error_code: Some(400), - meta: None, - }; - } - } - if let Some(session_id) = parsed.session_id.as_deref() { if let Err(message) = Self::validate_session_id(session_id.trim()) { return ValidationResult { @@ -724,11 +722,11 @@ Patch schema for "update": match parsed.action { CronAction::GetTime => ValidationResult::default(), CronAction::List => { - if parsed.session_id.is_none() - && context + let has_effective_session = parsed.session_id.is_some() + || context .and_then(|tool_context| tool_context.session_id.as_deref()) - .is_none() - { + .is_some(); + if !has_effective_session { return ValidationResult { result: false, message: Some( @@ -739,7 +737,7 @@ Patch schema for "update": meta: None, }; } - if parsed.workspace.is_none() + if parsed.session_id.is_none() && context .and_then(|tool_context| tool_context.workspace_root()) .is_none() @@ -747,7 +745,7 @@ Patch schema for "update": return ValidationResult { result: false, message: Some( - "workspace is required for list when the current workspace is unavailable" + "the current workspace is required for list when session_id is omitted" .to_string(), ), error_code: Some(400), @@ -783,11 +781,11 @@ Patch schema for "update": }; } - if parsed.session_id.is_none() - && context + let has_effective_session = parsed.session_id.is_some() + || context .and_then(|tool_context| tool_context.session_id.as_deref()) - .is_none() - { + .is_some(); + if !has_effective_session { return ValidationResult { result: false, message: Some( @@ -798,7 +796,7 @@ Patch schema for "update": meta: None, }; } - if parsed.workspace.is_none() + if parsed.session_id.is_none() && context .and_then(|tool_context| tool_context.workspace_root()) .is_none() @@ -806,7 +804,7 @@ Patch schema for "update": return ValidationResult { result: false, message: Some( - "workspace is required for add when the current workspace is unavailable" + "the current workspace is required for add when session_id is omitted" .to_string(), ), error_code: Some(400), @@ -971,10 +969,11 @@ Patch schema for "update": CronAction::List => { let cron_service = get_global_cron_service() .ok_or_else(|| BitFunError::tool("cron service not initialized".to_string()))?; - let workspace = - self.resolve_effective_workspace(params.workspace.as_deref(), context)?; let session_id = self.resolve_effective_session_id(params.session_id.as_deref(), context)?; + let workspace = self + .resolve_effective_workspace_for_session(&session_id, context) + .await?; let mut jobs = cron_service .list_jobs_filtered(Some(&workspace), Some(&session_id)) .await; @@ -1004,10 +1003,11 @@ Patch schema for "update": CronAction::Add => { let cron_service = get_global_cron_service() .ok_or_else(|| BitFunError::tool("cron service not initialized".to_string()))?; - let workspace = - self.resolve_effective_workspace(params.workspace.as_deref(), context)?; let session_id = self.resolve_effective_session_id(params.session_id.as_deref(), context)?; + let workspace = self + .resolve_effective_workspace_for_session(&session_id, context) + .await?; let job = params .job .ok_or_else(|| BitFunError::tool("job is required for add".to_string()))?; @@ -1149,3 +1149,93 @@ Patch schema for "update": } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::framework::ToolUseContext; + use serde_json::json; + use std::collections::HashMap; + + fn empty_context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: Default::default(), + workspace_services: None, + } + } + + #[tokio::test] + async fn validate_list_allows_missing_workspace_when_session_id_present() { + let tool = CronTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "list", + "session_id": "worker_1", + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_add_allows_missing_workspace_when_session_id_present() { + let tool = CronTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "add", + "session_id": "worker_1", + "job": { + "payload": "hello", + "schedule": { + "kind": "every", + "every": 60 + } + } + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_rejects_legacy_workspace_field() { + let tool = CronTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "list", + "session_id": "worker_1", + "workspace": "E:/Projects/OpenBitfun/BitFun", + }), + Some(&empty_context()), + ) + .await; + + assert!(!validation.result); + assert!( + validation + .message + .as_deref() + .unwrap_or_default() + .contains("unknown field") + ); + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs index 0e4cd8627d..31a048310c 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs @@ -71,38 +71,6 @@ impl SessionControlTool { Ok(()) } - fn resolve_workspace(&self, workspace: &str) -> BitFunResult { - let workspace = workspace.trim(); - if workspace.is_empty() { - return Err(BitFunError::tool( - "workspace is required and cannot be empty".to_string(), - )); - } - - let path = Path::new(workspace); - if !path.is_absolute() { - return Err(BitFunError::tool( - "workspace must be an absolute path".to_string(), - )); - } - - let resolved = normalize_path(workspace); - let path = Path::new(&resolved); - if !path.exists() { - return Err(BitFunError::tool(format!( - "Workspace does not exist: {}", - resolved - ))); - } - if !path.is_dir() { - return Err(BitFunError::tool(format!( - "Workspace is not a directory: {}", - resolved - ))); - } - Ok(resolved) - } - fn default_session_name() -> String { "New Session".to_string() } @@ -126,6 +94,64 @@ impl SessionControlTool { Ok(format!("session-{}", creator_session_id)) } + fn validate_workspace_shape(&self, workspace: &str) -> ValidationResult { + if workspace.trim().is_empty() { + return ValidationResult { + result: false, + message: Some("workspace is required and cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + + if !Path::new(workspace.trim()).is_absolute() { + return ValidationResult { + result: false, + message: Some("workspace must be an absolute path".to_string()), + error_code: Some(400), + meta: None, + }; + } + + ValidationResult::default() + } + + async fn resolve_effective_workspace( + &self, + action: SessionControlAction, + session_id: Option<&str>, + context: &ToolUseContext, + coordinator: &crate::agentic::coordination::ConversationCoordinator, + ) -> BitFunResult { + match action { + SessionControlAction::Cancel | SessionControlAction::Delete => { + let session_id = session_id.ok_or_else(|| { + BitFunError::tool(format!("session_id is required for {}", action.as_str())) + })?; + if let Some(resolved) = coordinator + .resolve_session_workspace_path(session_id) + .await + .map(|path| path.to_string_lossy().to_string()) + { + return Ok(resolved); + } + Err(BitFunError::NotFound(format!( + "Workspace for session '{}' could not be resolved", + session_id + ))) + } + SessionControlAction::Create | SessionControlAction::List => context + .workspace_root() + .map(|path| normalize_path(path.to_string_lossy().as_ref())) + .ok_or_else(|| { + BitFunError::tool(format!( + "workspace is required for {} when the current workspace is unavailable", + action.as_str() + )) + }), + } + } + fn validate_mutating_action_target( &self, action: SessionControlAction, @@ -167,18 +193,20 @@ impl SessionControlTool { } if let Some(tool_context) = context { - if let Ok(workspace) = self.resolve_workspace(&parsed.workspace) { - if self.current_workspace_session(tool_context, &workspace) == Some(session_id) { - return ValidationResult { - result: false, - message: Some(format!( - "cannot {} the current session from SessionControl", - action.as_str() - )), - error_code: Some(400), - meta: None, - }; - } + if context + .and_then(|value| value.session_id.as_deref()) + .is_some_and(|current_session_id| current_session_id == session_id) + && tool_context.workspace_root().is_some() + { + return ValidationResult { + result: false, + message: Some(format!( + "cannot {} the current session from SessionControl", + action.as_str() + )), + error_code: Some(400), + meta: None, + }; } } @@ -287,7 +315,7 @@ impl SessionControlAgentType { #[derive(Debug, Clone, Deserialize)] struct SessionControlInput { action: SessionControlAction, - workspace: String, + workspace: Option, session_id: Option, session_name: Option, agent_type: Option, @@ -309,10 +337,8 @@ Actions: - "delete": Delete an existing session by session_id. - "list": List all sessions. -Required inputs: -- "workspace": Absolute workspace path for the target session scope. - -Optional inputs: +Arguments: +- "workspace": Absolute workspace path. Required for create and list. Ignored for cancel and delete. - "session_name": Only used by create. Defaults to "New Session". - "agent_type": Only used by create. Defaults to "agentic". - "agentic": Coding-focused agent for implementation, debugging, and code changes. @@ -342,7 +368,7 @@ Optional inputs: }, "workspace": { "type": "string", - "description": "Required absolute workspace path." + "description": "Required absolute workspace path for create and list. Ignored for cancel and delete." }, "session_id": { "type": "string", @@ -358,7 +384,7 @@ Optional inputs: "description": "Optional agent type when creating a session. Defaults to agentic." } }, - "required": ["action", "workspace"], + "required": ["action"], "additionalProperties": false }) } @@ -388,26 +414,46 @@ Optional inputs: } }; - if parsed.workspace.trim().is_empty() { - return ValidationResult { - result: false, - message: Some("workspace is required and cannot be empty".to_string()), - error_code: Some(400), - meta: None, - }; - } - - if !Path::new(parsed.workspace.trim()).is_absolute() { - return ValidationResult { - result: false, - message: Some("workspace must be an absolute path".to_string()), - error_code: Some(400), - meta: None, - }; + if let Some(workspace) = parsed.workspace.as_deref() { + let should_validate_workspace = matches!( + parsed.action, + SessionControlAction::Create | SessionControlAction::List + ); + if !should_validate_workspace { + // Ignore workspace for cancel/delete so callers cannot accidentally + // scope these actions to the wrong workspace. + return match parsed.action { + SessionControlAction::Cancel => self + .validate_mutating_action_target( + SessionControlAction::Cancel, + &parsed, + context, + ), + SessionControlAction::Delete => self + .validate_mutating_action_target( + SessionControlAction::Delete, + &parsed, + context, + ), + _ => ValidationResult::default(), + }; + } + let workspace_validation = self.validate_workspace_shape(workspace); + if !workspace_validation.result { + return workspace_validation; + } } match parsed.action { SessionControlAction::Create => { + if parsed.workspace.is_none() { + return ValidationResult { + result: false, + message: Some("workspace is required for create".to_string()), + error_code: Some(400), + meta: None, + }; + } if parsed.session_id.is_some() { return ValidationResult { result: false, @@ -445,6 +491,14 @@ Optional inputs: ); } SessionControlAction::List => { + if parsed.workspace.is_none() { + return ValidationResult { + result: false, + message: Some("workspace is required for list".to_string()), + error_code: Some(400), + meta: None, + }; + } if parsed.agent_type.is_some() { return ValidationResult { result: false, @@ -491,11 +545,8 @@ Optional inputs: match action { "create" => format!("Create session in {}", workspace), - "cancel" => format!( - "Cancel active turn for session {} in {}", - session_id, workspace - ), - "delete" => format!("Delete session {} in {}", session_id, workspace), + "cancel" => format!("Cancel active turn for session {}", session_id), + "delete" => format!("Delete session {}", session_id), "list" => format!("List sessions in {}", workspace), _ => format!("Manage sessions in {}", workspace), } @@ -508,13 +559,18 @@ Optional inputs: ) -> BitFunResult> { let params: SessionControlInput = serde_json::from_value(input.clone()) .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; - let workspace = self.resolve_workspace(¶ms.workspace)?; - let workspace_path = Path::new(&workspace); let coordinator = get_global_coordinator() .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; match params.action { SessionControlAction::Create => { + let workspace = self.resolve_effective_workspace( + SessionControlAction::Create, + None, + context, + &coordinator, + ) + .await?; let session_name = params .session_name .clone() @@ -568,6 +624,14 @@ Optional inputs: BitFunError::tool("session_id is required for cancel".to_string()) })?; Self::validate_session_id(session_id).map_err(BitFunError::tool)?; + let workspace = self.resolve_effective_workspace( + SessionControlAction::Cancel, + Some(session_id), + context, + &coordinator, + ) + .await?; + let workspace_path = Path::new(&workspace); if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(BitFunError::tool( "cannot cancel the current session from SessionControl".to_string(), @@ -641,6 +705,14 @@ Optional inputs: BitFunError::tool("session_id is required for delete".to_string()) })?; Self::validate_session_id(session_id).map_err(BitFunError::tool)?; + let workspace = self.resolve_effective_workspace( + SessionControlAction::Delete, + Some(session_id), + context, + &coordinator, + ) + .await?; + let workspace_path = Path::new(&workspace); if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(BitFunError::tool( "cannot delete the current session from SessionControl".to_string(), @@ -669,6 +741,14 @@ Optional inputs: }]) } SessionControlAction::List => { + let workspace = self.resolve_effective_workspace( + SessionControlAction::List, + None, + context, + &coordinator, + ) + .await?; + let workspace_path = Path::new(&workspace); let sessions = coordinator.list_sessions(workspace_path).await?; let current_session_id = self.current_workspace_session(context, &workspace); let result_for_assistant = @@ -728,13 +808,11 @@ mod tests { #[tokio::test] async fn validate_cancel_requires_session_id() { let tool = SessionControlTool::new(); - let workspace = temp_workspace_path(); let validation = tool .validate_input( &json!({ "action": "cancel", - "workspace": workspace, }), Some(&empty_context()), ) @@ -750,13 +828,11 @@ mod tests { #[tokio::test] async fn validate_cancel_rejects_session_name() { let tool = SessionControlTool::new(); - let workspace = temp_workspace_path(); let validation = tool .validate_input( &json!({ "action": "cancel", - "workspace": workspace, "session_id": "worker_1", "session_name": "should-not-be-here", }), @@ -771,6 +847,41 @@ mod tests { ); } + #[tokio::test] + async fn validate_cancel_allows_missing_workspace() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "cancel", + "session_id": "worker_1", + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_cancel_ignores_workspace_when_provided() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "cancel", + "session_id": "worker_1", + "workspace": "not-an-absolute-path", + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + #[tokio::test] async fn validate_list_rejects_session_id() { let tool = SessionControlTool::new(); @@ -794,6 +905,26 @@ mod tests { ); } + #[tokio::test] + async fn validate_list_requires_workspace() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "list", + }), + Some(&empty_context()), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("workspace is required for list") + ); + } + #[test] fn render_message_for_cancel_is_specific() { let tool = SessionControlTool::new(); @@ -806,6 +937,6 @@ mod tests { &ToolRenderOptions { verbose: false }, ); - assert_eq!(message, "Cancel active turn for session worker_1 in /repo"); + assert_eq!(message, "Cancel active turn for session worker_1"); } } diff --git a/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs index 826c0201ba..6f033500b1 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs @@ -1,4 +1,4 @@ -use super::util::normalize_path; +use crate::agentic::coordination::get_global_coordinator; use crate::agentic::persistence::PersistenceManager; use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, @@ -9,7 +9,6 @@ use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde::Deserialize; use serde_json::{json, Value}; -use std::path::Path; use std::sync::Arc; /// SessionHistory tool - export a grep-friendly transcript file for a session. @@ -47,39 +46,6 @@ impl SessionHistoryTool { Ok(()) } - fn resolve_workspace(&self, workspace: &str) -> BitFunResult { - let workspace = workspace.trim(); - if workspace.is_empty() { - return Err(BitFunError::tool( - "workspace is required and cannot be empty".to_string(), - )); - } - - let path = Path::new(workspace); - if !path.is_absolute() { - return Err(BitFunError::tool( - "workspace must be an absolute path".to_string(), - )); - } - - let resolved = normalize_path(workspace); - let path = Path::new(&resolved); - if !path.exists() { - return Err(BitFunError::tool(format!( - "Workspace does not exist: {}", - resolved - ))); - } - if !path.is_dir() { - return Err(BitFunError::tool(format!( - "Workspace is not a directory: {}", - resolved - ))); - } - - Ok(resolved) - } - fn resolve_session_id(&self, session_id: &str) -> BitFunResult { let session_id = session_id.trim().to_string(); Self::validate_session_id(&session_id).map_err(BitFunError::tool)?; @@ -89,7 +55,6 @@ impl SessionHistoryTool { #[derive(Debug, Clone, Deserialize)] struct SessionHistoryInput { - workspace: String, session_id: String, #[serde(default)] tools: Option, @@ -165,10 +130,6 @@ Examples: json!({ "type": "object", "properties": { - "workspace": { - "type": "string", - "description": "Required absolute workspace path." - }, "session_id": { "type": "string", "description": "Required session ID to export." @@ -193,7 +154,7 @@ Examples: "description": "Optional list of turn selectors. Supports index and start:end forms such as \":1\", \"-20:\", \"10:30\", or \"15\"." } }, - "required": ["workspace", "session_id"], + "required": ["session_id"], "additionalProperties": false }) } @@ -223,24 +184,6 @@ Examples: } }; - if parsed.workspace.trim().is_empty() { - return ValidationResult { - result: false, - message: Some("workspace is required and cannot be empty".to_string()), - error_code: Some(400), - meta: None, - }; - } - - if !Path::new(parsed.workspace.trim()).is_absolute() { - return ValidationResult { - result: false, - message: Some("workspace must be an absolute path".to_string()), - error_code: Some(400), - meta: None, - }; - } - if parsed.session_id.trim().is_empty() { return ValidationResult { result: false, @@ -304,12 +247,23 @@ Examples: let params: SessionHistoryInput = serde_json::from_value(input.clone()) .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; - let workspace = self.resolve_workspace(¶ms.workspace)?; let session_id = self.resolve_session_id(¶ms.session_id)?; + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + let workspace = coordinator + .resolve_session_workspace_path(&session_id) + .await + .map(|path| path.to_string_lossy().to_string()) + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Workspace for session '{}' could not be resolved", + session_id + )) + })?; let manager = PersistenceManager::new(Arc::new(PathManager::new()?))?; let transcript = manager .export_session_transcript( - Path::new(&workspace), + std::path::Path::new(&workspace), &session_id, &SessionTranscriptExportOptions { tools: params.tools.unwrap_or(false), @@ -336,3 +290,25 @@ Examples: }]) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[tokio::test] + async fn validate_allows_missing_workspace_when_session_id_present() { + let tool = SessionHistoryTool::new(); + + let validation = tool + .validate_input( + &json!({ + "session_id": "worker_1", + }), + None, + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs index 274d835d16..f6b99bb092 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs @@ -3,7 +3,7 @@ use crate::agentic::coordination::{ get_global_coordinator, get_global_scheduler, AgentSessionReplyRoute, DialogSubmissionPolicy, DialogTriggerSource, }; -use crate::agentic::core::PromptEnvelope; +use crate::agentic::core::{PromptEnvelope, SessionConfig}; use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; @@ -90,6 +90,52 @@ impl SessionMessageTool { Ok(resolved) } + fn validate_workspace_shape( + &self, + workspace: &str, + context: Option<&ToolUseContext>, + ) -> ValidationResult { + let workspace = workspace.trim(); + if workspace.is_empty() { + return ValidationResult { + result: false, + message: Some("workspace is required and cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + + match context { + Some(context) => { + let ws_ok = if context.is_remote() { + posix_style_path_is_absolute(workspace) + } else { + Path::new(workspace).is_absolute() + }; + if !ws_ok { + return ValidationResult { + result: false, + message: Some("workspace must be an absolute path".to_string()), + error_code: Some(400), + meta: None, + }; + } + } + None => { + if !Path::new(workspace).is_absolute() && !posix_style_path_is_absolute(workspace) { + return ValidationResult { + result: false, + message: Some("workspace must be an absolute path".to_string()), + error_code: Some(400), + meta: None, + }; + } + } + } + + ValidationResult::default() + } + fn sender_session_id<'a>(&self, context: &'a ToolUseContext) -> BitFunResult<&'a str> { context.session_id.as_deref().ok_or_else(|| { BitFunError::tool("SessionMessage requires a source session".to_string()) @@ -105,6 +151,13 @@ impl SessionMessageTool { }) } + fn creator_session_marker(&self, context: &ToolUseContext) -> BitFunResult { + let creator_session_id = context.session_id.as_ref().ok_or_else(|| { + BitFunError::tool("SessionMessage requires a source session".to_string()) + })?; + Ok(format!("session-{}", creator_session_id)) + } + fn format_forwarded_message(&self, message: &str) -> String { let mut envelope = PromptEnvelope::new(); envelope.push_system_reminder( @@ -134,28 +187,13 @@ impl SessionMessageAgentType { Self::Cowork => "Cowork", } } - - fn from_str(value: &str) -> Option { - if value.eq_ignore_ascii_case("agentic") { - Some(Self::Agentic) - } else if value.eq_ignore_ascii_case("plan") { - Some(Self::Plan) - } else if value.eq_ignore_ascii_case("cowork") { - Some(Self::Cowork) - } else { - None - } - } - - fn is_coding_mode(&self) -> bool { - matches!(self, Self::Agentic | Self::Plan) - } } #[derive(Debug, Clone, Deserialize)] struct SessionMessageInput { - workspace: String, - session_id: String, + workspace: Option, + session_id: Option, + session_name: Option, message: String, agent_type: Option, } @@ -169,13 +207,16 @@ impl Tool for SessionMessageTool { async fn description(&self) -> BitFunResult { Ok( r#"Asynchronously send a message to another agent session. When the target session finishes, its result is automatically sent back to you as a follow-up message. - -You must provide the target workspace as an absolute path, and you can optionally set agent_type to choose how the target session handles the request: + +Usage: +- Create a new session and send: omit "session_id", and provide "workspace", "session_name", "agent_type", and "message". +- Reusing an existing session: provide "session_id" and "message". You may omit "workspace"; the tool will resolve it from the target session when possible. + +Allowed agent types when creating a session: - "agentic": Coding-focused agent for implementation, debugging, and code changes. - "Plan": Planning agent for clarifying requirements and producing an implementation plan before coding. - "Cowork": Collaborative agent for office-style work such as research, documentation, presentations, etc. - -When overriding an existing session's agent_type, only switching between "agentic" and "Plan" is allowed. It will not switch coding sessions to or from "Cowork"."# +"# .to_string(), ) } @@ -194,11 +235,15 @@ When overriding an existing session's agent_type, only switching between "agenti "properties": { "workspace": { "type": "string", - "description": "Required absolute target workspace path." + "description": "Required absolute target workspace path when creating a new session. Optional when session_id is provided." }, "session_id": { "type": "string", - "description": "Target session ID." + "description": "Optional target session ID. Omit it to create a new session and send the message there." + }, + "session_name": { + "type": "string", + "description": "Required when session_id is omitted. Display name for the new session." }, "message": { "type": "string", @@ -207,10 +252,10 @@ When overriding an existing session's agent_type, only switching between "agenti "agent_type": { "type": "string", "enum": ["agentic", "Plan", "Cowork"], - "description": "Optional target agent type. Defaults to the target session's current agent type." + "description": "Required when session_id is omitted. Not allowed when sending to an existing session." } }, - "required": ["workspace", "session_id", "message"], + "required": ["message"], "additionalProperties": false }) } @@ -240,15 +285,6 @@ When overriding an existing session's agent_type, only switching between "agenti } }; - if let Err(message) = Self::validate_session_id(&parsed.session_id) { - return ValidationResult { - result: false, - message: Some(message), - error_code: Some(400), - meta: None, - }; - } - if parsed.message.trim().is_empty() { return ValidationResult { result: false, @@ -258,42 +294,94 @@ When overriding an existing session's agent_type, only switching between "agenti }; } - if parsed.workspace.trim().is_empty() { - return ValidationResult { - result: false, - message: Some("workspace is required and cannot be empty".to_string()), - error_code: Some(400), - meta: None, - }; - } + match parsed.session_id.as_deref() { + Some(session_id) => { + if let Err(message) = Self::validate_session_id(session_id) { + return ValidationResult { + result: false, + message: Some(message), + error_code: Some(400), + meta: None, + }; + } - let Some(context) = context else { - if !Path::new(parsed.workspace.trim()).is_absolute() - && !posix_style_path_is_absolute(parsed.workspace.trim()) - { - return ValidationResult { - result: false, - message: Some("workspace must be an absolute path".to_string()), - error_code: Some(400), - meta: None, + if parsed.session_name.is_some() { + return ValidationResult { + result: false, + message: Some( + "session_name is only allowed when session_id is omitted".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + + if parsed.agent_type.is_some() { + return ValidationResult { + result: false, + message: Some( + "agent_type override is not allowed when session_id is provided" + .to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + + if let Some(workspace) = parsed.workspace.as_deref() { + let workspace_validation = self.validate_workspace_shape(workspace, context); + if !workspace_validation.result { + return workspace_validation; + } + } + } + None => { + if parsed + .session_name + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return ValidationResult { + result: false, + message: Some( + "session_name is required when session_id is omitted".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + + if parsed.agent_type.is_none() { + return ValidationResult { + result: false, + message: Some( + "agent_type is required when session_id is omitted".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + + let Some(workspace) = parsed.workspace.as_deref() else { + return ValidationResult { + result: false, + message: Some( + "workspace is required when session_id is omitted".to_string(), + ), + error_code: Some(400), + meta: None, + }; }; + let workspace_validation = self.validate_workspace_shape(workspace, context); + if !workspace_validation.result { + return workspace_validation; + } } - return ValidationResult::default(); - }; + } - let ws_ok = if context.is_remote() { - posix_style_path_is_absolute(parsed.workspace.trim()) - } else { - Path::new(parsed.workspace.trim()).is_absolute() + let Some(context) = context else { + return ValidationResult::default(); }; - if !ws_ok { - return ValidationResult { - result: false, - message: Some("workspace must be an absolute path".to_string()), - error_code: Some(400), - meta: None, - }; - } let Some(source_session_id) = context.session_id.as_deref() else { return ValidationResult { @@ -306,15 +394,17 @@ When overriding an existing session's agent_type, only switching between "agenti }; }; - if source_session_id == parsed.session_id { - return ValidationResult { - result: false, - message: Some( - "SessionMessage cannot send a message to the same session".to_string(), - ), - error_code: Some(400), - meta: None, - }; + if let Some(target_session_id) = parsed.session_id.as_deref() { + if source_session_id == target_session_id { + return ValidationResult { + result: false, + message: Some( + "SessionMessage cannot send a message to the same session".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } } ValidationResult::default() @@ -324,13 +414,16 @@ When overriding an existing session's agent_type, only switching between "agenti let workspace = input .get("workspace") .and_then(|value| value.as_str()) - .unwrap_or("unknown workspace"); - let session_id = input - .get("session_id") - .and_then(|value| value.as_str()) - .unwrap_or("unknown"); - - format!("Send message to session {} in {}", session_id, workspace) + .unwrap_or("resolved workspace"); + if let Some(session_id) = input.get("session_id").and_then(|value| value.as_str()) { + format!("Send message to session {} in {}", session_id, workspace) + } else { + let session_name = input + .get("session_name") + .and_then(|value| value.as_str()) + .unwrap_or("new session"); + format!("Create session {} in {} and send message", session_name, workspace) + } } async fn call_impl( @@ -340,17 +433,7 @@ When overriding an existing session's agent_type, only switching between "agenti ) -> BitFunResult> { let params: SessionMessageInput = serde_json::from_value(input.clone()) .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; - let workspace = self.resolve_workspace(¶ms.workspace, context)?; - let workspace_path = Path::new(&workspace); let source_session_id = self.sender_session_id(context)?.to_string(); - let target_session_id = params.session_id.clone(); - - if source_session_id == target_session_id { - return Err(BitFunError::tool( - "SessionMessage cannot send a message to the same session".to_string(), - )); - } - let source_workspace = self.sender_workspace(context)?; let coordinator = get_global_coordinator() @@ -358,47 +441,98 @@ When overriding an existing session's agent_type, only switching between "agenti let scheduler = get_global_scheduler() .ok_or_else(|| BitFunError::tool("scheduler not initialized".to_string()))?; - let existing_sessions = coordinator.list_sessions(workspace_path).await?; - let target_session = existing_sessions - .iter() - .find(|session| session.session_id == target_session_id.as_str()) - .ok_or_else(|| { - BitFunError::NotFound(format!( - "Session '{}' not found in workspace '{}'", - target_session_id, workspace - )) - })?; - - let persisted_agent_type = target_session.agent_type.trim(); - let target_agent_type = if let Some(requested_agent_type) = params.agent_type.as_ref() { - let current_agent_type = if persisted_agent_type.is_empty() { - SessionMessageAgentType::Agentic - } else { - SessionMessageAgentType::from_str(persisted_agent_type).ok_or_else(|| { - BitFunError::tool(format!( - "SessionMessage agent_type override is only supported for sessions using 'agentic', 'Plan', or 'Cowork'. Current agent type is '{}'.", - persisted_agent_type - )) - })? - }; + let (target_session_id, target_agent_type, created_session_id, workspace) = + if let Some(target_session_id) = params.session_id.clone() { + if source_session_id == target_session_id { + return Err(BitFunError::tool( + "SessionMessage cannot send a message to the same session".to_string(), + )); + } - if requested_agent_type.as_str() != current_agent_type.as_str() - && !(requested_agent_type.is_coding_mode() && current_agent_type.is_coding_mode()) - { - return Err(BitFunError::tool(format!( - "SessionMessage only allows agent_type override between 'agentic' and 'Plan'. Cannot switch session '{}' from '{}' to '{}'.", - target_session_id, - current_agent_type.as_str(), - requested_agent_type.as_str() - ))); - } + let workspace = if let Some(workspace) = params.workspace.as_deref() { + self.resolve_workspace(workspace, context)? + } else { + coordinator + .resolve_session_workspace_path(&target_session_id) + .await + .map(|path| path.to_string_lossy().to_string()) + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Workspace for session '{}' could not be resolved", + target_session_id + )) + })? + }; + let workspace_path = Path::new(&workspace); + let existing_sessions = coordinator.list_sessions(workspace_path).await?; + let target_session = existing_sessions + .iter() + .find(|session| session.session_id == target_session_id.as_str()) + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Session '{}' not found in workspace '{}'", + target_session_id, workspace + )) + })?; + + let persisted_agent_type = target_session.agent_type.trim(); + let target_agent_type = if persisted_agent_type.is_empty() { + "agentic".to_string() + } else { + persisted_agent_type.to_string() + }; - requested_agent_type.as_str().to_string() - } else if persisted_agent_type.is_empty() { - "agentic".to_string() - } else { - persisted_agent_type.to_string() - }; + (target_session_id, target_agent_type, None, workspace) + } else { + let workspace = self.resolve_workspace( + params.workspace.as_deref().ok_or_else(|| { + BitFunError::tool( + "workspace is required when session_id is omitted".to_string(), + ) + })?, + context, + )?; + let session_name = params + .session_name + .clone() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + BitFunError::tool( + "session_name is required when session_id is omitted".to_string(), + ) + })?; + let agent_type = params + .agent_type + .as_ref() + .ok_or_else(|| { + BitFunError::tool( + "agent_type is required when session_id is omitted".to_string(), + ) + })? + .as_str() + .to_string(); + let created_by = self.creator_session_marker(context)?; + let session = coordinator + .create_session_with_workspace_and_creator( + None, + session_name, + agent_type.clone(), + SessionConfig { + workspace_path: Some(workspace.clone()), + ..Default::default() + }, + workspace.clone(), + Some(created_by), + ) + .await?; + + ( + session.session_id.clone(), + session.agent_type.clone(), + Some(session.session_id), + workspace, + ) + }; let forwarded_message = self.format_forwarded_message(¶ms.message); @@ -427,12 +561,191 @@ When overriding an existing session's agent_type, only switching between "agenti "target_workspace": workspace.clone(), "target_session_id": target_session_id.clone(), "target_agent_type": target_agent_type.clone(), + "created_session_id": created_session_id.clone(), + }), + result_for_assistant: Some(if let Some(created_session_id) = created_session_id { + format!( + "Created session '{}' and accepted the message in workspace '{}' using agent type '{}'.", + created_session_id, workspace, target_agent_type + ) + } else { + format!( + "Message accepted for session '{}' in workspace '{}' using agent type '{}'.", + target_session_id, workspace, target_agent_type + ) }), - result_for_assistant: Some(format!( - "Message accepted for session '{}' in workspace '{}' using agent type '{}'.", - target_session_id, workspace, target_agent_type - )), image_attachments: None, }]) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::framework::ToolUseContext; + use serde_json::json; + use std::collections::HashMap; + use std::fs; + use uuid::Uuid; + + fn empty_context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: Default::default(), + workspace_services: None, + } + } + + fn session_context(session_id: &str) -> ToolUseContext { + ToolUseContext { + session_id: Some(session_id.to_string()), + ..empty_context() + } + } + + fn temp_workspace_path() -> String { + let path = std::env::temp_dir().join(format!( + "bitfun-session-message-tool-test-{}", + Uuid::new_v4() + )); + fs::create_dir_all(&path).expect("temp workspace should be created"); + path.to_string_lossy().to_string() + } + + #[tokio::test] + async fn validate_existing_session_rejects_agent_type_override() { + let tool = SessionMessageTool::new(); + let workspace = temp_workspace_path(); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace, + "session_id": "worker_1", + "message": "hello", + "agent_type": "Plan", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("agent_type override is not allowed when session_id is provided") + ); + } + + #[tokio::test] + async fn validate_new_session_requires_session_name() { + let tool = SessionMessageTool::new(); + let workspace = temp_workspace_path(); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace, + "message": "hello", + "agent_type": "agentic", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("session_name is required when session_id is omitted") + ); + } + + #[tokio::test] + async fn validate_new_session_requires_agent_type() { + let tool = SessionMessageTool::new(); + let workspace = temp_workspace_path(); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace, + "message": "hello", + "session_name": "Worker Session", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("agent_type is required when session_id is omitted") + ); + } + + #[tokio::test] + async fn validate_new_session_accepts_create_and_send_shape() { + let tool = SessionMessageTool::new(); + let workspace = temp_workspace_path(); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace, + "message": "hello", + "session_name": "Worker Session", + "agent_type": "agentic", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_existing_session_allows_missing_workspace() { + let tool = SessionMessageTool::new(); + + let validation = tool + .validate_input( + &json!({ + "session_id": "worker_1", + "message": "hello", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_new_session_requires_workspace() { + let tool = SessionMessageTool::new(); + + let validation = tool + .validate_input( + &json!({ + "message": "hello", + "session_name": "Worker Session", + "agent_type": "agentic", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("workspace is required when session_id is omitted") + ); + } +} diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index e5124daa93..aab62d8e6c 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -36,67 +36,9 @@ fn current_workspace_path() -> Option { async fn resolve_session_workspace_path(session_id: &str) -> Option { use crate::agentic::coordination::get_global_coordinator; - use crate::agentic::persistence::PersistenceManager; - use crate::infrastructure::PathManager; - use crate::service::workspace::get_global_workspace_service; if let Some(coordinator) = get_global_coordinator() { - if let Some(workspace_path) = coordinator - .get_session_manager() - .get_session(session_id) - .and_then(|session| session.config.workspace_path.clone()) - .filter(|path| !path.is_empty()) - { - return Some(std::path::PathBuf::from(workspace_path)); - } - } - - let workspace_service = get_global_workspace_service()?; - let mut candidates: Vec = workspace_service - .get_opened_workspaces() - .await - .into_iter() - .map(|workspace| workspace.root_path) - .collect(); - - if let Some(current_workspace) = workspace_service.get_current_workspace().await { - let current_root = current_workspace.root_path; - if !candidates.iter().any(|path| path == ¤t_root) { - candidates.push(current_root); - } - } - - let Ok(path_manager) = PathManager::new() else { - return None; - }; - let path_manager = Arc::new(path_manager); - let Ok(persistence_manager) = PersistenceManager::new(path_manager) else { - return None; - }; - - for workspace_path in candidates { - match persistence_manager - .load_session_metadata(&workspace_path, session_id) - .await - { - Ok(Some(metadata)) => { - if let Some(bound_workspace) = - metadata.workspace_path.filter(|path| !path.is_empty()) - { - return Some(std::path::PathBuf::from(bound_workspace)); - } - return Some(workspace_path); - } - Ok(None) => {} - Err(err) => { - debug!( - "Failed to load session metadata while resolving workspace: session_id={} workspace={} error={}", - session_id, - workspace_path.display(), - err - ); - } - } + return coordinator.resolve_session_workspace_path(session_id).await; } None diff --git a/src/crates/core/src/service/workspace/manager.rs b/src/crates/core/src/service/workspace/manager.rs index d4cc00016d..c1bfa80ecc 100644 --- a/src/crates/core/src/service/workspace/manager.rs +++ b/src/crates/core/src/service/workspace/manager.rs @@ -870,6 +870,25 @@ impl WorkspaceManager { &mut self, path: PathBuf, options: WorkspaceOpenOptions, + ) -> BitFunResult { + self.upsert_workspace_with_options(path, options, true).await + } + + /// Registers or refreshes workspace activity without changing opened UI state. + pub async fn track_workspace_with_options( + &mut self, + path: PathBuf, + options: WorkspaceOpenOptions, + ) -> BitFunResult { + self.upsert_workspace_with_options(path, options, false) + .await + } + + async fn upsert_workspace_with_options( + &mut self, + path: PathBuf, + options: WorkspaceOpenOptions, + keep_opened: bool, ) -> BitFunResult { let is_remote = options.workspace_kind == WorkspaceKind::Remote; @@ -1032,7 +1051,9 @@ impl WorkspaceManager { workspace.load_identity().await; workspace.load_worktree().await; } - self.ensure_workspace_open(&workspace_id); + if keep_opened { + self.ensure_workspace_open(&workspace_id); + } if options.auto_set_current { self.set_current_workspace_with_recent_policy( workspace_id.clone(), @@ -1054,7 +1075,9 @@ impl WorkspaceManager { self.workspaces .insert(workspace_id.clone(), workspace.clone()); - self.ensure_workspace_open(&workspace_id); + if keep_opened { + self.ensure_workspace_open(&workspace_id); + } if options.auto_set_current { self.set_current_workspace_with_recent_policy( workspace_id.clone(), diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index 5bfc2f7143..1a960320ae 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -13,7 +13,9 @@ use crate::infrastructure::{try_get_path_manager_arc, PathManager}; use crate::service::bootstrap::{ ensure_workspace_gitignore_ignores_bitfun, initialize_workspace_persona_files, }; -use crate::service::remote_ssh::workspace_state::local_workspace_roots_equal; +use crate::service::remote_ssh::workspace_state::{ + local_workspace_roots_equal, normalize_remote_workspace_path, remote_workspace_stable_id, +}; use crate::service::workspace_runtime::{ try_get_workspace_runtime_service_arc, WorkspaceRuntimeService, }; @@ -342,6 +344,40 @@ impl WorkspaceService { result } + /// Registers or refreshes workspace activity without marking it as opened in the UI. + pub async fn track_workspace_activity( + &self, + path: PathBuf, + options: WorkspaceCreateOptions, + ) -> BitFunResult { + let mut options = self.normalize_workspace_options_for_path(&path, options); + options.auto_set_current = false; + let result = { + let mut manager = self.manager.write().await; + manager + .track_workspace_with_options(path, Self::to_manager_open_options(&options)) + .await + }; + + if let Ok(workspace) = result.as_ref() { + self.ensure_workspace_runtime_best_effort(workspace, "tracked") + .await; + } + + if result.is_ok() { + if let Err(e) = self.save_workspace_data().await { + warn!("Failed to save workspace data after tracking activity: {}", e); + } + } + + if let Ok(workspace) = result.as_ref() { + self.maintain_workspace_sessions_best_effort(&workspace.root_path, "workspace_tracked") + .await; + } + + result + } + /// Quickly opens a workspace (using default options). pub async fn quick_open(&self, path: &str) -> BitFunResult { let path_buf = PathBuf::from(path); @@ -1604,6 +1640,19 @@ impl WorkspaceService { mut options: WorkspaceCreateOptions, ) -> WorkspaceCreateOptions { if options.workspace_kind == WorkspaceKind::Remote { + if options.stable_workspace_id.is_none() { + if let Some(ssh_host) = options + .remote_ssh_host + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + options.stable_workspace_id = Some(remote_workspace_stable_id( + ssh_host, + &normalize_remote_workspace_path(&path.to_string_lossy()), + )); + } + } return options; } @@ -2080,4 +2129,62 @@ mod tests { "legacy session directory should be removed after startup migration" ); } + + #[tokio::test] + async fn track_workspace_activity_registers_without_opening_workspace() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + let workspace_root = env.create_workspace_dir("tracked-workspace"); + + let tracked = service + .track_workspace_activity(workspace_root.clone(), WorkspaceCreateOptions::default()) + .await + .expect("workspace tracking should succeed"); + + let tracked_by_path = service + .get_workspace_by_path(&workspace_root) + .await + .expect("tracked workspace should be queryable by path"); + assert_eq!(tracked_by_path.id, tracked.id); + + let recent = service.get_recent_workspaces().await; + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].id, tracked.id); + + assert!( + service.get_opened_workspaces().await.is_empty(), + "tracked workspace activity should not add the workspace to the opened UI list" + ); + assert!( + service.get_current_workspace().await.is_none(), + "tracked workspace activity should not change the current workspace" + ); + } + + #[tokio::test] + async fn track_workspace_activity_assigns_stable_remote_workspace_id() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + let remote_workspace_root = PathBuf::from("/srv/bitfun/project"); + + let tracked = service + .track_workspace_activity( + remote_workspace_root.clone(), + WorkspaceCreateOptions { + workspace_kind: WorkspaceKind::Remote, + remote_connection_id: Some("conn-1".to_string()), + remote_ssh_host: Some("example-host".to_string()), + ..Default::default() + }, + ) + .await + .expect("remote workspace tracking should succeed"); + + assert_eq!( + tracked.id, + remote_workspace_stable_id("example-host", "/srv/bitfun/project") + ); + assert_eq!(tracked.root_path, remote_workspace_root); + assert!(service.get_opened_workspaces().await.is_empty()); + } } From b2414ac90b58803aec4e11aa7af5d6312f6919ca Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Fri, 15 May 2026 12:24:21 +0800 Subject: [PATCH 13/76] test(core): clean up temp dirs created by bitfun-core tests - isolate session and subagent test temp workspaces with Drop cleanup - clean up temp repos used by function agent port adapter tests - clean up miniapp runtime and storage temp roots after tests - keep bitfun-core test runs from leaving bitfun-* directories in Temp --- .../agents/definitions/custom/subagent.rs | 28 +++++-- .../core/src/agentic/persistence/manager.rs | 26 ++++--- .../src/agentic/persistence/session_branch.rs | 10 ++- .../session_workspace_maintenance.rs | 11 ++- .../src/agentic/session/session_manager.rs | 12 ++- .../implementations/session_control_tool.rs | 32 +++++--- .../implementations/session_message_tool.rs | 44 +++++++---- .../core/src/function_agents/port_adapters.rs | 73 ++++++++++++------- src/crates/core/src/miniapp/js_worker_pool.rs | 37 ++++++++-- src/crates/core/src/miniapp/storage.rs | 37 ++++++++-- 10 files changed, 224 insertions(+), 86 deletions(-) diff --git a/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs b/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs index 7d4ab0038c..87f93ff0cc 100644 --- a/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs +++ b/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs @@ -199,17 +199,35 @@ impl CustomSubagent { mod tests { use super::*; use std::fs; + use std::path::PathBuf; use uuid::Uuid; - fn temp_subagent_path(name: &str) -> String { - let dir = std::env::temp_dir().join(format!("bitfun-subagent-test-{}", Uuid::new_v4())); - fs::create_dir_all(&dir).expect("temp subagent dir should be created"); - dir.join(name).to_string_lossy().to_string() + struct TestTempDir { + path: PathBuf, + } + + impl TestTempDir { + fn new(prefix: &str) -> Self { + let path = std::env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4())); + fs::create_dir_all(&path).expect("temp dir should be created"); + Self { path } + } + + fn join(&self, name: &str) -> String { + self.path.join(name).to_string_lossy().to_string() + } + } + + impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } } #[test] fn review_metadata_round_trips_through_front_matter() { - let path = temp_subagent_path("review-agent.md"); + let dir = TestTempDir::new("bitfun-subagent-test"); + let path = dir.join("review-agent.md"); let mut subagent = CustomSubagent::new( "ReviewExtra".to_string(), "Additional code reviewer".to_string(), diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 546e7398db..d671655919 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -2571,6 +2571,12 @@ mod tests { fn path(&self) -> &Path { &self.path } + + fn path_manager(&self) -> Arc { + Arc::new(PathManager::with_user_root_for_tests( + self.path.join("user-root"), + )) + } } impl Drop for TestWorkspace { @@ -2621,8 +2627,8 @@ mod tests { #[tokio::test] async fn export_session_transcript_handles_first_selected_turn_without_panicking() { let workspace = TestWorkspace::new(); - let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); let session_id = Uuid::new_v4().to_string(); let metadata = SessionMetadata::new( @@ -2948,8 +2954,8 @@ mod tests { #[tokio::test] async fn subagent_session_kind_is_hidden_from_visible_session_index() { let workspace = TestWorkspace::new(); - let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); let mut metadata = SessionMetadata::new( Uuid::new_v4().to_string(), @@ -2981,8 +2987,8 @@ mod tests { #[tokio::test] async fn legacy_leaked_subagent_is_hidden_from_visible_session_index() { let workspace = TestWorkspace::new(); - let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); let mut metadata = SessionMetadata::new( Uuid::new_v4().to_string(), @@ -3014,8 +3020,8 @@ mod tests { #[tokio::test] async fn listing_sessions_does_not_create_sessions_dir_for_uninitialized_runtime() { let workspace = TestWorkspace::new(); - let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); let visible = manager .list_session_metadata(workspace.path()) @@ -3037,8 +3043,8 @@ mod tests { #[tokio::test] async fn saving_session_metadata_ensures_runtime_layout_before_writing() { let workspace = TestWorkspace::new(); - let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); let metadata = SessionMetadata::new( Uuid::new_v4().to_string(), diff --git a/src/crates/core/src/agentic/persistence/session_branch.rs b/src/crates/core/src/agentic/persistence/session_branch.rs index 306be413f9..bf95090977 100644 --- a/src/crates/core/src/agentic/persistence/session_branch.rs +++ b/src/crates/core/src/agentic/persistence/session_branch.rs @@ -246,6 +246,12 @@ mod tests { fn path(&self) -> &Path { &self.path } + + fn path_manager(&self) -> Arc { + Arc::new(PathManager::with_user_root_for_tests( + self.path.join("user-root"), + )) + } } impl Drop for TestWorkspace { @@ -278,8 +284,8 @@ mod tests { #[tokio::test] async fn branch_session_copies_turns_snapshots_and_lineage_metadata() { let workspace = TestWorkspace::new(); - let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); let mut source_session = Session::new( "Source Title".to_string(), diff --git a/src/crates/core/src/agentic/persistence/session_workspace_maintenance.rs b/src/crates/core/src/agentic/persistence/session_workspace_maintenance.rs index 3121a2e583..cc3da63991 100644 --- a/src/crates/core/src/agentic/persistence/session_workspace_maintenance.rs +++ b/src/crates/core/src/agentic/persistence/session_workspace_maintenance.rs @@ -137,6 +137,12 @@ mod tests { fn path(&self) -> &Path { &self.path } + + fn path_manager(&self) -> Arc { + Arc::new(PathManager::with_user_root_for_tests( + self.path.join("user-root"), + )) + } } impl Drop for TestWorkspace { @@ -149,8 +155,7 @@ mod tests { async fn workspace_maintenance_removes_hidden_sessions_once() { let workspace = TestWorkspace::new(); let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), ); let maintenance = SessionWorkspaceMaintenanceService::new(persistence_manager.clone()); @@ -219,7 +224,7 @@ mod tests { #[tokio::test] async fn legacy_hidden_sessions_are_migrated_then_cleaned_after_runtime_ensure() { let workspace = TestWorkspace::new(); - let path_manager = Arc::new(PathManager::new().expect("path manager")); + let path_manager = workspace.path_manager(); let runtime_service = WorkspaceRuntimeService::new(path_manager.clone()); let persistence_manager = Arc::new(PersistenceManager::new(path_manager.clone()).expect("persistence manager")); diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 554e0359fa..3b4b374746 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -2826,6 +2826,12 @@ mod tests { fn path(&self) -> &Path { &self.path } + + fn path_manager(&self) -> Arc { + Arc::new(PathManager::with_user_root_for_tests( + self.path.join("user-root"), + )) + } } impl Drop for TestWorkspace { @@ -2984,8 +2990,7 @@ mod tests { async fn restore_session_resets_processing_state_without_marking_unread_completion() { let workspace = TestWorkspace::new(); let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), ); let session_id = Uuid::new_v4().to_string(); let mut session = Session::new_with_id( @@ -3218,8 +3223,7 @@ mod tests { async fn rollback_context_deletes_persisted_turns_from_target() { let workspace = TestWorkspace::new(); let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), ); let manager = test_manager(persistence_manager.clone()); let session = manager diff --git a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs index 31a048310c..b106b1ef0d 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs @@ -778,6 +778,7 @@ mod tests { use serde_json::json; use std::collections::HashMap; use std::fs; + use std::path::PathBuf; use uuid::Uuid; fn empty_context() -> ToolUseContext { @@ -796,13 +797,26 @@ mod tests { } } - fn temp_workspace_path() -> String { - let path = std::env::temp_dir().join(format!( - "bitfun-session-control-tool-test-{}", - Uuid::new_v4() - )); - fs::create_dir_all(&path).expect("temp workspace should be created"); - path.to_string_lossy().to_string() + struct TestTempDir { + path: PathBuf, + } + + impl TestTempDir { + fn new(prefix: &str) -> Self { + let path = std::env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4())); + fs::create_dir_all(&path).expect("temp workspace should be created"); + Self { path } + } + + fn as_string(&self) -> String { + self.path.to_string_lossy().to_string() + } + } + + impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } } #[tokio::test] @@ -885,13 +899,13 @@ mod tests { #[tokio::test] async fn validate_list_rejects_session_id() { let tool = SessionControlTool::new(); - let workspace = temp_workspace_path(); + let workspace = TestTempDir::new("bitfun-session-control-tool-test"); let validation = tool .validate_input( &json!({ "action": "list", - "workspace": workspace, + "workspace": workspace.as_string(), "session_id": "worker_1", }), Some(&empty_context()), diff --git a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs index f6b99bb092..911f658ff8 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs @@ -586,6 +586,7 @@ mod tests { use serde_json::json; use std::collections::HashMap; use std::fs; + use std::path::PathBuf; use uuid::Uuid; fn empty_context() -> ToolUseContext { @@ -611,24 +612,37 @@ mod tests { } } - fn temp_workspace_path() -> String { - let path = std::env::temp_dir().join(format!( - "bitfun-session-message-tool-test-{}", - Uuid::new_v4() - )); - fs::create_dir_all(&path).expect("temp workspace should be created"); - path.to_string_lossy().to_string() + struct TestTempDir { + path: PathBuf, + } + + impl TestTempDir { + fn new(prefix: &str) -> Self { + let path = std::env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4())); + fs::create_dir_all(&path).expect("temp workspace should be created"); + Self { path } + } + + fn as_string(&self) -> String { + self.path.to_string_lossy().to_string() + } + } + + impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } } #[tokio::test] async fn validate_existing_session_rejects_agent_type_override() { let tool = SessionMessageTool::new(); - let workspace = temp_workspace_path(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); let validation = tool .validate_input( &json!({ - "workspace": workspace, + "workspace": workspace.as_string(), "session_id": "worker_1", "message": "hello", "agent_type": "Plan", @@ -647,12 +661,12 @@ mod tests { #[tokio::test] async fn validate_new_session_requires_session_name() { let tool = SessionMessageTool::new(); - let workspace = temp_workspace_path(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); let validation = tool .validate_input( &json!({ - "workspace": workspace, + "workspace": workspace.as_string(), "message": "hello", "agent_type": "agentic", }), @@ -670,12 +684,12 @@ mod tests { #[tokio::test] async fn validate_new_session_requires_agent_type() { let tool = SessionMessageTool::new(); - let workspace = temp_workspace_path(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); let validation = tool .validate_input( &json!({ - "workspace": workspace, + "workspace": workspace.as_string(), "message": "hello", "session_name": "Worker Session", }), @@ -693,12 +707,12 @@ mod tests { #[tokio::test] async fn validate_new_session_accepts_create_and_send_shape() { let tool = SessionMessageTool::new(); - let workspace = temp_workspace_path(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); let validation = tool .validate_input( &json!({ - "workspace": workspace, + "workspace": workspace.as_string(), "message": "hello", "session_name": "Worker Session", "agent_type": "agentic", diff --git a/src/crates/core/src/function_agents/port_adapters.rs b/src/crates/core/src/function_agents/port_adapters.rs index a38c225b8c..bb9ced2350 100644 --- a/src/crates/core/src/function_agents/port_adapters.rs +++ b/src/crates/core/src/function_agents/port_adapters.rs @@ -169,22 +169,49 @@ fn git_last_commit_timestamp(repo_path: &Path) -> Option { mod tests { use bitfun_product_domains::function_agents::ports::FunctionAgentGitPort; use std::fs; + use std::path::{Path, PathBuf}; use std::process::Command; use super::CoreFunctionAgentGitAdapter; + struct TestTempDir { + path: PathBuf, + } + + impl TestTempDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "bitfun-function-agent-port-{}-{}", + label, + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + #[tokio::test] async fn git_adapter_builds_commit_snapshot_from_existing_core_git_services() { - let repo = temp_repo("commit-snapshot"); - init_git_repo(&repo); - fs::write(repo.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap(); - fs::create_dir_all(repo.join("src")).unwrap(); - fs::write(repo.join("src/lib.rs"), "pub fn demo() {}\n").unwrap(); - git(&repo, &["add", "Cargo.toml", "src/lib.rs"]); + let repo = TestTempDir::new("commit-snapshot"); + init_git_repo(repo.path()); + fs::write(repo.path().join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap(); + fs::create_dir_all(repo.path().join("src")).unwrap(); + fs::write(repo.path().join("src/lib.rs"), "pub fn demo() {}\n").unwrap(); + git(repo.path(), &["add", "Cargo.toml", "src/lib.rs"]); let adapter = CoreFunctionAgentGitAdapter::default(); let snapshot = adapter - .git_commit_snapshot(repo.to_string_lossy().to_string()) + .git_commit_snapshot(repo.path().to_string_lossy().to_string()) .await .unwrap(); @@ -198,18 +225,18 @@ mod tests { #[tokio::test] async fn git_adapter_builds_startchat_snapshot_without_changing_git_semantics() { - let repo = temp_repo("startchat-snapshot"); - init_git_repo(&repo); - fs::write(repo.join("tracked.txt"), "base\n").unwrap(); - git(&repo, &["add", "tracked.txt"]); - git(&repo, &["commit", "-m", "initial"]); - fs::write(repo.join("tracked.txt"), "base\nchange\n").unwrap(); - fs::write(repo.join("staged.txt"), "staged\n").unwrap(); - git(&repo, &["add", "staged.txt"]); + let repo = TestTempDir::new("startchat-snapshot"); + init_git_repo(repo.path()); + fs::write(repo.path().join("tracked.txt"), "base\n").unwrap(); + git(repo.path(), &["add", "tracked.txt"]); + git(repo.path(), &["commit", "-m", "initial"]); + fs::write(repo.path().join("tracked.txt"), "base\nchange\n").unwrap(); + fs::write(repo.path().join("staged.txt"), "staged\n").unwrap(); + git(repo.path(), &["add", "staged.txt"]); let adapter = CoreFunctionAgentGitAdapter::default(); let snapshot = adapter - .startchat_git_snapshot(repo.to_string_lossy().to_string()) + .startchat_git_snapshot(repo.path().to_string_lossy().to_string()) .await .unwrap(); @@ -225,26 +252,16 @@ mod tests { #[tokio::test] async fn git_adapter_rejects_startchat_snapshot_when_git_command_fails() { - let repo = temp_repo("not-a-git-repo"); + let repo = TestTempDir::new("not-a-git-repo"); let adapter = CoreFunctionAgentGitAdapter::default(); let result = adapter - .startchat_git_snapshot(repo.to_string_lossy().to_string()) + .startchat_git_snapshot(repo.path().to_string_lossy().to_string()) .await; assert!(result.is_err()); } - fn temp_repo(label: &str) -> std::path::PathBuf { - let path = std::env::temp_dir().join(format!( - "bitfun-function-agent-port-{}-{}", - label, - uuid::Uuid::new_v4() - )); - fs::create_dir_all(&path).unwrap(); - path - } - fn init_git_repo(repo: &std::path::Path) { git(repo, &["init", "-b", "main"]); git(repo, &["config", "user.email", "test@example.com"]); diff --git a/src/crates/core/src/miniapp/js_worker_pool.rs b/src/crates/core/src/miniapp/js_worker_pool.rs index aeac4e9388..7fa28dbd1f 100644 --- a/src/crates/core/src/miniapp/js_worker_pool.rs +++ b/src/crates/core/src/miniapp/js_worker_pool.rs @@ -378,15 +378,42 @@ mod tests { use super::*; use bitfun_product_domains::miniapp::runtime::RuntimeKind; use std::collections::HashMap; + use std::fs; + use std::path::{Path, PathBuf}; + + struct TestTempDir { + path: PathBuf, + } + + impl TestTempDir { + fn new(prefix: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "{}-{}", + prefix, + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&path).expect("test root should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } #[tokio::test] async fn runtime_port_adapter_preserves_existing_runtime_and_noop_install() { - let root = std::env::temp_dir().join(format!( - "bitfun-miniapp-runtime-port-{}", - uuid::Uuid::new_v4() - )); + let root = TestTempDir::new("bitfun-miniapp-runtime-port"); let path_manager = - Arc::new(crate::infrastructure::PathManager::with_user_root_for_tests(root)); + Arc::new(crate::infrastructure::PathManager::with_user_root_for_tests( + root.path().to_path_buf(), + )); let app_id = "demo_app"; tokio::fs::create_dir_all(path_manager.miniapp_dir(app_id)) .await diff --git a/src/crates/core/src/miniapp/storage.rs b/src/crates/core/src/miniapp/storage.rs index a61d7e8f4d..bb08c567d3 100644 --- a/src/crates/core/src/miniapp/storage.rs +++ b/src/crates/core/src/miniapp/storage.rs @@ -851,16 +851,43 @@ mod tests { use bitfun_product_domains::miniapp::customization::{ MiniAppCustomizationMetadata, MiniAppCustomizationOrigin, MiniAppCustomizationOriginKind, }; + use std::fs; + use std::path::{Path, PathBuf}; use std::sync::Arc; + struct TestTempDir { + path: PathBuf, + } + + impl TestTempDir { + fn new(prefix: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "{}-{}", + prefix, + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&path).expect("test root should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + #[tokio::test] async fn storage_port_adapter_preserves_existing_file_lifecycle() { - let root = std::env::temp_dir().join(format!( - "bitfun-miniapp-storage-port-{}", - uuid::Uuid::new_v4() - )); + let root = TestTempDir::new("bitfun-miniapp-storage-port"); let path_manager = - Arc::new(crate::infrastructure::PathManager::with_user_root_for_tests(root)); + Arc::new(crate::infrastructure::PathManager::with_user_root_for_tests( + root.path().to_path_buf(), + )); let storage = MiniAppStorage::new(path_manager); let port: &dyn MiniAppStoragePort = &storage; let app = sample_app("demo_app"); From 3d1f34325d6e30d18ea5f1a7f5888c745736bd57 Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 17 May 2026 23:52:26 +0800 Subject: [PATCH 14/76] feat(core): preserve agent mode across session history changes - persist agent_type on user dialog turns and cache the last surviving dialog mode in session state - restore and clear previous mode correctly during session reload and rollback so reminders reflect real mode transitions - share coding mode prompt/tool defaults and expose plan/debug helpers consistently across modes - add test coverage for persisted turn mode recovery and rollback-to-empty behavior --- .../agents/definitions/modes/agentic.rs | 50 +++--- .../agentic/agents/definitions/modes/debug.rs | 16 +- .../agentic/agents/definitions/modes/plan.rs | 6 +- src/crates/core/src/agentic/agents/mod.rs | 38 ++++- .../src/agentic/coordination/coordinator.rs | 20 ++- src/crates/core/src/agentic/core/session.rs | 10 ++ .../core/src/agentic/persistence/manager.rs | 9 ++ .../src/agentic/session/session_manager.rs | 143 +++++++++++++++++- .../agentic/tools/implementations/log_tool.rs | 3 +- .../core/src/service/session_usage/service.rs | 1 + src/crates/services-core/src/session/types.rs | 9 ++ 11 files changed, 264 insertions(+), 41 deletions(-) diff --git a/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs b/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs index a43b7959dc..9b69600da4 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs @@ -1,6 +1,8 @@ //! Agentic Mode -use crate::agentic::agents::Agent; +use crate::agentic::agents::{ + shared_coding_mode_tools, Agent, SHARED_CODING_MODE_PROMPT_TEMPLATE, +}; use async_trait::async_trait; pub struct AgenticMode { default_tools: Vec, @@ -15,26 +17,7 @@ impl Default for AgenticMode { impl AgenticMode { pub fn new() -> Self { Self { - default_tools: vec![ - "Task".to_string(), - "Read".to_string(), - "Write".to_string(), - "Edit".to_string(), - "Delete".to_string(), - "Bash".to_string(), - "Grep".to_string(), - "Glob".to_string(), - "WebSearch".to_string(), - "WebFetch".to_string(), - "TodoWrite".to_string(), - "GenerativeUI".to_string(), - "Skill".to_string(), - "AskUserQuestion".to_string(), - "Git".to_string(), - "TerminalControl".to_string(), - "ControlHub".to_string(), - "InitMiniApp".to_string(), - ], + default_tools: shared_coding_mode_tools(), } } } @@ -58,7 +41,7 @@ impl Agent for AgenticMode { } fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { - "agentic_mode" + SHARED_CODING_MODE_PROMPT_TEMPLATE } fn default_tools(&self) -> Vec { @@ -72,20 +55,33 @@ impl Agent for AgenticMode { #[cfg(test)] mod tests { - use super::{Agent, AgenticMode}; + use super::{shared_coding_mode_tools, Agent, AgenticMode, SHARED_CODING_MODE_PROMPT_TEMPLATE}; #[test] fn always_uses_default_prompt_template() { let agent = AgenticMode::new(); - assert_eq!(agent.prompt_template_name(Some("gpt-5.1")), "agentic_mode"); + assert_eq!( + agent.prompt_template_name(Some("gpt-5.1")), + SHARED_CODING_MODE_PROMPT_TEMPLATE + ); assert_eq!( agent.prompt_template_name(Some("GPT-5-CODEX")), - "agentic_mode" + SHARED_CODING_MODE_PROMPT_TEMPLATE ); assert_eq!( agent.prompt_template_name(Some("claude-sonnet-4")), - "agentic_mode" + SHARED_CODING_MODE_PROMPT_TEMPLATE + ); + assert_eq!( + agent.prompt_template_name(None), + SHARED_CODING_MODE_PROMPT_TEMPLATE ); - assert_eq!(agent.prompt_template_name(None), "agentic_mode"); + } + + #[test] + fn shared_coding_tools_include_plan_and_debug_helpers() { + let tools = shared_coding_mode_tools(); + assert!(tools.contains(&"CreatePlan".to_string())); + assert!(tools.contains(&"Log".to_string())); } } diff --git a/src/crates/core/src/agentic/agents/definitions/modes/debug.rs b/src/crates/core/src/agentic/agents/definitions/modes/debug.rs index aaad02b210..6716aacda4 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/debug.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/debug.rs @@ -1,6 +1,6 @@ //! Debug Mode - Evidence-driven debugging mode -use crate::agentic::agents::{Agent, PromptBuilder, PromptBuilderContext}; +use crate::agentic::agents::{get_embedded_prompt, Agent, PromptBuilder, PromptBuilderContext}; use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{DebugModeConfig, LanguageDebugTemplate}; use crate::service::lsp::project_detector::{ProjectDetector, ProjectInfo}; @@ -11,7 +11,7 @@ use std::path::Path; pub struct DebugMode; -include!(concat!(env!("OUT_DIR"), "/embedded_agents_prompt.rs")); +const DEBUG_MODE_PROMPT_TEMPLATE: &str = "debug_mode"; impl Default for DebugMode { fn default() -> Self { @@ -179,7 +179,7 @@ impl DebugMode { section } - /// Builds session-level configuration with dynamic values like server endpoint and log path. + /// Builds session-level reminder details with dynamic values like server endpoint and log path. fn build_session_level_rule(&self, config: &DebugModeConfig, workspace_path: &str) -> String { let log_path = if config.log_path.starts_with('/') || config.log_path.starts_with('.') { config.log_path.clone() @@ -279,7 +279,7 @@ impl Agent for DebugMode { } fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { - "debug_mode" + DEBUG_MODE_PROMPT_TEMPLATE } async fn build_prompt(&self, context: &PromptBuilderContext) -> BitFunResult { @@ -295,7 +295,7 @@ impl Agent for DebugMode { project_info.languages, project_info.project_types ); - let system_prompt_template = get_embedded_prompt("debug_mode") + let system_prompt_template = get_embedded_prompt(DEBUG_MODE_PROMPT_TEMPLATE) .unwrap_or("Debug mode prompt not found in embedded files"); let language_templates = @@ -320,7 +320,11 @@ impl Agent for DebugMode { Ok(prompt_list.join("")) } - async fn get_system_reminder(&self, _index: usize) -> BitFunResult { + async fn get_system_reminder( + &self, + _previous_agent_type: Option<&str>, + _workspace: Option<&crate::agentic::WorkspaceBinding>, + ) -> BitFunResult { Ok(self.build_system_reminder()) } diff --git a/src/crates/core/src/agentic/agents/definitions/modes/plan.rs b/src/crates/core/src/agentic/agents/definitions/modes/plan.rs index 7385aec88f..ad7f557540 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/plan.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/plan.rs @@ -2,6 +2,9 @@ use crate::agentic::agents::{Agent, RequestContextPolicy}; use async_trait::async_trait; + +const PLAN_MODE_PROMPT_TEMPLATE: &str = "plan_mode"; + pub struct PlanMode { default_tools: Vec, } @@ -29,6 +32,7 @@ impl PlanMode { ], } } + } #[async_trait] @@ -50,7 +54,7 @@ impl Agent for PlanMode { } fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { - "plan_mode" + PLAN_MODE_PROMPT_TEMPLATE } fn default_tools(&self) -> Vec { diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index fb39b70043..07730aeac1 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -11,6 +11,7 @@ mod registry; pub(crate) mod citation_renumber; use crate::agentic::tools::framework::ToolExposure; +use crate::agentic::WorkspaceBinding; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; pub use definitions::custom::{CustomSubagent, CustomSubagentKind}; @@ -50,6 +51,33 @@ pub type AgentToolPolicyOverrides = IndexMap; static EMPTY_AGENT_TOOL_POLICY_OVERRIDES: std::sync::LazyLock = std::sync::LazyLock::new(AgentToolPolicyOverrides::default); +pub const SHARED_CODING_MODE_PROMPT_TEMPLATE: &str = "agentic_mode"; + +pub fn shared_coding_mode_tools() -> Vec { + vec![ + "Task".to_string(), + "Read".to_string(), + "Write".to_string(), + "Edit".to_string(), + "Delete".to_string(), + "Bash".to_string(), + "Grep".to_string(), + "Glob".to_string(), + "WebSearch".to_string(), + "WebFetch".to_string(), + "TodoWrite".to_string(), + "GenerativeUI".to_string(), + "Skill".to_string(), + "AskUserQuestion".to_string(), + "Git".to_string(), + "TerminalControl".to_string(), + "ControlHub".to_string(), + "InitMiniApp".to_string(), + "CreatePlan".to_string(), + "Log".to_string(), + ] +} + /// Agent trait defining the interface for all agents #[async_trait] pub trait Agent: Send + Sync + 'static { @@ -107,9 +135,13 @@ pub trait Agent: Send + Sync + 'static { /// Get the system reminder for this agent, only used for modes /// system_reminder will be appended to the user_query - /// This is not necessary for all modes - /// index is not used for now (Cursor first time enter plan mode and keep plan mode will use different reminder) - async fn get_system_reminder(&self, _index: usize) -> BitFunResult { + /// `previous_agent_type` can be used to distinguish first entry vs staying + /// in the same mode across turns. + async fn get_system_reminder( + &self, + _previous_agent_type: Option<&str>, + _workspace: Option<&WorkspaceBinding>, + ) -> BitFunResult { if let Some(system_reminder_template_name) = self.system_reminder_template_name() { let system_reminder = get_embedded_prompt(system_reminder_template_name).ok_or_else(|| { diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index e327f68ead..e88c2d7a01 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -969,6 +969,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, turn_id: &str, turn_index: usize, + agent_type: &str, user_input: &str, workspace_path: &str, // Pre-resolved on-disk session storage path (mirror dir for remote workspaces). @@ -1063,6 +1064,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet metadata: user_message_metadata, }, ); + turn_data.agent_type = Some(agent_type.to_string()); turn_data.status = status; turn_data.end_time = Some(now_ms); turn_data.duration_ms = Some(now_ms.saturating_sub(turn_data.start_time)); @@ -1138,6 +1140,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet async fn wrap_user_input( &self, agent_type: &str, + previous_agent_type: Option<&str>, user_input: String, workspace: Option<&WorkspaceBinding>, ) -> BitFunResult { @@ -1150,7 +1153,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let current_agent = agent_registry .get_agent(agent_type, workspace.map(|binding| binding.root_path())) .ok_or_else(|| BitFunError::NotFound(format!("Agent not found: {}", agent_type)))?; - let system_reminder = current_agent.get_system_reminder(0).await?; + let system_reminder = current_agent + .get_system_reminder(previous_agent_type, workspace) + .await?; let mut wrapped_user_input = if has_prompt_markup(&user_input) { user_input @@ -1558,6 +1563,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } }; + let previous_agent_type = session.last_user_dialog_agent_type.clone(); let requested_agent_type = agent_type.trim().to_string(); let provisional_agent_type = if !requested_agent_type.is_empty() { requested_agent_type.clone() @@ -1795,6 +1801,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let wrapped_user_input = self .wrap_user_input( &effective_agent_type, + previous_agent_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), user_input, session_workspace.as_ref(), ) @@ -1819,6 +1829,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .session_manager .start_dialog_turn( &session_id, + effective_agent_type.clone(), wrapped_user_input.clone(), turn_id, image_contexts, @@ -2087,7 +2098,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } let workspace_turn_status = match execution_engine - .execute_dialog_turn(effective_agent_type_clone, messages, execution_context) + .execute_dialog_turn( + effective_agent_type_clone.clone(), + messages, + execution_context, + ) .await { Ok(execution_result) => { @@ -2310,6 +2325,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &session_id_clone, &turn_id_clone, turn_index, + &effective_agent_type_clone, &user_input_for_workspace, wp, session_storage_path_for_finalize.as_deref(), diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/core/src/agentic/core/session.rs index f5155cb778..2539c11757 100644 --- a/src/crates/core/src/agentic/core/session.rs +++ b/src/crates/core/src/agentic/core/session.rs @@ -11,7 +11,15 @@ use uuid::Uuid; pub struct Session { pub session_id: String, pub session_name: String, + /// Current/default mode selection for the session. + /// This reflects what the next turn should run with by default, not + /// necessarily what the last surviving history turn used. pub agent_type: String, + /// Cached mode of the last surviving user dialog turn in history. + /// `previous_agent_type` reminders should read this instead of `agent_type` + /// so rollback-to-empty can still be treated as a fresh mode entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_user_dialog_agent_type: Option, #[serde( default, skip_serializing_if = "Option::is_none", @@ -71,6 +79,7 @@ impl Session { session_id: Uuid::new_v4().to_string(), session_name, agent_type, + last_user_dialog_agent_type: None, created_by: None, kind: SessionKind::Standard, snapshot_session_id: None, @@ -95,6 +104,7 @@ impl Session { session_id, session_name, agent_type, + last_user_dialog_agent_type: None, created_by: None, kind: SessionKind::Standard, snapshot_session_id: None, diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index d671655919..1b0fd49ddf 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -50,6 +50,10 @@ struct StoredSessionStateFile { schema_version: u32, config: SessionConfig, snapshot_session_id: Option, + // Derived runtime cache for reminder semantics. The source of truth lives + // on persisted dialog turns via `DialogTurnData.agent_type`. + #[serde(default, skip_serializing_if = "Option::is_none")] + last_user_dialog_agent_type: Option, compression_state: CompressionState, runtime_state: SessionState, } @@ -1775,6 +1779,7 @@ impl PersistenceManager { schema_version: SESSION_STORAGE_SCHEMA_VERSION, config: session.config.clone(), snapshot_session_id: session.snapshot_session_id.clone(), + last_user_dialog_agent_type: session.last_user_dialog_agent_type.clone(), compression_state: session.compression_state.clone(), runtime_state: Self::sanitize_runtime_state(&session.state), }; @@ -1844,6 +1849,9 @@ impl PersistenceManager { session_id: metadata.session_id.clone(), session_name: metadata.session_name.clone(), agent_type: metadata.agent_type.clone(), + last_user_dialog_agent_type: stored_state + .as_ref() + .and_then(|value| value.last_user_dialog_agent_type.clone()), created_by: metadata.created_by.clone(), kind: metadata.session_kind, snapshot_session_id: stored_state @@ -1879,6 +1887,7 @@ impl PersistenceManager { ..Default::default() }, snapshot_session_id: None, + last_user_dialog_agent_type: None, compression_state: CompressionState::default(), runtime_state: SessionState::Idle, }); diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 3b4b374746..464268ba61 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -1126,6 +1126,39 @@ impl SessionManager { Ok(()) } + fn derive_last_user_dialog_agent_type_from_turns( + turns: &[DialogTurnData], + fallback_agent_type: Option<&str>, + ) -> Option { + // New turns persist their mode on the turn itself. For older persisted + // sessions that predate this field, fall back to the session default + // only when at least one surviving user dialog turn exists. + turns + .iter() + .rev() + .find(|turn| turn.kind == DialogTurnKind::UserDialog) + .and_then(|turn| { + turn.agent_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .or_else(|| { + if turns + .iter() + .any(|turn| turn.kind == DialogTurnKind::UserDialog) + { + fallback_agent_type + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + } else { + None + } + }) + } + /// Update session model id (in-memory + persistence) pub async fn update_session_model_id( &self, @@ -1559,6 +1592,10 @@ impl SessionManager { .iter() .map(|turn| turn.turn_id.clone()) .collect(); + session.last_user_dialog_agent_type = Self::derive_last_user_dialog_agent_type_from_turns( + &persisted_turns, + Some(session.agent_type.as_str()), + ); let mut latest_turn_index: Option = None; let context_snapshot_started_at = Instant::now(); let mut messages = match self @@ -1715,12 +1752,34 @@ impl SessionManager { // 2) Restore the in-memory context cache. self.context_store.replace_context(session_id, messages); + let last_user_dialog_agent_type = if target_turn == 0 { + None + } else { + let surviving_turns = self + .persistence_manager + .load_session_turns(workspace_path, session_id) + .await?; + let kept_turns = surviving_turns + .into_iter() + .take(target_turn) + .collect::>(); + let fallback_agent_type = self + .sessions + .get(session_id) + .map(|session| session.agent_type.clone()); + Self::derive_last_user_dialog_agent_type_from_turns( + &kept_turns, + fallback_agent_type.as_deref(), + ) + }; + // 3) Truncate session turn list & persist // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. let session_snapshot = if let Some(mut session) = self.sessions.get_mut(session_id) { if session.dialog_turn_ids.len() > target_turn { session.dialog_turn_ids.truncate(target_turn); } + session.last_user_dialog_agent_type = last_user_dialog_agent_type; session.state = SessionState::Idle; session.updated_at = SystemTime::now(); session.last_activity_at = SystemTime::now(); @@ -1813,6 +1872,7 @@ impl SessionManager { &self, session_id: &str, kind: DialogTurnKind, + agent_type: Option, user_input: String, turn_id: Option, context_message: Option, @@ -1836,6 +1896,9 @@ impl SessionManager { if let Some(mut session) = self.sessions.get_mut(session_id) { session.dialog_turn_ids.push(turn_id.clone()); + if kind == DialogTurnKind::UserDialog { + session.last_user_dialog_agent_type = agent_type.clone(); + } session.state = SessionState::Processing { current_turn_id: turn_id.clone(), phase: processing_phase, @@ -1855,6 +1918,11 @@ impl SessionManager { turn_id.clone(), turn_index, session_id.to_string(), + if kind == DialogTurnKind::UserDialog { + agent_type.clone() + } else { + None + }, UserMessageData { id: format!("{}-user", turn_id), content: user_input, @@ -1891,6 +1959,7 @@ impl SessionManager { pub async fn start_dialog_turn( &self, session_id: &str, + agent_type: String, user_input: String, turn_id: Option, image_contexts: Option>, @@ -1909,6 +1978,7 @@ impl SessionManager { .start_persisted_turn( session_id, DialogTurnKind::UserDialog, + Some(agent_type), user_input, turn_id, Some(user_message), @@ -1934,6 +2004,7 @@ impl SessionManager { .start_persisted_turn( session_id, DialogTurnKind::ManualCompaction, + None, display_message, turn_id, None, @@ -3239,7 +3310,7 @@ mod tests { .expect("session should create"); for index in 0..3 { - let turn = DialogTurnData::new( + let mut turn = DialogTurnData::new( format!("turn-{index}"), index, session.session_id.clone(), @@ -3250,6 +3321,11 @@ mod tests { metadata: None, }, ); + turn.agent_type = Some(if index == 0 { + "agentic".to_string() + } else { + "Plan".to_string() + }); persistence_manager .save_dialog_turn(workspace.path(), &turn) .await @@ -3266,6 +3342,7 @@ mod tests { "turn-1".to_string(), "turn-2".to_string(), ]; + active.last_user_dialog_agent_type = Some("Plan".to_string()); } persistence_manager .save_turn_context_snapshot( @@ -3300,6 +3377,7 @@ mod tests { .expect("turns should load"); assert_eq!(turns.len(), 1); assert_eq!(turns[0].user_message.content, "prompt 0"); + assert_eq!(turns[0].agent_type.as_deref(), Some("agentic")); assert!(persistence_manager .load_turn_context_snapshot(workspace.path(), &session.session_id, 1) .await @@ -3312,6 +3390,10 @@ mod tests { .await .expect("session should restore"); assert_eq!(restored.dialog_turn_ids, vec!["turn-0".to_string()]); + assert_eq!( + restored.last_user_dialog_agent_type.as_deref(), + Some("agentic") + ); assert_eq!( manager .context_store @@ -3328,6 +3410,63 @@ mod tests { assert_eq!(metadata.turn_count, 1); } + #[tokio::test] + async fn rollback_to_empty_history_clears_last_user_dialog_agent_type() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Rollback empty history".to_string(), + "Plan".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + let mut turn = DialogTurnData::new( + "turn-0".to_string(), + 0, + session.session_id.clone(), + UserMessageData { + id: "turn-0-user".to_string(), + content: "plan prompt".to_string(), + timestamp: 0, + metadata: None, + }, + ); + turn.agent_type = Some("Plan".to_string()); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + + { + let mut active = manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active"); + active.dialog_turn_ids = vec!["turn-0".to_string()]; + active.last_user_dialog_agent_type = Some("Plan".to_string()); + } + + manager + .rollback_context_to_turn_start(workspace.path(), &session.session_id, 0) + .await + .expect("rollback should succeed"); + + let active = manager + .get_session(&session.session_id) + .expect("session should remain in memory"); + assert_eq!(active.agent_type, "Plan"); + assert_eq!(active.last_user_dialog_agent_type, None); + } + #[tokio::test] async fn delete_session_removes_workspace_cache_entry() { let workspace = TestWorkspace::new(); @@ -3384,6 +3523,7 @@ mod tests { "turn-2".to_string(), 1, "session-1".to_string(), + None, UserMessageData { id: "user-2".to_string(), content: "/compact".to_string(), @@ -3396,6 +3536,7 @@ mod tests { "turn-3".to_string(), 2, "session-1".to_string(), + None, UserMessageData { id: "user-3".to_string(), content: "# Session Usage Report".to_string(), diff --git a/src/crates/core/src/agentic/tools/implementations/log_tool.rs b/src/crates/core/src/agentic/tools/implementations/log_tool.rs index 2d4f32da80..9fe86f1a68 100644 --- a/src/crates/core/src/agentic/tools/implementations/log_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/log_tool.rs @@ -188,7 +188,8 @@ The tool will return the log content or analysis results that you can use to dia } fn short_description(&self) -> String { - "Read and analyze log files for debugging and monitoring.".to_string() + "Read and analyze log files for debugging and monitoring; mainly used in Debug mode." + .to_string() } fn default_exposure(&self) -> ToolExposure { diff --git a/src/crates/core/src/service/session_usage/service.rs b/src/crates/core/src/service/session_usage/service.rs index 705025bd4a..850e71c6fa 100644 --- a/src/crates/core/src/service/session_usage/service.rs +++ b/src/crates/core/src/service/session_usage/service.rs @@ -1748,6 +1748,7 @@ mod tests { session_id: "session-1".to_string(), timestamp: 1_000 + turn_index as u64, kind, + agent_type: None, user_message: UserMessageData { id: format!("user-{}", turn_index), content: "hidden from report".to_string(), diff --git a/src/crates/services-core/src/session/types.rs b/src/crates/services-core/src/session/types.rs index 6cd8f5cab4..96497f7f49 100644 --- a/src/crates/services-core/src/session/types.rs +++ b/src/crates/services-core/src/session/types.rs @@ -225,6 +225,12 @@ pub struct DialogTurnData { #[serde(default, alias = "turn_kind")] pub kind: DialogTurnKind, + /// Agent type used for this turn when it represents a user dialog. + /// Maintenance/local utility turns leave this empty so they do not affect + /// mode-transition reminder semantics. + #[serde(default, skip_serializing_if = "Option::is_none", alias = "agent_type")] + pub agent_type: Option, + /// User message #[serde(alias = "user_message")] pub user_message: UserMessageData, @@ -706,6 +712,7 @@ impl DialogTurnData { turn_id, turn_index, session_id, + None, user_message, ) } @@ -716,6 +723,7 @@ impl DialogTurnData { turn_id: String, turn_index: usize, session_id: String, + agent_type: Option, user_message: UserMessageData, ) -> Self { let now = std::time::SystemTime::now() @@ -729,6 +737,7 @@ impl DialogTurnData { session_id, timestamp: now, kind, + agent_type, user_message, model_rounds: Vec::new(), start_time: now, From e4d8618047aeab26728a3aed55abf0f504b9c18f Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 18 May 2026 00:32:28 +0800 Subject: [PATCH 15/76] feat(core): add background subagent task delivery - add `run_in_background` support to Task tool and return a background task handle - generalize round steering into round injection for current-turn and exact-turn delivery - deliver background subagent results via steering when parent is running - start a new parent turn automatically when background results arrive while idle - wire scheduler/coordinator/execution engine to the new background delivery flow --- src/apps/desktop/src/lib.rs | 2 +- src/apps/server/src/bootstrap.rs | 2 +- .../src/agentic/coordination/coordinator.rs | 133 +++++++++++++-- .../src/agentic/coordination/scheduler.rs | 83 ++++++++-- .../src/agentic/execution/execution_engine.rs | 43 ++--- .../core/src/agentic/execution/types.rs | 10 +- src/crates/core/src/agentic/mod.rs | 7 +- src/crates/core/src/agentic/round_preempt.rs | 151 ++++++++++++------ .../tools/implementations/task_tool.rs | 38 +++++ .../core/src/agentic/tools/pipeline/types.rs | 6 +- 10 files changed, 368 insertions(+), 107 deletions(-) diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 0eb2ca632b..f8ebc3c489 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1177,7 +1177,7 @@ async fn init_agentic_system() -> anyhow::Result<( coordination::DialogScheduler::new(coordinator.clone(), session_manager.clone()); coordinator.set_scheduler_notifier(scheduler.outcome_sender()); coordinator.set_round_preempt_source(scheduler.preempt_monitor()); - coordinator.set_round_steering_source(scheduler.steering_monitor()); + coordinator.set_round_injection_source(scheduler.round_injection_monitor()); coordination::set_global_scheduler(scheduler.clone()); let cron_service = diff --git a/src/apps/server/src/bootstrap.rs b/src/apps/server/src/bootstrap.rs index d7923a606d..f4a912cfcb 100644 --- a/src/apps/server/src/bootstrap.rs +++ b/src/apps/server/src/bootstrap.rs @@ -114,7 +114,7 @@ pub async fn initialize(workspace: Option) -> anyhow::Result>, /// Round-boundary user steering source (mid-turn user message injection); injected after construction - round_steering_source: OnceLock>, + round_injection_source: OnceLock>, /// In-flight dialog turn tracker per session, used to serialize cancel→start /// transitions so a new turn never starts touching the in-memory message /// list while the previous (cancelled) turn's spawn task is still draining. @@ -754,7 +759,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet subagent_timeout_registry: Arc::new(RwLock::new(HashMap::new())), scheduler_notify_tx: OnceLock::new(), round_preempt_source: OnceLock::new(), - round_steering_source: OnceLock::new(), + round_injection_source: OnceLock::new(), active_turns_per_session: Arc::new(DashMap::new()), } } @@ -770,10 +775,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let _ = self.round_preempt_source.set(source); } - /// Wire round-boundary user-steering source (typically the scheduler's - /// [`SessionSteeringBuffer`](crate::agentic::round_preempt::SessionSteeringBuffer)). - pub fn set_round_steering_source(&self, source: Arc) { - let _ = self.round_steering_source.set(source); + /// Wire round-boundary injection source (typically the scheduler's + /// [`SessionRoundInjectionBuffer`](crate::agentic::round_preempt::SessionRoundInjectionBuffer)). + pub fn set_round_injection_source(&self, source: Arc) { + let _ = self.round_injection_source.set(source); } /// Dynamically adjust a running subagent's timeout. @@ -1963,7 +1968,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet runtime_tool_restrictions: ToolRuntimeRestrictions::default(), workspace_services, round_preempt: self.round_preempt_source.get().cloned(), - round_steering: self.round_steering_source.get().cloned(), + round_injection: self.round_injection_source.get().cloned(), recover_partial_on_cancel: false, }; @@ -3076,7 +3081,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // Subagents are autonomous; user steering is targeted at top-level // dialog turns only. Leave None so we don't intercept buffer entries // that belong to a different (parent) session/turn. - round_steering: None, + round_injection: None, recover_partial_on_cancel: true, }; @@ -3688,6 +3693,116 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub async fn start_background_subagent( + &self, + agent_type: String, + task_description: String, + subagent_parent_info: SubagentParentInfo, + workspace_path: Option, + context: Option>, + model_id: Option, + timeout_seconds: Option, + ) -> BitFunResult { + let workspace_path = workspace_path.ok_or_else(|| { + BitFunError::Validation( + "workspace_path is required when creating a background subagent session" + .to_string(), + ) + })?; + let model_id = model_id + .map(|model_id| model_id.trim().to_string()) + .filter(|model_id| !model_id.is_empty()); + let parent_session = self + .session_manager + .get_session(&subagent_parent_info.session_id) + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Parent session not found: {}", + subagent_parent_info.session_id + )) + })?; + let parent_agent_type = parent_session.agent_type.clone(); + let parent_workspace_path = parent_session.config.workspace_path.clone(); + let background_task_id = format!("bg-subagent-{}", uuid::Uuid::new_v4()); + let background_task_id_for_delivery = background_task_id.clone(); + let request = HiddenSubagentExecutionRequest { + session_name: format!("Subagent: {}", task_description), + agent_type: agent_type.clone(), + session_config: Self::build_session_config_for_workspace(workspace_path, model_id) + .await, + initial_messages: vec![Message::user(task_description.clone())], + created_by: Some(format!("session-{}", subagent_parent_info.session_id)), + subagent_parent_info: Some(subagent_parent_info.clone()), + context: context.unwrap_or_default(), + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + }; + let coordinator = get_global_coordinator().ok_or_else(|| { + BitFunError::service("Coordinator not initialized".to_string()) + })?; + + tokio::spawn(async move { + let delivery_text = match coordinator + .execute_hidden_subagent_internal(request, None, timeout_seconds) + .await + { + Ok(result) => { + if result.is_partial_timeout() { + format!( + "Background subagent '{}' timed out with partial result:\n\n{}\n", + agent_type, result.text + ) + } else { + format!( + "Background subagent '{}' completed successfully:\n\n{}\n", + agent_type, result.text + ) + } + } + Err(error) => { + format!( + "Background subagent '{}' failed before producing a final result.\nError: {}", + agent_type, error + ) + } + }; + + let metadata = serde_json::json!({ + "kind": "background_subagent_result", + "backgroundTaskId": background_task_id_for_delivery, + "subagentType": agent_type, + }); + + if let Some(scheduler) = super::scheduler::get_global_scheduler() { + if let Err(error) = scheduler + .deliver_background_subagent_result( + subagent_parent_info.session_id.clone(), + parent_agent_type, + parent_workspace_path, + delivery_text, + Some(task_description), + Some(metadata), + ) + .await + { + warn!( + "Failed to deliver background subagent result: background_task_id={}, parent_session_id={}, error={}", + background_task_id_for_delivery, + subagent_parent_info.session_id, + error + ); + } + } else { + warn!( + "Scheduler not initialized; background subagent result dropped: background_task_id={}, parent_session_id={}", + background_task_id_for_delivery, + subagent_parent_info.session_id + ); + } + }); + + Ok(BackgroundSubagentStartResult { background_task_id }) + } + /// Clean up subagent session resources /// /// Release resources occupied by subagent session (sandbox, etc.) and delete session diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index a94ec44768..724d19d736 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -15,8 +15,9 @@ use super::turn_outcome::{TurnOutcome, TurnOutcomeQueueAction, TurnOutcomeStatus use crate::agentic::core::{PromptEnvelope, SessionState}; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::round_preempt::{ - DialogRoundPreemptSource, DialogRoundSteeringSource, SessionRoundYieldFlags, - SessionSteeringBuffer, SteeringMessage, + DialogRoundInjectionSource, DialogRoundPreemptSource, RoundInjection, + RoundInjectionKind, RoundInjectionTarget, SessionRoundInjectionBuffer, + SessionRoundYieldFlags, }; use crate::agentic::session::SessionManager; use dashmap::DashMap; @@ -163,9 +164,9 @@ pub struct DialogScheduler { outcome_tx: mpsc::Sender<(String, TurnOutcome)>, /// When a user submits while `Processing`, engine yields after the current model round. round_yield_flags: Arc, - /// Per-session FIFO buffer of user "steering" messages drained at round boundaries + /// Per-session FIFO buffer of round injections drained at round boundaries /// by the engine and injected into the running dialog turn. - steering_buffer: Arc, + round_injection_buffer: Arc, } /// Outcome of [`DialogScheduler::submit_steering`]. @@ -200,7 +201,7 @@ impl DialogScheduler { suppressed_cancelled_replies: Arc::new(DashMap::new()), outcome_tx, round_yield_flags: Arc::new(SessionRoundYieldFlags::default()), - steering_buffer: Arc::new(SessionSteeringBuffer::default()), + round_injection_buffer: Arc::new(SessionRoundInjectionBuffer::default()), }); let scheduler_for_handler = Arc::clone(&scheduler); @@ -221,9 +222,9 @@ impl DialogScheduler { self.round_yield_flags.clone() } - /// Pass to [`ConversationCoordinator::set_round_steering_source`](super::coordinator::ConversationCoordinator::set_round_steering_source). - pub fn steering_monitor(&self) -> Arc { - self.steering_buffer.clone() + /// Pass to [`ConversationCoordinator::set_round_injection_source`](super::coordinator::ConversationCoordinator::set_round_injection_source). + pub fn round_injection_monitor(&self) -> Arc { + self.round_injection_buffer.clone() } /// Submit a user "steering" message into the currently running dialog turn. @@ -265,21 +266,22 @@ impl DialogScheduler { let steering_id = Uuid::new_v4().to_string(); let display = display_content.unwrap_or_else(|| content.clone()); - let message = SteeringMessage { + let message = RoundInjection { id: steering_id.clone(), - turn_id: turn_id.clone(), + kind: RoundInjectionKind::UserSteering, + target: RoundInjectionTarget::ExactTurn(turn_id.clone()), content, display_content: display, created_at: SystemTime::now(), }; - self.steering_buffer.push(&session_id, message); + self.round_injection_buffer.push(&session_id, message); info!( "Steering message buffered: session_id={}, turn_id={}, steering_id={}, pending={}", session_id, turn_id, steering_id, - self.steering_buffer.pending_count(&session_id) + self.round_injection_buffer.pending_count(&session_id) ); Ok(DialogSteerOutcome::Buffered { @@ -289,6 +291,61 @@ impl DialogScheduler { }) } + /// Deliver a completed background subagent result back to the parent + /// session. If the session is currently processing, inject the result into + /// the running turn at the next model-round boundary. Otherwise, start a + /// new turn immediately so the result is handled without waiting for an + /// unrelated future message. + pub async fn deliver_background_subagent_result( + &self, + session_id: String, + agent_type: String, + workspace_path: Option, + content: String, + display_content: Option, + user_message_metadata: Option, + ) -> Result<(), String> { + let display = display_content.unwrap_or_else(|| content.clone()); + let state = self + .session_manager + .get_session(&session_id) + .map(|s| s.state.clone()); + + match state { + Some(SessionState::Processing { .. }) => { + let injection_id = Uuid::new_v4().to_string(); + self.round_injection_buffer.push( + &session_id, + RoundInjection { + id: injection_id, + kind: RoundInjectionKind::BackgroundSubagentResult, + target: RoundInjectionTarget::CurrentRunningTurn, + content, + display_content: display, + created_at: SystemTime::now(), + }, + ); + Ok(()) + } + _ => { + self.submit( + session_id, + content, + Some(display), + None, + agent_type, + workspace_path, + DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + None, + user_message_metadata, + None, + ) + .await + .map(|_| ()) + } + } + } + fn user_message_may_preempt(policy: &DialogSubmissionPolicy) -> bool { matches!( policy.trigger_source, @@ -715,7 +772,7 @@ Status: {status}" // outcome is processed (race window between turn finalize and the // next turn starting). Targeting by turn_id keeps those alive. let _drained = self - .steering_buffer + .round_injection_buffer .drain_for_turn(&session_id, outcome.turn_id()); let suppressed_cancelled_reply = self.take_suppressed_cancelled_reply(&session_id, outcome.turn_id()); diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index e43b0b8701..25bd8d5135 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -18,6 +18,7 @@ use crate::agentic::image_analysis::{ build_multimodal_message_with_images, process_image_contexts_for_provider, ImageContextData, ImageLimits, }; +use crate::agentic::round_preempt::RoundInjectionKind; use crate::agentic::session::{CompressionTailPolicy, ContextCompressor, SessionManager}; use crate::agentic::tools::{ resolve_tool_manifest, ResolvedToolManifest, SubagentParentInfo, ToolRuntimeRestrictions, @@ -1857,8 +1858,8 @@ impl ExecutionEngine { agent_type: agent_type.clone(), context_vars: round_context_vars, runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), - steering_interrupt: context.round_steering.as_ref().map(|source| { - crate::agentic::round_preempt::DialogRoundSteeringInterrupt::new( + steering_interrupt: context.round_injection.as_ref().map(|source| { + crate::agentic::round_preempt::DialogRoundInjectionInterrupt::new( context.session_id.clone(), context.dialog_turn_id.clone(), Arc::clone(source), @@ -2096,26 +2097,28 @@ impl ExecutionEngine { // contrast with the `round_preempt` path below which finalizes the turn so a // queued *new turn* can take over. If the model wanted to finish but the user // steered, we keep the turn running so the steering message gets a response. - let mut steering_injected = false; - if let Some(steer) = context.round_steering.as_ref() { - let pending = steer.take_pending(&context.session_id, &context.dialog_turn_id); + let mut injection_applied = false; + if let Some(source) = context.round_injection.as_ref() { + let pending = source.take_pending(&context.session_id, &context.dialog_turn_id); if !pending.is_empty() { info!( - "Injecting {} user steering message(s) at round boundary: session_id={}, dialog_turn_id={}, round_index={}", + "Injecting {} round message(s) at round boundary: session_id={}, dialog_turn_id={}, round_index={}", pending.len(), context.session_id, context.dialog_turn_id, round_index ); - for steering_msg in pending { - // Wrap the steering content in a system_reminder envelope so the - // model treats it as an out-of-band course correction layered on - // top of the running task, not as a brand-new top-level instruction - // that supersedes everything before it. Matches Codex CLI semantics. - let wrapped = format!( - "\nThe user sent a new message while this turn was running. You have just finished the previous atomic action; handle this new user message now as the current direction, while preserving the existing conversation and task context. Do not ignore it or wait for a separate future turn.\n\nNew user message:\n{}\n", - steering_msg.content - ); + for injection in pending { + let wrapped = match injection.kind { + RoundInjectionKind::UserSteering => format!( + "\nThe user sent a new message while this turn was running. You have just finished the previous atomic action; handle this new user message now as the current direction, while preserving the existing conversation and task context. Do not ignore it or wait for a separate future turn.\n\nNew user message:\n{}\n", + injection.content + ), + RoundInjectionKind::BackgroundSubagentResult => format!( + "\nA background subagent has finished and returned new information while this turn was running. Incorporate it into your current work immediately when relevant. Do not wait for a separate future turn.\n\nBackground subagent result:\n{}\n", + injection.content + ), + }; let user_msg = Message::user(wrapped); messages.push(user_msg.clone()); if let Err(e) = self @@ -2131,15 +2134,15 @@ impl ExecutionEngine { session_id: context.session_id.clone(), turn_id: context.dialog_turn_id.clone(), round_index, - steering_id: steering_msg.id, - content: steering_msg.content, - display_content: steering_msg.display_content, + steering_id: injection.id, + content: injection.content, + display_content: injection.display_content, subagent_parent_info: event_subagent_parent_info.clone(), }, EventPriority::Normal, ) .await; - steering_injected = true; + injection_applied = true; } } } @@ -2158,7 +2161,7 @@ impl ExecutionEngine { // (write the answer), and continue. // - Model emitted nothing at all -> partial recovery / truncation. // Retrying without new context will not help, so end the turn. - if steering_injected { + if injection_applied { // fall through to next round so the model can respond to the steering } else if !round_result.has_more_rounds { if round_result.had_assistant_text { diff --git a/src/crates/core/src/agentic/execution/types.rs b/src/crates/core/src/agentic/execution/types.rs index 89176a93d1..60b3a89a1c 100644 --- a/src/crates/core/src/agentic/execution/types.rs +++ b/src/crates/core/src/agentic/execution/types.rs @@ -2,7 +2,7 @@ use crate::agentic::core::Message; use crate::agentic::round_preempt::{ - DialogRoundPreemptSource, DialogRoundSteeringInterrupt, DialogRoundSteeringSource, + DialogRoundInjectionInterrupt, DialogRoundInjectionSource, DialogRoundPreemptSource, }; use crate::agentic::tools::pipeline::SubagentParentInfo; use crate::agentic::tools::ToolRuntimeRestrictions; @@ -29,9 +29,9 @@ pub struct ExecutionContext { pub workspace_services: Option, /// When set, engine may end the turn after a full model round if a user message was queued. pub round_preempt: Option>, - /// When set, engine drains pending user steering messages at each round boundary + /// When set, engine drains pending round injections at each round boundary /// and injects them into the dialog history without ending the turn. - pub round_steering: Option>, + pub round_injection: Option>, /// When true, stream cancellation may be converted into a partial assistant /// result if text/tool output has already been produced. pub recover_partial_on_cancel: bool, @@ -54,9 +54,9 @@ pub struct RoundContext { pub agent_type: String, pub context_vars: HashMap, pub runtime_tool_restrictions: ToolRuntimeRestrictions, - /// Cooperative interrupt checked by tool execution so user steering can be + /// Cooperative interrupt checked by tool execution so round injections can be /// applied after the currently running atomic tool/batch finishes. - pub steering_interrupt: Option, + pub steering_interrupt: Option, pub cancellation_token: CancellationToken, pub workspace_services: Option, pub recover_partial_on_cancel: bool, diff --git a/src/crates/core/src/agentic/mod.rs b/src/crates/core/src/agentic/mod.rs index df12e26309..698c163f50 100644 --- a/src/crates/core/src/agentic/mod.rs +++ b/src/crates/core/src/agentic/mod.rs @@ -56,9 +56,10 @@ pub use fork_agent::*; pub use image_analysis::{ImageAnalyzer, MessageEnhancer}; pub use persistence::PersistenceManager; pub use round_preempt::{ - DialogRoundPreemptSource, DialogRoundSteeringInterrupt, DialogRoundSteeringSource, - NoopDialogRoundPreemptSource, NoopDialogRoundSteeringSource, SessionRoundYieldFlags, - SessionSteeringBuffer, SteeringMessage, + DialogRoundInjectionInterrupt, DialogRoundInjectionSource, DialogRoundPreemptSource, + NoopDialogRoundInjectionSource, NoopDialogRoundPreemptSource, RoundInjection, + RoundInjectionKind, RoundInjectionTarget, SessionRoundInjectionBuffer, + SessionRoundYieldFlags, }; pub use session::*; pub use side_question::*; diff --git a/src/crates/core/src/agentic/round_preempt.rs b/src/crates/core/src/agentic/round_preempt.rs index b5f59550e4..936a5d9a6e 100644 --- a/src/crates/core/src/agentic/round_preempt.rs +++ b/src/crates/core/src/agentic/round_preempt.rs @@ -3,8 +3,8 @@ //! The [`DialogRoundPreemptSource`] is implemented by [`DialogScheduler`](super::scheduler::DialogScheduler) //! and read by [`ExecutionEngine`](super::execution::ExecutionEngine) after each completed model round. //! -//! In addition, the [`DialogRoundSteeringSource`] trait is read by the engine at the same -//! round boundary to retrieve any pending user "steering" messages that should be injected +//! In addition, the [`DialogRoundInjectionSource`] trait is read by the engine at the same +//! round boundary to retrieve any pending round injections that should be injected //! into the current dialog turn (Codex-style mid-turn injection) without ending the turn. use std::sync::atomic::{AtomicBool, Ordering}; @@ -65,17 +65,32 @@ impl DialogRoundPreemptSource for SessionRoundYieldFlags { } } -// ── Mid-turn user "steering" injection ───────────────────────────────────── +// ── Round-boundary injection ──────────────────────────────────────────────── -/// A user-authored message to inject into the currently running dialog turn at the -/// next model-round boundary. Produced by `submit_steering` on the scheduler/coordinator -/// and consumed by [`ExecutionEngine`](super::execution::ExecutionEngine) before each new round. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RoundInjectionKind { + UserSteering, + BackgroundSubagentResult, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RoundInjectionTarget { + /// Only inject into the exact targeted running turn. If that turn already + /// finished, the injection is ignored. + ExactTurn(String), + /// Inject into whichever turn is currently running for the session. + CurrentRunningTurn, +} + +/// A message to inject into the currently running dialog turn at the next +/// model-round boundary. Produced by the scheduler/coordinator and consumed by +/// [`ExecutionEngine`](super::execution::ExecutionEngine) before each new round. #[derive(Debug, Clone)] -pub struct SteeringMessage { +pub struct RoundInjection { pub id: String, - /// The dialog turn this steering targets. Steering messages whose `turn_id` does not - /// match the running turn are ignored (e.g. user steered a turn that already finished). - pub turn_id: String, + pub kind: RoundInjectionKind, + /// Injection target routing policy. + pub target: RoundInjectionTarget, pub content: String, /// Original (pre-rendering) text from the user, for UI display when the rendered /// `content` differs (e.g. when wrapped with a system reminder envelope). @@ -83,53 +98,53 @@ pub struct SteeringMessage { pub created_at: SystemTime, } -/// Observes whether any user steering messages are pending for a given (session, turn). -pub trait DialogRoundSteeringSource: Send + Sync { - /// Check whether the given running turn has pending steering without +/// Observes whether any round injections are pending for a given (session, turn). +pub trait DialogRoundInjectionSource: Send + Sync { + /// Check whether the given running turn has pending injections without /// consuming it. This lets tool execution stop at a safe boundary while the /// execution engine remains responsible for draining and injecting the /// messages into the next model round. fn has_pending(&self, session_id: &str, turn_id: &str) -> bool; - /// Drain all pending steering messages targeted at the given dialog turn. + /// Drain all pending injections targeted at the given dialog turn. /// Implementations must be safe to call concurrently from multiple round boundaries. - fn take_pending(&self, session_id: &str, turn_id: &str) -> Vec; + fn take_pending(&self, session_id: &str, turn_id: &str) -> Vec; } /// Used when no scheduler is wired (e.g. tests, isolated execution). -pub struct NoopDialogRoundSteeringSource; +pub struct NoopDialogRoundInjectionSource; -impl DialogRoundSteeringSource for NoopDialogRoundSteeringSource { +impl DialogRoundInjectionSource for NoopDialogRoundInjectionSource { fn has_pending(&self, _session_id: &str, _turn_id: &str) -> bool { false } - fn take_pending(&self, _session_id: &str, _turn_id: &str) -> Vec { + fn take_pending(&self, _session_id: &str, _turn_id: &str) -> Vec { Vec::new() } } #[derive(Clone)] -pub struct DialogRoundSteeringInterrupt { +pub struct DialogRoundInjectionInterrupt { session_id: String, turn_id: String, - source: Arc, + source: Arc, } -impl std::fmt::Debug for DialogRoundSteeringInterrupt { +impl std::fmt::Debug for DialogRoundInjectionInterrupt { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DialogRoundSteeringInterrupt") + f.debug_struct("DialogRoundInjectionInterrupt") .field("session_id", &self.session_id) .field("turn_id", &self.turn_id) .finish_non_exhaustive() } } -impl DialogRoundSteeringInterrupt { +impl DialogRoundInjectionInterrupt { pub fn new( session_id: String, turn_id: String, - source: Arc, + source: Arc, ) -> Self { Self { session_id, @@ -143,35 +158,37 @@ impl DialogRoundSteeringInterrupt { } } -/// Per-session FIFO buffer of user steering messages keyed by `session_id`. -/// Messages are appended via [`SessionSteeringBuffer::push`] and drained at round boundaries. +/// Per-session FIFO buffer of round injections keyed by `session_id`. +/// Messages are appended via [`SessionRoundInjectionBuffer::push`] and drained at round boundaries. #[derive(Debug, Default)] -pub struct SessionSteeringBuffer { - inner: dashmap::DashMap>, +pub struct SessionRoundInjectionBuffer { + inner: dashmap::DashMap>, } -impl SessionSteeringBuffer { - pub fn push(&self, session_id: &str, message: SteeringMessage) { +impl SessionRoundInjectionBuffer { + pub fn push(&self, session_id: &str, message: RoundInjection) { self.inner .entry(session_id.to_string()) .or_default() .push(message); } - /// Drain all messages whose `turn_id` matches `turn_id`. Messages targeting a different - /// turn are dropped (the targeted turn is no longer running), matching Codex semantics - /// where steering is bound to a specific in-flight turn. - pub fn drain_for_turn(&self, session_id: &str, turn_id: &str) -> Vec { + /// Drain all messages eligible for the currently running turn. Exact-turn + /// injections that target a different turn are retained until the targeted + /// turn consumes them or the session is cleared. + pub fn drain_for_turn(&self, session_id: &str, turn_id: &str) -> Vec { let Some(mut entry) = self.inner.get_mut(session_id) else { return Vec::new(); }; let mut taken = Vec::new(); let mut keep = Vec::new(); for msg in entry.drain(..) { - if msg.turn_id == turn_id { - taken.push(msg); - } else { - keep.push(msg); + match &msg.target { + RoundInjectionTarget::ExactTurn(target_turn_id) if target_turn_id == turn_id => { + taken.push(msg); + } + RoundInjectionTarget::CurrentRunningTurn => taken.push(msg), + RoundInjectionTarget::ExactTurn(_) => keep.push(msg), } } *entry = keep; @@ -181,7 +198,12 @@ impl SessionSteeringBuffer { pub fn has_pending_for_turn(&self, session_id: &str, turn_id: &str) -> bool { self.inner .get(session_id) - .map(|entry| entry.iter().any(|msg| msg.turn_id == turn_id)) + .map(|entry| { + entry.iter().any(|msg| match &msg.target { + RoundInjectionTarget::ExactTurn(target_turn_id) => target_turn_id == turn_id, + RoundInjectionTarget::CurrentRunningTurn => true, + }) + }) .unwrap_or(false) } @@ -195,12 +217,12 @@ impl SessionSteeringBuffer { } } -impl DialogRoundSteeringSource for SessionSteeringBuffer { +impl DialogRoundInjectionSource for SessionRoundInjectionBuffer { fn has_pending(&self, session_id: &str, turn_id: &str) -> bool { self.has_pending_for_turn(session_id, turn_id) } - fn take_pending(&self, session_id: &str, turn_id: &str) -> Vec { + fn take_pending(&self, session_id: &str, turn_id: &str) -> Vec { self.drain_for_turn(session_id, turn_id) } } @@ -209,10 +231,22 @@ impl DialogRoundSteeringSource for SessionSteeringBuffer { mod steering_tests { use super::*; - fn msg(turn_id: &str, content: &str) -> SteeringMessage { - SteeringMessage { + fn exact_turn_msg(turn_id: &str, content: &str) -> RoundInjection { + RoundInjection { id: uuid::Uuid::new_v4().to_string(), - turn_id: turn_id.to_string(), + kind: RoundInjectionKind::UserSteering, + target: RoundInjectionTarget::ExactTurn(turn_id.to_string()), + content: content.to_string(), + display_content: content.to_string(), + created_at: SystemTime::now(), + } + } + + fn current_turn_msg(content: &str) -> RoundInjection { + RoundInjection { + id: uuid::Uuid::new_v4().to_string(), + kind: RoundInjectionKind::BackgroundSubagentResult, + target: RoundInjectionTarget::CurrentRunningTurn, content: content.to_string(), display_content: content.to_string(), created_at: SystemTime::now(), @@ -221,10 +255,10 @@ mod steering_tests { #[test] fn drain_for_turn_returns_only_matching_turn_messages_in_fifo_order() { - let buf = SessionSteeringBuffer::default(); - buf.push("s1", msg("turn_a", "first")); - buf.push("s1", msg("turn_b", "for_b_only")); - buf.push("s1", msg("turn_a", "second")); + let buf = SessionRoundInjectionBuffer::default(); + buf.push("s1", exact_turn_msg("turn_a", "first")); + buf.push("s1", exact_turn_msg("turn_b", "for_b_only")); + buf.push("s1", exact_turn_msg("turn_a", "second")); assert!(buf.has_pending_for_turn("s1", "turn_a")); assert!(buf.has_pending_for_turn("s1", "turn_b")); @@ -246,17 +280,30 @@ mod steering_tests { #[test] fn drain_for_turn_on_empty_session_returns_empty() { - let buf = SessionSteeringBuffer::default(); + let buf = SessionRoundInjectionBuffer::default(); assert!(buf.drain_for_turn("missing", "turn_a").is_empty()); } #[test] fn clear_drops_all_pending_for_session() { - let buf = SessionSteeringBuffer::default(); - buf.push("s1", msg("turn_a", "x")); - buf.push("s1", msg("turn_b", "y")); + let buf = SessionRoundInjectionBuffer::default(); + buf.push("s1", exact_turn_msg("turn_a", "x")); + buf.push("s1", exact_turn_msg("turn_b", "y")); buf.clear("s1"); assert_eq!(buf.pending_count("s1"), 0); assert!(buf.drain_for_turn("s1", "turn_a").is_empty()); } + + #[test] + fn current_running_turn_messages_are_delivered_to_active_turn() { + let buf = SessionRoundInjectionBuffer::default(); + buf.push("s1", current_turn_msg("background result")); + + assert!(buf.has_pending_for_turn("s1", "turn_a")); + + let drained = buf.drain_for_turn("s1", "turn_a"); + assert_eq!(drained.len(), 1); + assert_eq!(drained[0].content, "background result"); + assert_eq!(buf.pending_count("s1"), 0); + } } diff --git a/src/crates/core/src/agentic/tools/implementations/task_tool.rs b/src/crates/core/src/agentic/tools/implementations/task_tool.rs index 0650602fcb..6e82b85487 100644 --- a/src/crates/core/src/agentic/tools/implementations/task_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/task_tool.rs @@ -523,6 +523,10 @@ impl Tool for TaskTool { "minimum": 0, "description": "Optional timeout for this subagent task in seconds. Use 0 or omit it to disable the timeout." }, + "run_in_background": { + "type": "boolean", + "description": "Optional. When true, start the subagent in the background and return immediately. The final result will be delivered back to the parent agent by steering if it is still running, or by starting a new turn if it is idle." + }, "retry": { "type": "boolean", "description": "DeepReview only: true when this Task call is a retry for the same reviewer role after partial_timeout or an explicit transient capacity failure in the current turn." @@ -710,6 +714,10 @@ impl Tool for TaskTool { } None => None, }; + let run_in_background = input + .get("run_in_background") + .and_then(Value::as_bool) + .unwrap_or(false); let is_retry = input.get("retry").and_then(Value::as_bool).unwrap_or(false); let requested_auto_retry = Self::is_deep_review_auto_retry(input); let is_auto_retry = is_retry && requested_auto_retry; @@ -1107,6 +1115,36 @@ impl Tool for TaskTool { values }); let prepared_prompt = prompt; + if run_in_background { + let parent_info = SubagentParentInfo { + tool_call_id: tool_call_id.clone(), + session_id: session_id.clone(), + dialog_turn_id: dialog_turn_id.clone(), + }; + let background_result = coordinator + .start_background_subagent( + subagent_type.clone(), + prepared_prompt.clone(), + parent_info, + Some(effective_workspace_path.clone()), + subagent_context.clone(), + model_id.clone(), + timeout_seconds, + ) + .await?; + return Ok(vec![ToolResult::Result { + data: json!({ + "status": "started", + "run_in_background": true, + "background_task_id": background_result.background_task_id, + }), + result_for_assistant: Some(format!( + "Background subagent '{}' started successfully.\nIts final result will be delivered back automatically to you when it is finished.", + subagent_type, background_result.background_task_id + )), + image_attachments: None, + }]); + } let mut provider_capacity_retry_reason: Option = None; let mut provider_capacity_queue_elapsed_ms = 0_u64; let mut provider_capacity_retry_attempts = 0_usize; diff --git a/src/crates/core/src/agentic/tools/pipeline/types.rs b/src/crates/core/src/agentic/tools/pipeline/types.rs index 2944d2bd2b..9ab01a3179 100644 --- a/src/crates/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/core/src/agentic/tools/pipeline/types.rs @@ -2,7 +2,7 @@ use crate::agentic::core::{ToolCall, ToolExecutionState}; use crate::agentic::events::SubagentParentInfo as EventSubagentParentInfo; -use crate::agentic::round_preempt::DialogRoundSteeringInterrupt; +use crate::agentic::round_preempt::DialogRoundInjectionInterrupt; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; @@ -77,8 +77,8 @@ pub struct ToolExecutionContext { pub allowed_tools: Vec, pub runtime_tool_restrictions: ToolRuntimeRestrictions, /// Optional cooperative interrupt used to stop remaining tool calls when a - /// user steering message is waiting for this turn. - pub steering_interrupt: Option, + /// round injection is waiting for this turn. + pub steering_interrupt: Option, pub workspace_services: Option, } From 6815d15a28c950d45ffc95d80c2cdd47d18264fa Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 18 May 2026 00:55:45 +0800 Subject: [PATCH 16/76] feat(subagent): add GeneralPurpose subagent --- .../definitions/subagents/general_purpose.rs | 64 +++++++++++++++++++ .../agents/definitions/subagents/mod.rs | 2 + src/crates/core/src/agentic/agents/mod.rs | 2 +- .../agents/prompts/general_purpose_agent.md | 36 +++++++++++ .../src/agentic/agents/registry/builtin.rs | 1 + .../src/agentic/agents/registry/catalog.rs | 9 ++- .../core/src/agentic/agents/registry/tests.rs | 11 ++++ 7 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs create mode 100644 src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs b/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs new file mode 100644 index 0000000000..048612640a --- /dev/null +++ b/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs @@ -0,0 +1,64 @@ +use crate::agentic::agents::{Agent, RequestContextPolicy}; +use async_trait::async_trait; + +pub struct GeneralPurposeAgent { + default_tools: Vec, +} + +impl Default for GeneralPurposeAgent { + fn default() -> Self { + Self::new() + } +} + +impl GeneralPurposeAgent { + pub fn new() -> Self { + Self { + default_tools: vec![ + "Bash".to_string(), + "Read".to_string(), + "Glob".to_string(), + "Grep".to_string(), + "Write".to_string(), + "Edit".to_string(), + "WebSearch".to_string(), + "WebFetch".to_string(), + ], + } + } +} + +#[async_trait] +impl Agent for GeneralPurposeAgent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + "GeneralPurpose" + } + + fn name(&self) -> &str { + "General Purpose" + } + + fn description(&self) -> &str { + r#"General-purpose implementation and research subagent for multi-step tasks that need focused codebase search, targeted file edits."# + } + + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + "general_purpose_agent" + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::instructions_only() + } + + fn is_readonly(&self) -> bool { + false + } +} diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/mod.rs b/src/crates/core/src/agentic/agents/definitions/subagents/mod.rs index ea21841eaa..37309c8e3f 100644 --- a/src/crates/core/src/agentic/agents/definitions/subagents/mod.rs +++ b/src/crates/core/src/agentic/agents/definitions/subagents/mod.rs @@ -1,9 +1,11 @@ mod computer_use; mod explore; mod file_finder; +mod general_purpose; mod research_specialist; pub use computer_use::ComputerUseMode; pub use explore::ExploreAgent; pub use file_finder::FileFinderAgent; +pub use general_purpose::GeneralPurposeAgent; pub use research_specialist::ResearchSpecialistAgent; diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index 07730aeac1..c1d12a371d 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -25,7 +25,7 @@ pub use definitions::review::{ }; pub use definitions::shared::ReadonlySubagent; pub use definitions::subagents::{ - ComputerUseMode, ExploreAgent, FileFinderAgent, ResearchSpecialistAgent, + ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, ResearchSpecialistAgent, }; use indexmap::IndexMap; pub use prompt_builder::{ diff --git a/src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md b/src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md new file mode 100644 index 0000000000..16ee626dd7 --- /dev/null +++ b/src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md @@ -0,0 +1,36 @@ +You are a general-purpose agent for BitFun, a desktop AI IDE and agent runtime. Given the user's message, use the available tools to complete the task. Complete the task fully. Do not over-engineer, but do not leave the task half-done. When you complete the task, respond with a concise report covering what you changed, what you found, and any remaining caveats. + +{LANGUAGE_PREFERENCE} + +## Strengths + +- Searching for code, configurations, and patterns across large codebases +- Analyzing multiple files to understand system architecture +- Investigating complex questions that require exploring many files +- Performing multi-step research tasks + +## Working style + +- Search broadly when you do not yet know where something lives. Narrow quickly once you find strong candidates. +- Prefer `Grep` and `Glob` before reading many files in full. +- Use `Read` when you know the path or have narrowed the candidate set enough that reading is justified. +- Read before you edit. Do not propose or apply changes to code you have not inspected. +- Prefer focused edits to existing files over broad rewrites. +- Do not create new files unless they are clearly necessary for completing the requested task. +- Do not proactively create documentation files such as `README` or `*.md` unless the user explicitly asks for them. + +## Editing rules + +- Preserve existing architecture, naming, and local style unless the task explicitly calls for a structural change. +- Keep changes scoped to the user's request. Avoid opportunistic refactors. +- If the task appears ambiguous or risky, bias toward the smallest safe change and explain the constraint in your final report. +- When using `Write`, prefer creating or replacing only files that are clearly intended outputs of the task. When modifying existing files, prefer `Edit` whenever practical. + +## Final response + +- Keep the final response concise and concrete. +- Include the relevant file paths you changed or inspected when they matter to the parent agent. +- Include short code snippets only when the exact text is load-bearing. +- Avoid emojis. + +{ENV_INFO} \ No newline at end of file diff --git a/src/crates/core/src/agentic/agents/registry/builtin.rs b/src/crates/core/src/agentic/agents/registry/builtin.rs index e431d8b6c2..d2fe9cd136 100644 --- a/src/crates/core/src/agentic/agents/registry/builtin.rs +++ b/src/crates/core/src/agentic/agents/registry/builtin.rs @@ -20,6 +20,7 @@ pub(crate) fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static s | "ReviewJudge" | "ReviewFixer" => "fast", "Explore" | "FileFinder" | "CodeReview" | "GenerateDoc" | "Init" => "primary", + "GeneralPurpose" => "fast", _ => "fast", } } diff --git a/src/crates/core/src/agentic/agents/registry/catalog.rs b/src/crates/core/src/agentic/agents/registry/catalog.rs index b9acf54853..633609dfdc 100644 --- a/src/crates/core/src/agentic/agents/registry/catalog.rs +++ b/src/crates/core/src/agentic/agents/registry/catalog.rs @@ -3,8 +3,8 @@ use super::visibility::SubagentVisibilityPolicy; use crate::agentic::agents::{ Agent, AgenticMode, ArchitectureReviewerAgent, BusinessLogicReviewerAgent, ClawMode, CodeReviewAgent, ComputerUseMode, CoworkMode, DebugMode, DeepResearchMode, DeepReviewAgent, - ExploreAgent, FileFinderAgent, FrontendReviewerAgent, GenerateDocAgent, InitAgent, - PerformanceReviewerAgent, PlanMode, ResearchSpecialistAgent, ReviewFixerAgent, + ExploreAgent, FileFinderAgent, FrontendReviewerAgent, GeneralPurposeAgent, GenerateDocAgent, + InitAgent, PerformanceReviewerAgent, PlanMode, ResearchSpecialistAgent, ReviewFixerAgent, ReviewJudgeAgent, SecurityReviewerAgent, TeamMode, }; use std::sync::Arc; @@ -63,6 +63,11 @@ pub fn builtin_agent_specs() -> Vec { category: AgentCategory::SubAgent, visibility_policy: SubagentVisibilityPolicy::public(), }, + BuiltinAgentSpec { + factory: || Arc::new(GeneralPurposeAgent::new()), + category: AgentCategory::SubAgent, + visibility_policy: SubagentVisibilityPolicy::public(), + }, BuiltinAgentSpec { factory: || Arc::new(ResearchSpecialistAgent::new()), category: AgentCategory::SubAgent, diff --git a/src/crates/core/src/agentic/agents/registry/tests.rs b/src/crates/core/src/agentic/agents/registry/tests.rs index 7608bf11ab..5df0dfca21 100644 --- a/src/crates/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/core/src/agentic/agents/registry/tests.rs @@ -119,6 +119,14 @@ fn non_deep_review_builtin_subagents_default_to_primary() { } } +#[test] +fn general_purpose_builtin_subagent_defaults_to_fast() { + assert_eq!( + default_model_id_for_builtin_agent("GeneralPurpose"), + "fast" + ); +} + #[test] fn deep_review_family_defaults_to_fast() { for agent_type in [ @@ -185,6 +193,9 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { }) .await; assert!(agentic_visible.iter().any(|agent| agent.id == "Explore")); + assert!(agentic_visible + .iter() + .any(|agent| agent.id == "GeneralPurpose")); assert!(!agentic_visible .iter() .any(|agent| agent.id == "ReviewSecurity")); From 0d435f255588cfdb8286966de3703ecc26e6b520 Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 18 May 2026 02:06:03 +0800 Subject: [PATCH 17/76] feat(agentic): add Multitask mode and improve background subagent result delivery - add builtin Multitask mode with agentic-equivalent tooling and multitask-specific reminders - surface Multitask across mode registry, model defaults, skill policy, remote mapping, and localized frontend mode pickers - fix chat input mode filtering and persisted agent-type validation so Multitask can be selected in the UI - move agent system reminders ahead of user_query when wrapping turn input - correct background subagent result delivery semantics so result turns display the actual returned result instead of the original delegated task --- .../agents/definitions/modes/agentic.rs | 33 ----- .../agentic/agents/definitions/modes/mod.rs | 2 + .../agents/definitions/modes/multitask.rs | 90 +++++++++++++ src/crates/core/src/agentic/agents/mod.rs | 5 +- .../src/agentic/agents/registry/builtin.rs | 2 +- .../src/agentic/agents/registry/catalog.rs | 9 +- .../core/src/agentic/agents/registry/query.rs | 11 +- .../core/src/agentic/agents/registry/tests.rs | 2 + .../core/src/agentic/context_profile.rs | 2 +- .../src/agentic/coordination/coordinator.rs | 125 +++++++++++++----- .../src/agentic/session/session_manager.rs | 6 +- .../tools/implementations/skills/policy.rs | 4 +- .../src/remote_connect.rs | 1 + .../src/flow_chat/components/ChatInput.tsx | 5 +- .../EventHandlerModule.test.ts | 23 ++++ .../flow-chat-manager/EventHandlerModule.ts | 25 +++- .../src/flow_chat/store/FlowChatStore.ts | 11 +- src/web-ui/src/locales/en-US/flow-chat.json | 2 + .../src/locales/en-US/scenes/agents.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 2 + .../src/locales/zh-CN/scenes/agents.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 2 + .../src/locales/zh-TW/scenes/agents.json | 1 + 23 files changed, 280 insertions(+), 85 deletions(-) create mode 100644 src/crates/core/src/agentic/agents/definitions/modes/multitask.rs diff --git a/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs b/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs index 9b69600da4..9a8b6fe742 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs @@ -52,36 +52,3 @@ impl Agent for AgenticMode { false } } - -#[cfg(test)] -mod tests { - use super::{shared_coding_mode_tools, Agent, AgenticMode, SHARED_CODING_MODE_PROMPT_TEMPLATE}; - - #[test] - fn always_uses_default_prompt_template() { - let agent = AgenticMode::new(); - assert_eq!( - agent.prompt_template_name(Some("gpt-5.1")), - SHARED_CODING_MODE_PROMPT_TEMPLATE - ); - assert_eq!( - agent.prompt_template_name(Some("GPT-5-CODEX")), - SHARED_CODING_MODE_PROMPT_TEMPLATE - ); - assert_eq!( - agent.prompt_template_name(Some("claude-sonnet-4")), - SHARED_CODING_MODE_PROMPT_TEMPLATE - ); - assert_eq!( - agent.prompt_template_name(None), - SHARED_CODING_MODE_PROMPT_TEMPLATE - ); - } - - #[test] - fn shared_coding_tools_include_plan_and_debug_helpers() { - let tools = shared_coding_mode_tools(); - assert!(tools.contains(&"CreatePlan".to_string())); - assert!(tools.contains(&"Log".to_string())); - } -} diff --git a/src/crates/core/src/agentic/agents/definitions/modes/mod.rs b/src/crates/core/src/agentic/agents/definitions/modes/mod.rs index 831af452d2..85895d86c5 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/mod.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/mod.rs @@ -3,6 +3,7 @@ mod claw; mod cowork; mod debug; mod deep_research; +mod multitask; mod plan; mod team; @@ -11,5 +12,6 @@ pub use claw::ClawMode; pub use cowork::CoworkMode; pub use debug::DebugMode; pub use deep_research::DeepResearchMode; +pub use multitask::MultitaskMode; pub use plan::PlanMode; pub use team::TeamMode; diff --git a/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs b/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs new file mode 100644 index 0000000000..10af4cb979 --- /dev/null +++ b/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs @@ -0,0 +1,90 @@ +//! Multitask Mode + +use crate::agentic::agents::{ + shared_coding_mode_tools, Agent, SHARED_CODING_MODE_PROMPT_TEMPLATE, +}; +use async_trait::async_trait; + +pub struct MultitaskMode { + default_tools: Vec, +} + +impl Default for MultitaskMode { + fn default() -> Self { + Self::new() + } +} + +impl MultitaskMode { + pub fn new() -> Self { + Self { + default_tools: shared_coding_mode_tools(), + } + } + + fn build_first_entry_reminder(&self) -> String { + r#"You are now in Multitask mode. + +Treat the task as a parallel work orchestration problem whenever it is beneficial. First decompose the work into orthogonal subtasks or an explicit DAG with clear dependency edges. Then use subagents proactively to execute independent branches in parallel. + +Prefer: +- independent subtasks with minimal overlap +- clear ownership and deliverables per subagent +- parallel execution for non-blocking branches +- background execution for independent subagents whenever possible; prefer the Task tool's `run_in_background` mode instead of blocking on each branch +- local execution only for the immediate critical path + +Do not force parallelism when the task is tiny or tightly coupled, but default to decomposition-first thinking in this mode."# + .to_string() + } + + fn build_ongoing_reminder(&self) -> String { + r#"You are still in Multitask mode. + +Continue working with a parallel-first mindset. Prefer orthogonal decomposition, preserve clear dependency edges, and keep using subagents proactively for independent branches whenever parallel execution is beneficial."# + .to_string() + } +} + +#[async_trait] +impl Agent for MultitaskMode { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + "Multitask" + } + + fn name(&self) -> &str { + "Multitask" + } + + fn description(&self) -> &str { + "Agentic coding mode optimized for orthogonal task decomposition and proactive parallel subagent execution" + } + + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + SHARED_CODING_MODE_PROMPT_TEMPLATE + } + + async fn get_system_reminder( + &self, + previous_agent_type: Option<&str>, + _workspace: Option<&crate::agentic::WorkspaceBinding>, + ) -> crate::util::errors::BitFunResult { + if previous_agent_type == Some(self.id()) { + Ok(self.build_ongoing_reminder()) + } else { + Ok(self.build_first_entry_reminder()) + } + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn is_readonly(&self) -> bool { + false + } +} diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index c1d12a371d..203fe81d28 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -17,7 +17,8 @@ use async_trait::async_trait; pub use definitions::custom::{CustomSubagent, CustomSubagentKind}; pub use definitions::hidden::{CodeReviewAgent, DeepReviewAgent, GenerateDocAgent, InitAgent}; pub use definitions::modes::{ - AgenticMode, ClawMode, CoworkMode, DebugMode, DeepResearchMode, PlanMode, TeamMode, + AgenticMode, ClawMode, CoworkMode, DebugMode, DeepResearchMode, MultitaskMode, PlanMode, + TeamMode, }; pub use definitions::review::{ ArchitectureReviewerAgent, BusinessLogicReviewerAgent, FrontendReviewerAgent, @@ -73,8 +74,6 @@ pub fn shared_coding_mode_tools() -> Vec { "TerminalControl".to_string(), "ControlHub".to_string(), "InitMiniApp".to_string(), - "CreatePlan".to_string(), - "Log".to_string(), ] } diff --git a/src/crates/core/src/agentic/agents/registry/builtin.rs b/src/crates/core/src/agentic/agents/registry/builtin.rs index d2fe9cd136..82738c54bc 100644 --- a/src/crates/core/src/agentic/agents/registry/builtin.rs +++ b/src/crates/core/src/agentic/agents/registry/builtin.rs @@ -10,7 +10,7 @@ use std::sync::Arc; pub(crate) fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static str { match agent_type { "agentic" | "Cowork" | "ComputerUse" | "Plan" | "debug" | "Claw" | "DeepResearch" - | "Team" => "auto", + | "Team" | "Multitask" => "auto", "DeepReview" | "ReviewBusinessLogic" | "ReviewPerformance" diff --git a/src/crates/core/src/agentic/agents/registry/catalog.rs b/src/crates/core/src/agentic/agents/registry/catalog.rs index 633609dfdc..c0691f399e 100644 --- a/src/crates/core/src/agentic/agents/registry/catalog.rs +++ b/src/crates/core/src/agentic/agents/registry/catalog.rs @@ -4,8 +4,8 @@ use crate::agentic::agents::{ Agent, AgenticMode, ArchitectureReviewerAgent, BusinessLogicReviewerAgent, ClawMode, CodeReviewAgent, ComputerUseMode, CoworkMode, DebugMode, DeepResearchMode, DeepReviewAgent, ExploreAgent, FileFinderAgent, FrontendReviewerAgent, GeneralPurposeAgent, GenerateDocAgent, - InitAgent, PerformanceReviewerAgent, PlanMode, ResearchSpecialistAgent, ReviewFixerAgent, - ReviewJudgeAgent, SecurityReviewerAgent, TeamMode, + InitAgent, MultitaskMode, PerformanceReviewerAgent, PlanMode, ResearchSpecialistAgent, + ReviewFixerAgent, ReviewJudgeAgent, SecurityReviewerAgent, TeamMode, }; use std::sync::Arc; @@ -33,6 +33,11 @@ pub fn builtin_agent_specs() -> Vec { category: AgentCategory::Mode, visibility_policy: SubagentVisibilityPolicy::default(), }, + BuiltinAgentSpec { + factory: || Arc::new(MultitaskMode::new()), + category: AgentCategory::Mode, + visibility_policy: SubagentVisibilityPolicy::default(), + }, BuiltinAgentSpec { factory: || Arc::new(PlanMode::new()), category: AgentCategory::Mode, diff --git a/src/crates/core/src/agentic/agents/registry/query.rs b/src/crates/core/src/agentic/agents/registry/query.rs index cf486353ae..7f9076cfe3 100644 --- a/src/crates/core/src/agentic/agents/registry/query.rs +++ b/src/crates/core/src/agentic/agents/registry/query.rs @@ -115,11 +115,12 @@ impl AgentRegistry { let order = |id: &str| -> u8 { match id { "agentic" => 0, - "Cowork" => 1, - "Plan" => 2, - "debug" => 3, - "DeepResearch" => 4, - "Team" => 5, + "Multitask" => 1, + "Cowork" => 2, + "Plan" => 3, + "debug" => 4, + "DeepResearch" => 5, + "Team" => 6, _ => 99, } }; diff --git a/src/crates/core/src/agentic/agents/registry/tests.rs b/src/crates/core/src/agentic/agents/registry/tests.rs index 5df0dfca21..bd6c45ac01 100644 --- a/src/crates/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/core/src/agentic/agents/registry/tests.rs @@ -71,6 +71,7 @@ fn insert_project_subagent(registry: &AgentRegistry, workspace: &Path, id: &str, fn top_level_modes_default_to_auto() { for agent_type in [ "agentic", + "Multitask", "Cowork", "Plan", "debug", @@ -358,6 +359,7 @@ async fn prompt_stability_task_visible_subagents_are_sorted_deterministically() "ABuiltin", "Explore", "FileFinder", + "GeneralPurpose", "zBuiltin", "AProject", "zProject", diff --git a/src/crates/core/src/agentic/context_profile.rs b/src/crates/core/src/agentic/context_profile.rs index a969490888..3b108af974 100644 --- a/src/crates/core/src/agentic/context_profile.rs +++ b/src/crates/core/src/agentic/context_profile.rs @@ -207,7 +207,7 @@ impl ContextProfilePolicy { fn is_long_task_agent(agent_type: &str) -> bool { matches!( agent_type, - "agentic" | "DeepReview" | "DeepResearch" | "ComputerUse" | "Team" + "agentic" | "Multitask" | "DeepReview" | "DeepResearch" | "ComputerUse" | "Team" ) || agent_type.starts_with("Review") } diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 194365c0fd..a8e0887e78 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -104,6 +104,34 @@ pub struct BackgroundSubagentStartResult { pub background_task_id: String, } +fn format_background_subagent_delivery_text( + background_task_id: &str, + agent_type: &str, + outcome: Result<&SubagentResult, &BitFunError>, +) -> String { + match outcome { + Ok(result) => { + if result.is_partial_timeout() { + format!( + "Background subagent '{}' (background_task_id='{}') completed with partial timeout result:\n\n{}\n", + agent_type, background_task_id, result.text + ) + } else { + format!( + "Background subagent '{}' (background_task_id='{}') completed successfully:\n\n{}\n", + agent_type, background_task_id, result.text + ) + } + } + Err(error) => { + format!( + "Background subagent '{}' (background_task_id='{}') failed before producing a final result.\nError: {}", + agent_type, background_task_id, error + ) + } + } +} + struct HiddenSubagentExecutionRequest { session_name: String, agent_type: String, @@ -1162,22 +1190,23 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .get_system_reminder(previous_agent_type, workspace) .await?; - let mut wrapped_user_input = if has_prompt_markup(&user_input) { - user_input + if has_prompt_markup(&user_input) { + if system_reminder.is_empty() { + Ok(user_input) + } else { + let mut envelope = PromptEnvelope::new(); + envelope.push_system_reminder(system_reminder); + envelope.push_user_query(user_input); + Ok(envelope.render()) + } } else { let mut envelope = PromptEnvelope::new(); - envelope.push_user_query(user_input); - envelope.render() - }; - if !system_reminder.is_empty() { - let mut envelope = PromptEnvelope::new(); - envelope.push_system_reminder(system_reminder); - if !wrapped_user_input.is_empty() { - wrapped_user_input.push('\n'); + if !system_reminder.is_empty() { + envelope.push_system_reminder(system_reminder); } - wrapped_user_input.push_str(&envelope.render()); + envelope.push_user_query(user_input); + Ok(envelope.render()) } - Ok(wrapped_user_input) } pub async fn ensure_assistant_bootstrap( @@ -3745,31 +3774,23 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .execute_hidden_subagent_internal(request, None, timeout_seconds) .await { - Ok(result) => { - if result.is_partial_timeout() { - format!( - "Background subagent '{}' timed out with partial result:\n\n{}\n", - agent_type, result.text - ) - } else { - format!( - "Background subagent '{}' completed successfully:\n\n{}\n", - agent_type, result.text - ) - } - } - Err(error) => { - format!( - "Background subagent '{}' failed before producing a final result.\nError: {}", - agent_type, error - ) - } + Ok(result) => format_background_subagent_delivery_text( + &background_task_id_for_delivery, + &agent_type, + Ok(&result), + ), + Err(error) => format_background_subagent_delivery_text( + &background_task_id_for_delivery, + &agent_type, + Err(&error), + ), }; let metadata = serde_json::json!({ "kind": "background_subagent_result", "backgroundTaskId": background_task_id_for_delivery, "subagentType": agent_type, + "taskDescription": task_description, }); if let Some(scheduler) = super::scheduler::get_global_scheduler() { @@ -3779,7 +3800,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet parent_agent_type, parent_workspace_path, delivery_text, - Some(task_description), + None, Some(metadata), ) .await @@ -4384,6 +4405,46 @@ mod tests { assert_eq!(normalize_subagent_max_concurrency(usize::MAX), 64); } + #[test] + fn background_subagent_delivery_text_includes_background_task_id() { + let completed = super::SubagentResult::completed("done".to_string()); + let completed_text = super::format_background_subagent_delivery_text( + "bg-subagent-123", + "GeneralPurpose", + Ok(&completed), + ); + assert!(completed_text.contains( + "Background subagent 'GeneralPurpose' (background_task_id='bg-subagent-123') completed successfully:" + )); + assert!(completed_text.contains("\n")); + assert!(!completed_text.contains("background_task_id=\"bg-subagent-123\"")); + + let partial = super::SubagentResult::partial_timeout( + "partial".to_string(), + "timeout".to_string(), + ); + let partial_text = super::format_background_subagent_delivery_text( + "bg-subagent-456", + "GeneralPurpose", + Ok(&partial), + ); + assert!(partial_text.contains( + "Background subagent 'GeneralPurpose' (background_task_id='bg-subagent-456') completed with partial timeout result:" + )); + assert!(partial_text.contains("\n")); + assert!(!partial_text.contains("background_task_id=\"bg-subagent-456\"")); + + let failed_text = super::format_background_subagent_delivery_text( + "bg-subagent-789", + "GeneralPurpose", + Err(&crate::util::errors::BitFunError::tool("boom".to_string())), + ); + assert!(failed_text.contains( + "Background subagent 'GeneralPurpose' (background_task_id='bg-subagent-789') failed before producing a final result." + )); + assert!(failed_text.contains("Error:")); + } + #[test] fn agent_submission_turn_id_prefers_explicit_field_over_metadata() { let mut metadata = serde_json::Map::new(); diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 464268ba61..06452d513d 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -2873,6 +2873,7 @@ mod tests { use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::SessionContextStore; use crate::infrastructure::PathManager; + use crate::service::remote_ssh::workspace_state::local_workspace_roots_equal; use crate::service::session::{ DialogTurnData, ModelRoundData, ToolCallData, ToolItemData, ToolResultData, UserMessageData, }; @@ -3487,8 +3488,9 @@ mod tests { manager .session_workspace_index .get(&session.session_id) - .map(|entry| entry.clone()), - Some(workspace.path().to_path_buf()) + .as_deref() + .map(|entry| local_workspace_roots_equal(entry, workspace.path())), + Some(true) ); manager diff --git a/src/crates/core/src/agentic/tools/implementations/skills/policy.rs b/src/crates/core/src/agentic/tools/implementations/skills/policy.rs index c7a912aa34..9012195488 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/policy.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/policy.rs @@ -9,6 +9,7 @@ use super::catalog::{builtin_skill_spec, BuiltinSkillGroup, BuiltinSkillId, Buil #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SkillModeId { Agentic, + Multitask, Cowork, Plan, Debug, @@ -23,6 +24,7 @@ impl SkillModeId { pub fn parse(mode_id: &str) -> Self { match mode_id.trim() { "agentic" => Self::Agentic, + "Multitask" => Self::Multitask, "Cowork" => Self::Cowork, "Plan" => Self::Plan, "debug" => Self::Debug, @@ -121,7 +123,7 @@ pub fn policy_for_mode(mode_id: &str) -> ModeSkillPolicy { match SkillModeId::parse(mode_id) { SkillModeId::Plan => PLAN_POLICY, SkillModeId::Debug => DEBUG_POLICY, - SkillModeId::Agentic | SkillModeId::Claw => AGENTIC_POLICY, + SkillModeId::Agentic | SkillModeId::Multitask | SkillModeId::Claw => AGENTIC_POLICY, SkillModeId::Cowork => COWORK_POLICY, SkillModeId::Team => TEAM_POLICY, SkillModeId::ComputerUse | SkillModeId::DeepResearch | SkillModeId::Other => { diff --git a/src/crates/services-integrations/src/remote_connect.rs b/src/crates/services-integrations/src/remote_connect.rs index 30f47eb398..07f5315fca 100644 --- a/src/crates/services-integrations/src/remote_connect.rs +++ b/src/crates/services-integrations/src/remote_connect.rs @@ -271,6 +271,7 @@ pub struct RemoteModelCatalogPollDelta { pub fn resolve_remote_agent_type(mobile_type: Option<&str>) -> &'static str { match mobile_type { Some("code") | Some("agentic") | Some("Agentic") => "agentic", + Some("multitask") | Some("Multitask") => "Multitask", Some("cowork") | Some("Cowork") => "Cowork", Some("plan") | Some("Plan") => "Plan", Some("debug") | Some("Debug") => "debug", diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index a35ea5be4a..f33242f742 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -351,7 +351,10 @@ export const ChatInput: React.FC = ({ /** Code session: modes switchable on top of default agentic */ const incrementalCodeModes = useMemo( - () => switchableModes.filter(m => m.id === 'Plan' || m.id === 'debug' || m.id === 'DeepResearch' || m.id === 'Team'), + () => + switchableModes.filter( + m => m.id !== 'agentic' + ), [switchableModes] ); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index a54e6f05ed..e9005f2781 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { normalizeSubagentParentInfo } from './subagentParentInfo'; import { + __test_only__, formatDialogErrorForNotification, handleDialogTurnComplete, handleSessionStateChanged, @@ -74,6 +75,28 @@ describe('normalizeSubagentParentInfo', () => { }); }); +describe('resolveDialogTurnDisplayContent', () => { + it('prefers original user input for ordinary turns', () => { + expect( + __test_only__.resolveDialogTurnDisplayContent( + '\nwrapped runtime content\n', + 'Original human message', + { kind: 'user_dialog' }, + ), + ).toBe('Original human message'); + }); + + it('still prefers original user input when metadata is background_subagent_result', () => { + expect( + __test_only__.resolveDialogTurnDisplayContent( + 'Delivered result text', + 'Display content chosen by backend', + { kind: 'background_subagent_result' }, + ), + ).toBe('Display content chosen by backend'); + }); +}); + describe('shouldProcessEvent', () => { const mockSessionId = 'test-session'; const mockTurnId = 'test-turn'; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 435d433a79..e7455b99cb 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -105,6 +105,23 @@ export function isAppWindowFocused(): boolean { return document.visibilityState === 'visible' && document.hasFocus(); } +function resolveDialogTurnDisplayContent( + userInput: unknown, + originalUserInput: unknown, + _userMessageMetadata: unknown, +): string { + const cleanedUserInput = cleanRemoteUserInput(typeof userInput === 'string' ? userInput : ''); + const cleanedOriginalUserInput = cleanRemoteUserInput( + typeof originalUserInput === 'string' ? originalUserInput : '' + ); + + return cleanedOriginalUserInput || cleanedUserInput; +} + +export const __test_only__ = { + resolveDialogTurnDisplayContent, +}; + function shouldMarkUnreadCompletion(sessionId: string): boolean { const activeSessionId = FlowChatStore.getInstance().getState().activeSessionId; return sessionId !== activeSessionId || !isAppWindowFocused(); @@ -1269,9 +1286,11 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { mimeType: img.mime_type, })) : undefined; - const displayContent = originalUserInput - ? cleanRemoteUserInput(originalUserInput) - : cleanRemoteUserInput(userInput || ''); + const displayContent = resolveDialogTurnDisplayContent( + userInput, + originalUserInput, + userMessageMetadata, + ); const turnKind = userMessageMetadata?.kind === 'manual_compaction' ? 'manual_compaction' : 'user_dialog'; diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index a0ec4e20d7..8fc7658396 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -52,7 +52,16 @@ import { sessionBelongsToWorkspaceNavRow } from '../utils/sessionOrdering'; import { sessionMatchesWorkspace } from '../utils/workspaceScope'; const log = createLogger('FlowChatStore'); -const VALID_AGENT_TYPES = new Set(['agentic', 'debug', 'Plan', 'Cowork', 'Claw', 'Team', 'DeepResearch']); +const VALID_AGENT_TYPES = new Set([ + 'agentic', + 'Multitask', + 'debug', + 'Plan', + 'Cowork', + 'Claw', + 'Team', + 'DeepResearch', +]); const METADATA_LIST_RECENT_DEDUPE_TTL_MS = 1000; interface MetadataListRequest { diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 2b47e9a176..3d0f3ed663 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -505,6 +505,7 @@ "sendingToBtw": "Side session: {{title}}", "modeDescriptions": { "agentic": "Full-featured AI assistant with access to all tools for comprehensive software development tasks", + "Multitask": "Multitask mode: decompose work into orthogonal branches or a DAG and proactively use subagents in parallel when it helps", "Claw": "Personal assistant mode for dedicated assistant workspaces and everyday task support", "Plan": "Plan first, execute later — clarify requirements and create an implementation plan before coding", "debug": "Evidence-driven systematic debugging: form hypotheses, gather runtime evidence, and fix with confidence", @@ -515,6 +516,7 @@ }, "modeNames": { "agentic": "Agentic", + "Multitask": "Multitask", "Claw": "Claw", "Plan": "Plan", "debug": "Debug", diff --git a/src/web-ui/src/locales/en-US/scenes/agents.json b/src/web-ui/src/locales/en-US/scenes/agents.json index 2b32d5a5d0..e76f2f3198 100644 --- a/src/web-ui/src/locales/en-US/scenes/agents.json +++ b/src/web-ui/src/locales/en-US/scenes/agents.json @@ -344,6 +344,7 @@ "GenerateDoc": "Documentation agent: automatically generate code documentation and explanations", "Init": "Init agent: help set up project structure and initial configuration", "ReviewFixer": "Review fixer agent: automatically fix code issues based on review results", + "Multitask": "Multitask mode: decompose work into orthogonal branches or a DAG and use subagents in parallel when beneficial", "Plan": "Plan mode: create detailed task plans and execution steps", "Debug": "Debug mode: systematically diagnose and fix errors in code", "Claw": "Claw mode: extract and integrate information from external sources", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 15dc8fed22..0cf1e76758 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -499,6 +499,7 @@ "sendingToBtw": "侧问会话:{{title}}", "modeDescriptions": { "agentic": "AI 主导执行,自动规划和完成编码任务,拥有完整的工具访问能力", + "Multitask": "多任务模式:将工作拆成正交分支或 DAG,并在合适时主动并行调度子 Agent 推进", "Claw": "个人助理模式:面向个人工作区和日常事务,使用独立的助理上下文", "Plan": "先规划后执行,先明确需求并制定实施计划,再进行编码", "debug": "证据驱动的系统化调试:提出假设、收集运行时证据、精准定位并修复问题", @@ -509,6 +510,7 @@ }, "modeNames": { "agentic": "Agentic", + "Multitask": "Multitask", "Claw": "Claw", "Plan": "Plan", "debug": "Debug", diff --git a/src/web-ui/src/locales/zh-CN/scenes/agents.json b/src/web-ui/src/locales/zh-CN/scenes/agents.json index c95323b943..72d1b9ce32 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/agents.json +++ b/src/web-ui/src/locales/zh-CN/scenes/agents.json @@ -344,6 +344,7 @@ "GenerateDoc": "文档生成智能体:自动生成代码文档和说明", "Init": "初始化智能体:帮助设置项目结构和初始配置", "ReviewFixer": "审查修复智能体:根据审查结果自动修复代码问题", + "Multitask": "多任务模式:将工作拆为正交分支或 DAG,并在合适时并行使用子智能体推进", "Plan": "规划模式:制定详细的任务计划和执行步骤", "Debug": "调试模式:系统性地诊断和修复代码中的错误", "Claw": "抓取模式:从外部源提取和整合信息", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index f7e17f177a..234754891a 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -499,6 +499,7 @@ "sendingToBtw": "側問會話:{{title}}", "modeDescriptions": { "agentic": "AI 主導執行,自動規劃和完成編碼任務,擁有完整的工具訪問能力", + "Multitask": "多工模式:將工作拆成正交分支或 DAG,並在合適時主動並行調度子 Agent 推進", "Claw": "個人助理模式:面向個人工作區和日常事務,使用獨立的助理上下文", "Plan": "先規劃後執行,先明確需求並制定實施計劃,再進行編碼", "debug": "證據驅動的系統化調試:提出假設、收集運行時證據、精準定位並修復問題", @@ -509,6 +510,7 @@ }, "modeNames": { "agentic": "Agentic", + "Multitask": "Multitask", "Claw": "Claw", "Plan": "Plan", "debug": "Debug", diff --git a/src/web-ui/src/locales/zh-TW/scenes/agents.json b/src/web-ui/src/locales/zh-TW/scenes/agents.json index e3e1154cd7..6e6f8e2c9f 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/agents.json +++ b/src/web-ui/src/locales/zh-TW/scenes/agents.json @@ -344,6 +344,7 @@ "GenerateDoc": "文件生成智慧體:自動生成程式碼文件和說明", "Init": "初始化智慧體:幫助設定專案結構和初始配置", "ReviewFixer": "審查修復智慧體:根據審查結果自動修復程式碼問題", + "Multitask": "多工模式:將工作拆為正交分支或 DAG,並在合適時並行使用子智慧體推進", "Plan": "規劃模式:制定詳細的任務計劃和執行步驟", "Debug": "除錯模式:系統性地診斷和修復程式碼中的錯誤", "Claw": "抓取模式:從外部來源提取和整合資訊", From 1cb4106f7b80335805404fc85c7ae702e94a87c1 Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 18 May 2026 10:44:46 +0800 Subject: [PATCH 18/76] chore(boundaries): refresh latest-main guardrails Align the core boundary script with current GetToolSpec prompt stubs and anchor latest-main CLI subagent, Web startup, and built-in MiniApp seed surfaces. Update decomposition docs so future runtime moves keep these product-surface behaviors explicit and do not imply owner migration completion. --- AGENTS.md | 13 +- docs/architecture/core-decomposition.md | 18 +- docs/plans/core-decomposition-plan.md | 10 +- scripts/check-core-boundaries.mjs | 451 +++++++++++++++++++++++- 4 files changed, 481 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5d23ce1896..a1d72904ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,7 +134,12 @@ and milestone verification gates. ### Latest-main runtime anchors - Agent registry migration must preserve mode-scoped subagent availability, - hidden/custom/review grouping, and desktop subagent API semantics. + hidden/custom/review grouping, desktop subagent API semantics, and CLI + mode-aware `/subagents` list/config behavior, including `Multitask` mode + and the built-in `GeneralPurpose` subagent. +- Background subagent task delivery remains core agent-runtime behavior: + `Task.run_in_background` must preserve parent metadata, workspace routing, + running-turn injection, and idle-session follow-up turn delivery. - DeepResearch report finalization currently relies on the core citation renumber hook; do not move it without preserving `report.md`, `citations.md`, `display_map.json`, and rejected-citation handling. @@ -142,6 +147,12 @@ and milestone verification gates. remote flashgrep fallback, and search preview/context mapping. - ACP timeout handling and Web operation-diff fallback are product-surface behavior; share facts through contracts, not UI/protocol implementation. +- Web startup trace, deferred background scheduling, narrow tool startup, and + non-blocking Flow Chat history hydration are Web product-surface behavior; + do not move those orchestration paths into core contract crates. +- Built-in MiniApp seeding, content-hash update markers, customization-update + metadata, and host dispatch execution remain core-owned runtime behavior + until a reviewed MiniApp runtime migration exists. ### Services/product owner closure diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 02d418f7ba..ff17fa1c97 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -125,9 +125,13 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate 代码必须面向 port contract,而不是新增对 coordinator 或 manager 的直接依赖。 - Agent runtime 必须通过 ports/providers 依赖 service 行为,不要依赖 concrete 的重集成 crate。 -- 最新主干已把 subagent 可见性做成 mode-scoped registry 行为。迁移 agent registry 或 - subagent definitions 前,必须先保留 mode visibility、hidden/custom/review 分组和 desktop - subagent API 等价测试;在此之前它们仍属于 `bitfun-core` product runtime assembly。 +- 最新主干已把 subagent 可见性做成 mode-scoped registry 行为,并且 CLI `/subagents` + 管理入口也复用该查询 / override 语义;后续又新增 `Multitask` mode、内置 + `GeneralPurpose` subagent 和 `Task.run_in_background` 后台结果投递。迁移 agent + registry、subagent definitions 或 agent scheduler 前,必须先保留 mode visibility、 + hidden/custom/review 分组、desktop subagent API、CLI mode-aware list/config、 + background result running-turn injection 与 idle-session follow-up turn 等价测试;在此之前它们仍属于 + `bitfun-core` product runtime assembly 与各 app surface adapter 的组合边界。 - DeepResearch 现在包含 citation renumber post-turn hook。迁移 agent runtime 或 prompt/report 处理前,必须保留 `report.md` / `citations.md` / `display_map.json` 的 deterministic post-processing 行为; 在此之前该 hook 仍属于 `bitfun-core` agent runtime assembly。 @@ -142,6 +146,10 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate - ACP startup timeout 和 operation diff fallback 属于 ACP/Web product surface 行为;后续只能通过 stable contract 共享事实,不得把 ACP timeout、tool diff fallback 或 Web diff rendering 下沉到 core-types、runtime-ports、agent-tools 等 contract crate。 +- 最新 Web 启动优化把 startup trace、deferred background scheduler、narrow tool initializer + 与历史会话 hydrate 放在 web app / Flow Chat surface。后续不能为了“共享启动能力”把 + `startupTrace`、`backgroundTaskScheduler`、history hydration 或 tool warmup 下沉到 core contract + crate;只能通过 product checks 证明 app 仍可组装。 - 最新 CLI 重构新增大量 TUI、theme、selector、dialog 和 chat-state 代码,但仍位于 `src/apps/cli`。后续 core decomposition 只能通过产品 check 验证 CLI 仍可组装,不应把 CLI presentation 依赖迁入 core-types、runtime-ports 或 agent-tools。 @@ -156,7 +164,9 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate package.json storage-shape helper、lifecycle / revision helper、host routing / allowlist helper、 customization metadata / permission diff 等纯决策 / 解析逻辑;实际 runtime detection、worker pool、 storage IO、PathManager、进程执行、host dispatch 执行、customization draft 存储 / 应用与 builtin - asset seeding 仍留在 core product runtime。 + asset seeding 仍留在 core product runtime。最新内置 PR Review MiniApp 依赖 core + `BuiltinApp` seed、content hash、install marker 与 customized update metadata;这些不是 + `product-domains` runtime owner 已迁移的证据。 - `product-domains` 可以先定义 MiniApp runtime/storage 与 function-agent Git/AI 的 port contract,并承载 function-agent 的纯 prompt / AI response parsing policy;core-owned adapter 只能在不改变执行路径的前提下委托现有 service,并先补等价测试。IO/进程/AI/Git 执行 owner diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 9f27ac36e8..6947730be0 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1648,16 +1648,18 @@ P2 后产品表面契约轨道(contract-only): - `default = []` 必须是单独 PR,且只在所有产品 crate 显式启用完整 runtime 后评估。 - 不允许把 facade 变成新的业务实现聚合。 -**P3 进入条件与最新主干补充(2026-05-15):** +**P3 进入条件与最新主干补充(2026-05-18):** - P3 只能在 P2 剩余迁移闭环后启动:重 service 迁移、`ToolUseContext` / tool exposure / manifest / `GetToolSpec` / concrete tool implementation 迁移、product registry / manifest / provider assembly、miniapp/function-agent 运行逻辑迁移都必须先完成或显式保留为 core-owned runtime;generic registry/provider container 已在 PR4 中完成低风险外移。 - 最近 `origin/main` 的 Deep Review 变更增加了 context profile、evidence ledger、capacity/cost/queue 控制、`deep_review_run_manifest` / `deep_review_cache`、以及 review-team UI orchestration;最新主干还补充了 agent-stream tool-call dedupe、search remote/fallback、session rollback persistence、remote workspace compatibility guard、ACP startup timeout / operation diff fallback 和 companion typewriter。P3 facade 收敛前必须确认这些行为要么仍由 core product runtime assembly 或对应 product surface 拥有,要么已有对应 owner crate + port/provider 合约和等价测试。 -- 最新主干的 mode-scoped subagent visibility 将 `agentic::agents` 重组为 definitions / registry / visibility 边界,并扩展了 desktop subagent API 与 Review Team 可见性测试;后续若迁移 agent registry,不能只做路径 re-export,必须保留 mode 可见性过滤、hidden/custom/review 分组语义和前后端 API contract。 +- 最新主干的 mode-scoped subagent visibility 将 `agentic::agents` 重组为 definitions / registry / visibility 边界,并扩展了 desktop subagent API、CLI `/subagents` mode-aware list/config 与 Review Team 可见性测试;后续又加入 `Multitask` mode、内置 `GeneralPurpose` subagent 和后台 subagent result delivery。后续若迁移 agent registry / subagent definitions / scheduler,不能只做路径 re-export,必须保留 mode 可见性过滤、hidden/custom/review 分组语义、CLI availability override 路径、前后端 API contract、`Task.run_in_background` 的 parent metadata / workspace routing、running-turn injection 与 idle-session follow-up turn 语义。 - 最新主干的 DeepResearch citation renumber hook 是 deterministic post-turn runtime 行为,不是普通 prompt 文案;后续若迁移 agent runtime / report finalization,必须保留 `report.md`、`citations.md`、`display_map.json` 与 REJECTED citation 过滤语义。 - 最新主干的 on-demand tool spec discovery 将 `ToolExposure`、`manifest_resolver`、`GetToolSpec`、collapsed-tool prompt stub 和 `ToolUseContext.unlocked_collapsed_tools` 接入 agent prompt / execution pipeline / desktop-MCP-ACP catalog。P3 facade 收敛前必须把这些显式保留在 core product tool runtime,或先完成等价快照与 port/provider 设计后再迁移。 - 最新主干的 search result rendering / context handling 与 remote workspace compatibility guard 要求后续 `service::search`、`workspace` 或 remote runtime 迁移保留 startup restored workspace guard、remote runtime ensure、remote flashgrep FilesWithMatches fallback、preview split 和 local/remote fallback contract。 - ACP startup timeout 和 Web file-operation diff fallback 属于 product surface 行为:可以在后续 contract 中记录 operation/diff facts,但不能把 ACP timeout policy 或 Web diff rendering 迁入 core contract crate。 -- 最新主干的 CLI 重构主要新增 TUI/theme/selector/dialog/chat-state 等 app-layer 代码,后续又收敛预置 theme 并补充 desktop companion pet resize / Windows UX;这些当前没有改变 `services-integrations` 的迁移归属。后续若调整 shared crate 边界,必须继续把 `bitfun-cli`、`ratatui`、`crossterm`、`arboard`、`syntect-tui` 等 CLI-only 依赖限制在 app adapter / presentation layer,desktop / web-ui presentation 修复也不应被误判为 core service 迁移前置条件。 +- 最新主干的 Web 启动性能优化新增 startup trace、deferred background scheduler、narrow tool initializer、Monaco warmup 与历史会话非阻塞 hydrate;这些属于 web app / Flow Chat product surface,不是 core service 迁移前置条件。后续只能通过 web product checks 验证,不得把 `startupTrace`、`backgroundTaskScheduler`、history hydration 或 tool warmup 下沉到 core-types / runtime-ports / agent-tools。 +- 最新主干的 CLI 重构主要新增 TUI/theme/selector/dialog/chat-state 等 app-layer 代码,后续又收敛预置 theme、增加 mode-aware subagent management,并补充 desktop companion pet resize / Windows UX;这些当前没有改变 `services-integrations` 的迁移归属。后续若调整 shared crate 边界,必须继续把 `bitfun-cli`、`ratatui`、`crossterm`、`arboard`、`syntect-tui` 等 CLI-only 依赖限制在 app adapter / presentation layer,desktop / web-ui presentation 修复也不应被误判为 core service 迁移前置条件。 +- 最新主干的内置 PR Review MiniApp 通过 core `BuiltinApp` asset bundle、content hash、install marker、customization metadata 与 update marker seed 到用户数据目录;它可以复用 `product-domains` 的 MiniApp 纯 contract,但 builtin asset seeding / customized update runtime 仍显式 core-owned,迁移前必须保留这些行为的等价测试。 - P2 后产品表面策略要求“surface divergence, capability convergence”:CLI `/diff`、Desktop 快捷键/面板、Remote card、ACP method 可以映射到同一 capability contract,但不能为了复用把 surface command 或 UI rendering 下沉到 contract crate。 - `ToolUseContext` 的 shared-context / evidence checkpoint hook、`TaskTool` / `CodeReviewTool` 的 Deep Review capacity flow、session manifest/cache persistence、rollback persisted-turn cleanup、search fallback chain 与 stream finish/tool-call contract 不能在 P3 中只通过 re-export 消失;如果外移,需要先补 boundary contract、旧路径兼容和对应 regression。 - P3 的闭环检查应同时覆盖 Rust crate graph 与产品 runtime 行为:边界脚本只证明依赖方向,不能替代 Deep Review、MCP dynamic tools、tool manifest / `GetToolSpec`、remote connect、snapshot wrapping、miniapp/function-agent 的产品等价性验证。 @@ -1670,7 +1672,7 @@ P2 后产品表面契约轨道(contract-only): - 本次 rebase 到最新 `upstream/main` 后,PR #719 remote workspace guard、#721 companion preset、#715/#722 ACP fallback/timeout、per-mode subagent availability、DeepResearch citation renumber hook 和 search fallback/context 修复均已进入主干;它们不改变本轮 guardrail PR 的代码行为,但会把后续 workspace/search、agent registry/runtime、ACP/Web surface 与 tool runtime 外移的等价性门槛抬高。 - 质量边界:本阶段证明已拆 owner crate 不依赖回 `bitfun-core`,并新增关键语义 baseline 约束 MCP config failure / catalog replacement invalidation / dynamic manifest、tool manifest / `GetToolSpec` collapsed exposure、MiniApp storage layout adapter 等价和 remote search scan-fallback retry gate;不声明 remote connect、`ToolUseContext`、concrete tool implementation、MiniApp IO / worker runtime 或 function-agent runtime 的外移完成。 - boundary check 已扩展到 `core-types`、`runtime-ports` 和 `agent-tools` 的轻量边界,并覆盖 Cargo inline 依赖和 dependency table 依赖声明,后续不能绕过脚本把重 runtime、concrete service、platform adapter 或 CLI/TUI presentation 依赖带入这些 contract crate。 -- boundary check 现在同时锁定 latest-main owner anchor:mode-scoped subagent availability、DeepResearch citation renumber hook、remote workspace startup guard、local/remote search fallback、ACP startup timeout 和 Web operation diff fallback。后续真正迁移这些 owner 时必须先补 port/provider 或 surface contract 设计,并同步更新脚本与等价测试。 +- boundary check 现在同时锁定 latest-main owner anchor:mode-scoped subagent availability、`Multitask` / `GeneralPurpose` registration、background subagent delivery、CLI subagent management surface、DeepResearch citation renumber hook、remote workspace startup guard、local/remote search fallback、ACP startup timeout、Web startup/history hydration、Web operation diff fallback 和 built-in MiniApp seed/update path。后续真正迁移这些 owner 时必须先补 port/provider 或 surface contract 设计,并同步更新脚本与等价测试。 - boundary check 也已锁定 `bitfun-core::service::git`、`bitfun-core::service::remote_ssh::types`、remote-SSH workspace path/identity/unresolved-key helper、MiniApp storage layout、`bitfun-core::service::mcp::{tool_info,tool_name}`、`bitfun-core::service::mcp::protocol::{types,jsonrpc}`、`bitfun-core::service::mcp::config::{location,cursor_format,json_config,service_helpers}`、`bitfun-core::service::mcp::server::config`、`bitfun-core::service::mcp::auth` 和 `bitfun-core::service::announcement::types` 的旧路径 facade-only / 禁止回流状态,并禁止在 `MCPServerProcess` runtime 文件重新定义已外移的 server type/status contract、auth error classifier 和 legacy remote header fallback helper,也禁止在 remote transport 重新实现 Authorization 归一化、client capability 构造和 rmcp result mapping;本轮新增禁止 core registry 重新拥有 `IndexMap` 工具容器或 dynamic metadata map。 - 后续迁移必须拆成可独立审核的提交:先补 port/provider 设计和等价测试;`remote-connect` 完整 runtime、`ToolUseContext` / concrete tool implementation、product-domain runtime 必须一次迁移一个 owner 主题。 - concrete tool implementation 或 product registry / manifest assembly 外移必须先有工具清单和 manifest 等价测试,并保留 dynamic provider metadata;不能把注册名解析、snapshot wrapper 或 runtime restriction 行为改成隐式约定。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index a8fa105063..97746d8120 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -1157,7 +1157,7 @@ const requiredContentRules = [ message: 'missing collapsed-tool name tracking', }, { - regex: /Call `GetToolSpec` first/, + regex: /First call `GetToolSpec`|Use GetToolSpec.*first/, message: 'missing collapsed-tool prompt stub', }, ], @@ -1292,6 +1292,155 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/core/src/agentic/agents/definitions/modes/mod.rs', + reason: + 'core agent mode definitions must continue exposing Multitask mode until an approved agent-runtime migration preserves mode registration semantics', + patterns: [ + { + regex: /\bmod multitask\b/, + message: 'missing Multitask mode module', + }, + { + regex: /\bpub use multitask::MultitaskMode\b/, + message: 'missing Multitask mode export', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/definitions/subagents/mod.rs', + reason: + 'core subagent definitions must continue exposing the built-in GeneralPurpose subagent until registry ownership migration has equivalence coverage', + patterns: [ + { + regex: /\bmod general_purpose\b/, + message: 'missing GeneralPurpose subagent module', + }, + { + regex: /\bpub use general_purpose::GeneralPurposeAgent\b/, + message: 'missing GeneralPurpose subagent export', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/registry/builtin.rs', + reason: + 'core builtin registry must continue registering latest-main mode and subagent defaults until agent registry ownership migrates with API equivalence tests', + patterns: [ + { + regex: /\bbuiltin_agent_specs\(\)/, + message: 'missing builtin agent spec registration source', + }, + { + regex: /"Multitask"\s*=>\s*"auto"/, + message: 'missing Multitask default model mapping', + }, + { + regex: /"GeneralPurpose"\s*=>\s*"fast"/, + message: 'missing GeneralPurpose default model mapping', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/implementations/task_tool.rs', + reason: + 'core Task tool must continue owning background subagent launch semantics until a reviewed agent-runtime port preserves delivery behavior', + patterns: [ + { + regex: /"run_in_background"/, + message: 'missing Task run_in_background schema flag', + }, + { + regex: /\bstart_background_subagent\b/, + message: 'missing background subagent launch path', + }, + { + regex: /\bbackground_task_id\b/, + message: 'missing background task id result contract', + }, + { + regex: /Background subagent/, + message: 'missing assistant-visible background subagent acknowledgement', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/coordination/scheduler.rs', + reason: + 'core scheduler must continue owning background subagent result delivery until running-turn and idle-session routing equivalence tests exist', + patterns: [ + { + regex: /\bdeliver_background_subagent_result\b/, + message: 'missing background subagent delivery entry point', + }, + { + regex: /RoundInjectionKind::BackgroundSubagentResult/, + message: 'missing running-turn background result injection', + }, + { + regex: /RoundInjectionTarget::CurrentRunningTurn/, + message: 'missing current-turn injection target', + }, + { + regex: /DialogTriggerSource::AgentSession/, + message: 'missing idle-session agent-session follow-up turn source', + }, + ], + }, + { + path: 'src/apps/cli/src/ui/startup.rs', + reason: + 'CLI mode-aware subagent management remains an app-layer product surface until agent registry migration has CLI equivalence coverage', + patterns: [ + { + regex: /\bfn show_available_subagent_list\b/, + message: 'missing CLI subagent list surface', + }, + { + regex: /\bfn show_subagent_config_selector\b/, + message: 'missing CLI subagent config surface', + }, + { + regex: /\bget_subagents_for_query\b/, + message: 'missing CLI mode-scoped subagent query', + }, + { + regex: /\bSubagentQueryContext\b/, + message: 'missing CLI subagent query context', + }, + { + regex: /\bupdate_subagent_override\b/, + message: 'missing CLI subagent availability update path', + }, + ], + }, + { + path: 'src/apps/cli/src/ui/subagent_selector.rs', + reason: + 'CLI subagent selector presentation must remain app-layer UI while registry availability semantics stay in core', + patterns: [ + { + regex: /\bpub enum SubagentSelectorAction\b/, + message: 'missing CLI subagent selector action contract', + }, + { + regex: /\bpub fn show_list\b/, + message: 'missing CLI subagent list mode', + }, + { + regex: /\bpub fn show_config\b/, + message: 'missing CLI subagent config mode', + }, + { + regex: /\bdefault_enabled\b/, + message: 'missing CLI default availability display', + }, + { + regex: /\bfn render_subagent_line\b/, + message: 'missing CLI subagent presentation renderer', + }, + ], + }, { path: 'src/crates/core/src/agentic/agents/citation_renumber.rs', reason: @@ -1426,6 +1575,144 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/web-ui/src/main.tsx', + reason: + 'web startup scheduling and trace orchestration remain web product-surface behavior, not core contract runtime', + patterns: [ + { + regex: /\bstartupTrace\b/, + message: 'missing web startup trace surface', + }, + { + regex: /\bbackgroundTaskScheduler\b/, + message: 'missing deferred startup scheduler surface', + }, + { + regex: /\binitializeAllTools\b/, + message: 'missing narrow tool-startup entry integration', + }, + { + regex: /\bafter_render_start\b/, + message: 'missing post-render startup phase', + }, + ], + }, + { + path: 'src/web-ui/src/shared/utils/startupTrace.ts', + reason: + 'web startup trace classification and redaction remain web infrastructure behavior until a telemetry contract is reviewed', + patterns: [ + { + regex: /\bfunction sanitizeTraceData\b/, + message: 'missing startup trace sanitization', + }, + { + regex: /\bexport function isRemoteTraceRequest\b/, + message: 'missing remote request classifier', + }, + { + regex: /\brecordApiCall\b/, + message: 'missing startup API-call trace recorder', + }, + { + regex: /\bflushSummary\b/, + message: 'missing bounded startup summary flush', + }, + { + regex: /\bmarkPhaseAfterAnimationFrames\b/, + message: 'missing frame-delayed startup marker', + }, + ], + }, + { + path: 'src/web-ui/src/shared/utils/backgroundTaskScheduler.ts', + reason: + 'web background startup scheduling remains web infrastructure behavior and must preserve dedupe/cancel semantics', + patterns: [ + { + regex: /\bexport class BackgroundTaskScheduler\b/, + message: 'missing background task scheduler', + }, + { + regex: /\binFlightKey\b/, + message: 'missing in-flight dedupe key', + }, + { + regex: /\bAbortController\b/, + message: 'missing cancellation controller', + }, + { + regex: /\bBackgroundTaskCancelledError\b/, + message: 'missing cancellation error contract', + }, + { + regex: /\bcancelIdle\b/, + message: 'missing idle callback cancellation', + }, + ], + }, + { + path: 'src/web-ui/src/tools/initializeTools.ts', + reason: + 'web tool startup must stay behind a narrow app-layer entry instead of importing product tools through shared contracts', + patterns: [ + { + regex: /\bexport async function initializeAllTools\b/, + message: 'missing narrow tool startup entry', + }, + { + regex: /\binitializeLsp\b/, + message: 'missing LSP startup initializer call', + }, + { + regex: /\binitializeGit\b/, + message: 'missing Git startup initializer call', + }, + { + regex: /does not import every tool/, + message: 'missing narrow startup import guard', + }, + ], + }, + { + path: 'src/web-ui/src/tools/editor/services/MonacoStartupWarmup.ts', + reason: + 'Monaco startup warmup remains a deferred web-app optimization, not a core runtime dependency', + patterns: [ + { + regex: /\bexport function scheduleMonacoStartupWarmup\b/, + message: 'missing deferred Monaco warmup entry', + }, + { + regex: /\bbackgroundTaskScheduler\b/, + message: 'missing background scheduler integration', + }, + { + regex: /startup:monaco-warmup/, + message: 'missing Monaco warmup dedupe key', + }, + ], + }, + { + path: 'src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts', + reason: + 'flow-chat history hydration remains web startup/product-surface behavior until a UI equivalence plan exists', + patterns: [ + { + regex: /\bhistorical_session_hydrate_request\b/, + message: 'missing historical session hydrate trace', + }, + { + regex: /Load history in the background/, + message: 'missing non-blocking history load contract', + }, + { + regex: /\bhistoryState: 'ready'/, + message: 'missing history-ready state contract', + }, + ], + }, { path: 'src/crates/core/src/miniapp/storage.rs', reason: @@ -1437,6 +1724,64 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/core/src/miniapp/builtin/mod.rs', + reason: + 'core must continue owning built-in MiniApp asset seeding and update markers until builtin asset runtime migration is reviewed', + patterns: [ + { + regex: /id: "builtin-pr-review"/, + message: 'missing built-in PR Review MiniApp anchor', + }, + { + regex: /\bstruct BuiltinInstallMarker\b/, + message: 'missing built-in MiniApp install marker', + }, + { + regex: /\bfn builtin_content_hash\b/, + message: 'missing built-in MiniApp content hash policy', + }, + { + regex: /\bfn should_seed_builtin_app\b/, + message: 'missing built-in MiniApp seed decision policy', + }, + { + regex: /\bload_customization_metadata\b/, + message: 'missing customized built-in preservation path', + }, + { + regex: /\bavailable_builtin_update\b/, + message: 'missing customized built-in update metadata path', + }, + ], + }, + { + path: 'src/crates/core/src/miniapp/host_dispatch.rs', + reason: + 'core must continue owning MiniApp host-dispatch execution until host/runtime migration is reviewed', + patterns: [ + { + regex: /\bpub async fn dispatch_host\b/, + message: 'missing MiniApp host dispatch entry', + }, + { + regex: /\basync fn dispatch_fs\b/, + message: 'missing MiniApp fs host dispatch', + }, + { + regex: /\basync fn dispatch_shell\b/, + message: 'missing MiniApp shell host dispatch', + }, + { + regex: /\bcommand_basename_allowed\b/, + message: 'missing MiniApp shell allowlist policy use', + }, + { + regex: /\bhost_allowed_by_allowlist\b/, + message: 'missing MiniApp net allowlist policy use', + }, + ], + }, { path: 'src/crates/services-integrations/src/remote_ssh/paths.rs', reason: @@ -2038,7 +2383,7 @@ function runManifestParserSelfTest() { }, { path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', - contracts: ['resolve_tool_manifest', 'GET_TOOL_SPEC_TOOL_NAME', 'ToolExposure'], + contracts: ['resolve_tool_manifest', 'GET_TOOL_SPEC_TOOL_NAME', 'ToolExposure', 'First call `GetToolSpec`'], }, { path: 'src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs', @@ -2064,6 +2409,51 @@ function runManifestParserSelfTest() { path: 'src/crates/core/src/agentic/agents/registry/types.rs', contracts: ['SubagentQueryContext', 'SubagentListScope', 'default_enabled', 'effective_enabled', 'SubagentStateReason'], }, + { + path: 'src/crates/core/src/agentic/agents/definitions/modes/mod.rs', + contracts: ['mod multitask', 'MultitaskMode'], + }, + { + path: 'src/crates/core/src/agentic/agents/definitions/subagents/mod.rs', + contracts: ['mod general_purpose', 'GeneralPurposeAgent'], + }, + { + path: 'src/crates/core/src/agentic/agents/registry/builtin.rs', + contracts: ['builtin_agent_specs', 'Multitask', 'GeneralPurpose'], + }, + { + path: 'src/crates/core/src/agentic/tools/implementations/task_tool.rs', + contracts: ['run_in_background', 'start_background_subagent', 'background_task_id', 'Background subagent'], + }, + { + path: 'src/crates/core/src/agentic/coordination/scheduler.rs', + contracts: [ + 'deliver_background_subagent_result', + 'BackgroundSubagentResult', + 'CurrentRunningTurn', + 'AgentSession', + ], + }, + { + path: 'src/apps/cli/src/ui/startup.rs', + contracts: [ + 'show_available_subagent_list', + 'show_subagent_config_selector', + 'get_subagents_for_query', + 'SubagentQueryContext', + 'update_subagent_override', + ], + }, + { + path: 'src/apps/cli/src/ui/subagent_selector.rs', + contracts: [ + 'SubagentSelectorAction', + 'show_list', + 'show_config', + 'default_enabled', + 'render_subagent_line', + ], + }, { path: 'src/crates/core/src/agentic/agents/citation_renumber.rs', contracts: ['run_for_session_workspace', 'try_renumber_research_report', 'display_map', 'REJECTED'], @@ -2088,10 +2478,67 @@ function runManifestParserSelfTest() { path: 'src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx', contracts: ['openLocalDiff', 'snapshotAPI\\.getOperationDiff', 'Snapshot diff unavailable', 'localDiffContent'], }, + { + path: 'src/web-ui/src/main.tsx', + contracts: ['startupTrace', 'backgroundTaskScheduler', 'initializeAllTools', 'after_render_start'], + }, + { + path: 'src/web-ui/src/shared/utils/startupTrace.ts', + contracts: [ + 'sanitizeTraceData', + 'isRemoteTraceRequest', + 'recordApiCall', + 'flushSummary', + 'markPhaseAfterAnimationFrames', + ], + }, + { + path: 'src/web-ui/src/shared/utils/backgroundTaskScheduler.ts', + contracts: [ + 'BackgroundTaskScheduler', + 'inFlightKey', + 'AbortController', + 'BackgroundTaskCancelledError', + 'cancelIdle', + ], + }, + { + path: 'src/web-ui/src/tools/initializeTools.ts', + contracts: ['initializeAllTools', 'initializeLsp', 'initializeGit', 'does not import every tool'], + }, + { + path: 'src/web-ui/src/tools/editor/services/MonacoStartupWarmup.ts', + contracts: ['scheduleMonacoStartupWarmup', 'backgroundTaskScheduler', 'startup:monaco-warmup'], + }, + { + path: 'src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts', + contracts: ['historical_session_hydrate_request', 'Load history in the background', "historyState: 'ready'"], + }, { path: 'src/crates/core/src/miniapp/storage.rs', contracts: ['MiniAppStoragePort'], }, + { + path: 'src/crates/core/src/miniapp/builtin/mod.rs', + contracts: [ + 'builtin-pr-review', + 'BuiltinInstallMarker', + 'builtin_content_hash', + 'should_seed_builtin_app', + 'load_customization_metadata', + 'available_builtin_update', + ], + }, + { + path: 'src/crates/core/src/miniapp/host_dispatch.rs', + contracts: [ + 'dispatch_host', + 'dispatch_fs', + 'dispatch_shell', + 'command_basename_allowed', + 'host_allowed_by_allowlist', + ], + }, { path: 'src/crates/core/src/miniapp/js_worker_pool.rs', contracts: ['MiniAppRuntimePort'], From deb75ead3b5a62f9312af8164b1345f621019e8a Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 15 May 2026 16:15:21 +0800 Subject: [PATCH 19/76] fix: reuse local ACP config for remote workspaces --- src/crates/acp/src/client/builtin_clients.rs | 72 --------- src/crates/acp/src/client/manager.rs | 140 ++++++++++++------ src/crates/acp/src/client/mod.rs | 1 + .../acp/src/client/remote_capability_store.rs | 8 + src/crates/acp/src/client/remote_shell.rs | 84 +++++++++++ src/crates/acp/src/client/requirements.rs | 44 ++++-- src/web-ui/src/app/App.tsx | 9 +- .../workspaceAcpMenuClients.test.ts | 86 ++++++++++- .../workspaces/workspaceAcpMenuClients.ts | 68 +++++---- .../components/AcpAgentsConfig.test.tsx | 5 +- .../config/components/AcpAgentsConfig.tsx | 10 +- .../locales/en-US/settings/acp-agents.json | 2 +- .../locales/zh-CN/settings/acp-agents.json | 2 +- .../locales/zh-TW/settings/acp-agents.json | 2 +- 14 files changed, 355 insertions(+), 178 deletions(-) create mode 100644 src/crates/acp/src/client/remote_shell.rs diff --git a/src/crates/acp/src/client/builtin_clients.rs b/src/crates/acp/src/client/builtin_clients.rs index 4a4cea5395..3751146212 100644 --- a/src/crates/acp/src/client/builtin_clients.rs +++ b/src/crates/acp/src/client/builtin_clients.rs @@ -54,10 +54,6 @@ pub(crate) fn builtin_acp_client_preset( .find(|preset| preset.id == client_id) } -pub(crate) fn supported_remote_acp_clients() -> String { - builtin_client_ids().collect::>().join(", ") -} - pub(crate) fn default_config_for_builtin_client(client_id: &str) -> Option { let preset = builtin_acp_client_preset(client_id)?; Some(AcpClientConfig { @@ -75,65 +71,10 @@ pub(crate) fn default_config_for_builtin_client(client_id: &str) -> Option Option { - let preset = builtin_acp_client_preset(client_id)?; - Some(render_shell_command(preset.command, preset.args)) -} - -pub(crate) fn remote_command_for_builtin_client_in_workspace( - client_id: &str, - workspace_path: &str, -) -> Option { - let command = remote_command_for_builtin_client(client_id)?; - let workspace_path = workspace_path.trim(); - if workspace_path.is_empty() { - return Some(command); - } - Some(format!( - "cd {} && {}", - shell_escape(workspace_path), - command - )) -} - -fn render_shell_command(command: &str, args: &[&str]) -> String { - std::iter::once(command) - .chain(args.iter().copied()) - .map(shell_escape) - .collect::>() - .join(" ") -} - -fn shell_escape(value: &str) -> String { - if value.chars().all(|ch| { - ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | ':' | '=' | '@') - }) { - value.to_string() - } else { - format!("'{}'", value.replace('\'', "'\\''")) - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn builds_remote_builtin_command_for_opencode() { - assert_eq!( - remote_command_for_builtin_client("opencode").as_deref(), - Some("opencode acp") - ); - } - - #[test] - fn builds_remote_builtin_command_for_npx_adapter() { - assert_eq!( - remote_command_for_builtin_client("codex").as_deref(), - Some("npx --yes @zed-industries/codex-acp@latest") - ); - } - #[test] fn returns_default_config_for_builtin_client() { let config = default_config_for_builtin_client("claude-code").expect("builtin config"); @@ -144,17 +85,4 @@ mod tests { vec!["--yes", "@zed-industries/claude-code-acp@latest"] ); } - - #[test] - fn shell_escape_quotes_spaces() { - assert_eq!(shell_escape("hello world"), "'hello world'"); - } - - #[test] - fn builds_remote_builtin_command_in_workspace() { - assert_eq!( - remote_command_for_builtin_client_in_workspace("opencode", "/tmp/my repo").as_deref(), - Some("cd '/tmp/my repo' && opencode acp") - ); - } } diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index e3c9be926d..0e9e12ee60 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -33,17 +33,14 @@ use tokio::process::{Child, Command}; use tokio::sync::{oneshot, Mutex, RwLock}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; -use super::builtin_clients::{ - builtin_acp_client_preset, builtin_client_ids, default_config_for_builtin_client, - remote_command_for_builtin_client, remote_command_for_builtin_client_in_workspace, - supported_remote_acp_clients, -}; +use super::builtin_clients::{builtin_client_ids, default_config_for_builtin_client}; use super::config::{ AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, AcpClientRequirementProbe, AcpClientStatus, RemoteAcpClientRequirementSnapshot, }; use super::remote_capability_store::RemoteAcpCapabilityStore; use super::remote_session::{preferred_resume_strategies, AcpRemoteSessionStrategy}; +use super::remote_shell::{remote_user_shell_command, render_remote_env_assignments, shell_escape}; use super::requirements::{ acp_requirement_spec, apply_command_environment, install_npm_cli_package, predownload_npm_adapter, probe_executable, probe_npm_adapter, probe_remote_executable, @@ -362,21 +359,35 @@ impl AcpClientService { BitFunError::service("SSH manager is not available for remote ACP".to_string()) })?; - let mut ids = builtin_client_ids() - .map(ToString::to_string) - .collect::>(); + let configs = self.load_configs().await?; + let mut ids = configs.keys().cloned().collect::>(); + for id in builtin_client_ids() { + if !ids.iter().any(|candidate| candidate == id) { + ids.push(id.to_string()); + } + } ids.sort(); let mut probes = Vec::with_capacity(ids.len()); for id in ids { - let spec = acp_requirement_spec(&id, None); - let tool = - probe_remote_executable(&ssh_manager, remote_connection_id, spec.tool_command) - .await; + let config = configs.get(&id); + let spec = acp_requirement_spec(&id, config); + let tool = probe_remote_executable( + &ssh_manager, + remote_connection_id, + spec.tool_command, + config.map(|config| &config.env), + ) + .await; let adapter = match spec.adapter { Some(adapter) => Some( - probe_remote_npx_adapter(&ssh_manager, remote_connection_id, adapter.package) - .await, + probe_remote_npx_adapter( + &ssh_manager, + remote_connection_id, + adapter.package, + config.map(|config| &config.env), + ) + .await, ), None => None, }; @@ -769,6 +780,7 @@ impl AcpClientService { self.config_service .set_config(CONFIG_PATH, canonical_value) .await?; + self.remote_capability_store.clear().await?; self.initialize_all().await } @@ -1533,7 +1545,7 @@ impl AcpClientService { )> { match remote_connection_id { Some(remote_connection_id) => self - .start_remote_transport(client_id, workspace_path, remote_connection_id) + .start_remote_transport(client_id, config, workspace_path, remote_connection_id) .await .map(|transport| (transport, None)), None => self @@ -1546,22 +1558,11 @@ impl AcpClientService { async fn start_remote_transport( &self, client_id: &str, + config: &AcpClientConfig, workspace_path: Option<&str>, remote_connection_id: &str, ) -> BitFunResult> { - let command = workspace_path - .map(str::trim) - .filter(|value| !value.is_empty()) - .and_then(|workspace_path| { - remote_command_for_builtin_client_in_workspace(client_id, workspace_path) - }) - .or_else(|| remote_command_for_builtin_client(client_id)) - .ok_or_else(|| { - BitFunError::config(format!( - "Remote ACP currently supports only built-in clients: {}", - supported_remote_acp_clients() - )) - })?; + let command = render_remote_client_command(config, workspace_path)?; let remote_manager = get_remote_workspace_manager().ok_or_else(|| { BitFunError::service("Remote workspace manager is not initialized".to_string()) })?; @@ -1595,16 +1596,15 @@ impl AcpClientService { .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); - let remote_builtin = remote_connection_id - .as_deref() - .and_then(|_| builtin_acp_client_preset(client_id)) - .is_some(); + let is_remote = remote_connection_id.is_some(); + let mut used_remote_default = false; let mut config = self .load_configs() .await? .remove(client_id) .or_else(|| { - if remote_connection_id.is_some() { + if is_remote { + used_remote_default = true; default_config_for_builtin_client(client_id) } else { None @@ -1612,9 +1612,15 @@ impl AcpClientService { }) .ok_or_else(|| BitFunError::NotFound(format!("ACP client not found: {}", client_id)))?; - if remote_builtin { + if used_remote_default { config.enabled = true; } + if config.command.trim().is_empty() { + return Err(BitFunError::config(format!( + "ACP client command is empty: {}", + client_id + ))); + } if !config.enabled { return Err(BitFunError::config(format!( "ACP client is disabled: {}", @@ -1622,7 +1628,7 @@ impl AcpClientService { ))); } - if remote_connection_id.is_some() { + if is_remote { ensure_remote_client_supported(client_id, workspace_path)?; } @@ -1634,7 +1640,7 @@ impl AcpClientService { } fn ensure_remote_client_supported( - client_id: &str, + _client_id: &str, workspace_path: Option<&str>, ) -> BitFunResult<()> { if workspace_path @@ -1646,14 +1652,41 @@ fn ensure_remote_client_supported( )); } - if builtin_acp_client_preset(client_id).is_none() { - return Err(BitFunError::config(format!( - "Remote ACP currently supports only built-in clients: {}", - supported_remote_acp_clients() - ))); + Ok(()) +} + +fn render_remote_client_command( + config: &AcpClientConfig, + workspace_path: Option<&str>, +) -> BitFunResult { + let command = config.command.trim(); + if command.is_empty() { + return Err(BitFunError::config( + "ACP client command is empty".to_string(), + )); } - Ok(()) + let mut command_parts = Vec::new(); + command_parts.push(shell_escape(command)); + command_parts.extend(config.args.iter().map(|arg| shell_escape(arg))); + + let mut parts = Vec::new(); + parts.push("exec".to_string()); + let env_assignments = render_remote_env_assignments(&config.env); + if !env_assignments.is_empty() { + parts.push("env".to_string()); + parts.extend(env_assignments); + } + parts.extend(command_parts); + + let command = parts.join(" "); + let workspace_path = workspace_path.map(str::trim).unwrap_or_default(); + let body = if workspace_path.is_empty() { + command + } else { + format!("cd {} && {}", shell_escape(workspace_path), command) + }; + Ok(remote_user_shell_command(&body)) } fn current_unix_timestamp_ms() -> u64 { @@ -2065,4 +2098,27 @@ mod tests { "ACP startup timed out: client 'codex' exceeded 60s during initialize and was terminated. Please try again after the client is ready." ); } + + #[test] + fn renders_remote_client_command_from_config() { + let config = AcpClientConfig { + name: Some("Custom".to_string()), + command: "custom-acp".to_string(), + args: vec!["--stdio".to_string(), "with space".to_string()], + env: HashMap::from([ + ("PATH".to_string(), "/remote/bin:/usr/bin".to_string()), + ("INVALID-NAME".to_string(), "ignored".to_string()), + ]), + enabled: true, + readonly: false, + permission_mode: AcpClientPermissionMode::Ask, + }; + + let command = render_remote_client_command(&config, Some("/srv/my repo")).expect("command"); + assert!(command.starts_with("bash -lc ")); + assert!(command.contains(".nvm/nvm.sh")); + assert!(command.contains( + "cd '\\''/srv/my repo'\\'' && exec env PATH=/remote/bin:/usr/bin custom-acp --stdio '\\''with space'\\''" + )); + } } diff --git a/src/crates/acp/src/client/mod.rs b/src/crates/acp/src/client/mod.rs index af6a6891f9..58959c3e9d 100644 --- a/src/crates/acp/src/client/mod.rs +++ b/src/crates/acp/src/client/mod.rs @@ -3,6 +3,7 @@ mod config; mod manager; mod remote_capability_store; mod remote_session; +mod remote_shell; mod requirements; mod session_options; mod session_persistence; diff --git a/src/crates/acp/src/client/remote_capability_store.rs b/src/crates/acp/src/client/remote_capability_store.rs index 759b71c419..2c9d894d3c 100644 --- a/src/crates/acp/src/client/remote_capability_store.rs +++ b/src/crates/acp/src/client/remote_capability_store.rs @@ -69,6 +69,14 @@ impl RemoteAcpCapabilityStore { self.persist(entries).await } + pub(crate) async fn clear(&self) -> BitFunResult<()> { + { + let mut guard = self.snapshots.write().await; + guard.clear(); + } + self.persist(Vec::new()).await + } + async fn persist( &self, snapshots: Vec, diff --git a/src/crates/acp/src/client/remote_shell.rs b/src/crates/acp/src/client/remote_shell.rs new file mode 100644 index 0000000000..c3c1162bd5 --- /dev/null +++ b/src/crates/acp/src/client/remote_shell.rs @@ -0,0 +1,84 @@ +use std::collections::HashMap; + +const REMOTE_ENV_BOOTSTRAP: &str = r#"__bitfun_home="${HOME:-}" +if [ -n "$__bitfun_home" ]; then + if [ -s "$__bitfun_home/.nvm/nvm.sh" ]; then + . "$__bitfun_home/.nvm/nvm.sh" >/dev/null 2>&1 + fi + for __bitfun_dir in "$__bitfun_home/.local/bin" "$__bitfun_home/.cargo/bin" "$__bitfun_home/.npm-global/bin"; do + if [ -d "$__bitfun_dir" ]; then + PATH="$__bitfun_dir:$PATH" + fi + done +fi +export PATH"#; + +pub(super) fn remote_user_shell_command(body: &str) -> String { + format!( + "bash -lc {}", + shell_escape(&format!("{REMOTE_ENV_BOOTSTRAP}\n{body}")) + ) +} + +pub(super) fn render_remote_env_assignments(env: &HashMap) -> Vec { + let mut entries = env + .iter() + .filter(|(key, _)| is_shell_env_key(key)) + .collect::>(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries + .into_iter() + .map(|(key, value)| format!("{key}={}", shell_escape(value))) + .collect() +} + +pub(super) fn shell_escape(value: &str) -> String { + if value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | ':' | '=' | '@') + }) { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + +fn is_shell_env_key(key: &str) -> bool { + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_env_assignments_use_valid_keys_in_stable_order() { + let env = HashMap::from([ + ("INVALID-NAME".to_string(), "ignored".to_string()), + ("PATH".to_string(), "/remote/bin:/usr/bin".to_string()), + ("ACP_HOME".to_string(), "/tmp/acp home".to_string()), + ]); + + assert_eq!( + render_remote_env_assignments(&env), + vec![ + "ACP_HOME='/tmp/acp home'".to_string(), + "PATH=/remote/bin:/usr/bin".to_string(), + ] + ); + } + + #[test] + fn remote_user_shell_command_loads_common_user_toolchains() { + let command = remote_user_shell_command("command -v codex"); + + assert!(command.starts_with("bash -lc ")); + assert!(command.contains(".nvm/nvm.sh")); + assert!(command.contains(".local/bin")); + assert!(command.contains("command -v codex")); + } +} diff --git a/src/crates/acp/src/client/requirements.rs b/src/crates/acp/src/client/requirements.rs index ca87ad5562..aedae55f22 100644 --- a/src/crates/acp/src/client/requirements.rs +++ b/src/crates/acp/src/client/requirements.rs @@ -10,6 +10,7 @@ use tokio::process::Command; use super::builtin_clients::builtin_acp_client_preset; use super::config::{AcpClientConfig, AcpRequirementProbeItem}; +use super::remote_shell::{remote_user_shell_command, render_remote_env_assignments, shell_escape}; const REQUIREMENT_PROBE_TIMEOUT: Duration = Duration::from_secs(3); const ADAPTER_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120); @@ -156,6 +157,7 @@ pub(crate) async fn probe_remote_executable( ssh_manager: &SSHConnectionManager, connection_id: &str, command: &str, + env: Option<&HashMap>, ) -> AcpRequirementProbeItem { let mut item = AcpRequirementProbeItem { name: command.to_string(), @@ -165,7 +167,9 @@ pub(crate) async fn probe_remote_executable( error: None, }; - let resolve_command = format!("command -v {}", shell_escape(command)); + let env_prefix = render_remote_env_prefix(env); + let resolve_command = + remote_user_shell_command(&format!("{env_prefix}command -v {}", shell_escape(command))); match ssh_manager .execute_command(connection_id, &resolve_command) .await @@ -191,7 +195,8 @@ pub(crate) async fn probe_remote_executable( } if item.installed { - let version_command = format!("{} --version", shell_escape(command)); + let version_command = + remote_user_shell_command(&format!("{env_prefix}{} --version", shell_escape(command))); match ssh_manager .execute_command(connection_id, &version_command) .await @@ -216,6 +221,7 @@ pub(crate) async fn probe_remote_npx_adapter( ssh_manager: &SSHConnectionManager, connection_id: &str, package: &str, + env: Option<&HashMap>, ) -> AcpRequirementProbeItem { let mut item = AcpRequirementProbeItem { name: package.to_string(), @@ -225,9 +231,10 @@ pub(crate) async fn probe_remote_npx_adapter( error: None, }; - let resolve_command = "command -v npx"; + let env_prefix = render_remote_env_prefix(env); + let resolve_command = remote_user_shell_command(&format!("{env_prefix}command -v npx")); match ssh_manager - .execute_command(connection_id, resolve_command) + .execute_command(connection_id, &resolve_command) .await { Ok((stdout, _stderr, exit_code)) if exit_code == 0 => { @@ -403,14 +410,15 @@ fn truncate_error(value: String) -> String { format!("{}...", value.chars().take(MAX_LEN).collect::()) } -fn shell_escape(value: &str) -> String { - if value.chars().all(|ch| { - ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | ':' | '=' | '@') - }) { - value.to_string() - } else { - format!("'{}'", value.replace('\'', "'\\''")) +fn render_remote_env_prefix(env: Option<&HashMap>) -> String { + let Some(env) = env else { + return String::new(); + }; + let assignments = render_remote_env_assignments(env); + if assignments.is_empty() { + return String::new(); } + format!("{} ", assignments.join(" ")) } fn find_executable(command: &str) -> Option { @@ -595,4 +603,18 @@ mod tests { let _ = std::fs::remove_dir_all(&test_dir); assert_eq!(found, Some(executable)); } + + #[test] + fn remote_env_prefix_uses_valid_keys_in_stable_order() { + let env = HashMap::from([ + ("INVALID-NAME".to_string(), "ignored".to_string()), + ("PATH".to_string(), "/remote/bin:/usr/bin".to_string()), + ("ACP_HOME".to_string(), "/tmp/acp home".to_string()), + ]); + + assert_eq!( + render_remote_env_prefix(Some(&env)), + "ACP_HOME='/tmp/acp home' PATH=/remote/bin:/usr/bin " + ); + } } diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index ae96de50ad..65fa605826 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -167,8 +167,13 @@ function App() { const { ACPClientAPI } = await import('../infrastructure/api/service-api/ACPClientAPI'); await ACPClientAPI.initializeClients(); log.debug('ACP clients initialized'); - // Requirement probes execute third-party CLIs such as `opencode --version`. - // Keep startup side-effect free; settings and ACP session creation can probe on demand. + void ACPClientAPI.probeClientRequirements() + .then(() => { + log.debug('ACP client requirements probed'); + }) + .catch((error) => { + log.warn('Failed to probe ACP client requirements during startup', error); + }); } catch (error) { log.error('Failed to initialize ACP clients', error); } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts index 2f9e79e2f7..3a26fdb7be 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts @@ -42,10 +42,11 @@ describe('loadWorkspaceAcpMenuClients', () => { expect(clients.map(item => item.id)).toEqual(['opencode']); }); - it('uses built-in ACP presets for remote workspaces without requiring local config', async () => { + it('uses local ACP config for remote workspaces and filters by remote requirements', async () => { vi.mocked(ACPClientAPI.getClients).mockResolvedValue([ - client('claude-code', false), + client('claude-code', true), client('custom-remote-only', true), + client('disabled-client', false), ]); vi.mocked(ACPClientAPI.probeClientRequirements).mockResolvedValue([ { @@ -62,9 +63,14 @@ describe('loadWorkspaceAcpMenuClients', () => { notes: ['claude is not available on remote PATH'], }, { - id: 'codex', - tool: { name: 'codex', installed: true }, - adapter: { name: '@zed-industries/codex-acp', installed: true }, + id: 'custom-remote-only', + tool: { name: 'custom-acp', installed: true }, + runnable: true, + notes: [], + }, + { + id: 'disabled-client', + tool: { name: 'disabled-client', installed: true }, runnable: true, notes: [], }, @@ -78,7 +84,75 @@ describe('loadWorkspaceAcpMenuClients', () => { expect(ACPClientAPI.probeClientRequirements).toHaveBeenCalledWith({ remoteConnectionId: 'ssh-1', }); + expect(clients.map(item => item.id)).toEqual(['custom-remote-only']); + }); + + it('refreshes remote requirements when no cached remote snapshot exists', async () => { + vi.mocked(ACPClientAPI.getClients).mockResolvedValue([ + client('custom-remote-only', true), + ]); + vi.mocked(ACPClientAPI.probeClientRequirements) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: 'custom-remote-only', + tool: { name: 'custom-acp', installed: true }, + runnable: true, + notes: [], + }, + ]); + + const clients = await loadWorkspaceAcpMenuClients({ + remoteWorkspace: true, + remoteConnectionId: 'ssh-1', + }); + + expect(ACPClientAPI.probeClientRequirements).toHaveBeenNthCalledWith(1, { + remoteConnectionId: 'ssh-1', + }); + expect(ACPClientAPI.probeClientRequirements).toHaveBeenNthCalledWith(2, { + remoteConnectionId: 'ssh-1', + force: true, + }); + expect(clients.map(item => item.id)).toEqual(['custom-remote-only']); + }); + + it('refreshes once when cached remote requirements hide every enabled local ACP client', async () => { + vi.mocked(ACPClientAPI.getClients).mockResolvedValue([ + client('codex', true), + ]); + vi.mocked(ACPClientAPI.probeClientRequirements) + .mockResolvedValueOnce([ + { + id: 'codex', + tool: { name: 'codex', installed: true }, + adapter: { name: '@zed-industries/codex-acp', installed: false }, + runnable: false, + notes: ['npx is not available on remote PATH'], + }, + ]) + .mockResolvedValueOnce([ + { + id: 'codex', + tool: { name: 'codex', installed: true }, + adapter: { name: '@zed-industries/codex-acp', installed: true }, + runnable: true, + notes: [], + }, + ]); + + const clients = await loadWorkspaceAcpMenuClients({ + remoteWorkspace: true, + remoteConnectionId: 'ssh-stale', + }); + + expect(ACPClientAPI.probeClientRequirements).toHaveBeenNthCalledWith(1, { + remoteConnectionId: 'ssh-stale', + }); + expect(ACPClientAPI.probeClientRequirements).toHaveBeenNthCalledWith(2, { + remoteConnectionId: 'ssh-stale', + force: true, + }); expect(clients.map(item => item.id)).toEqual(['codex']); - expect(clients[0]?.enabled).toBe(true); }); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.ts index 19d9f6eb7b..623497078e 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.ts @@ -3,31 +3,12 @@ import { type AcpClientInfo, } from '@/infrastructure/api/service-api/ACPClientAPI'; -const REMOTE_ACP_PRESETS = [ - { id: 'opencode', name: 'opencode' }, - { id: 'claude-code', name: 'Claude Code' }, - { id: 'codex', name: 'Codex' }, -] as const; - interface LoadWorkspaceAcpMenuClientsOptions { remoteWorkspace?: boolean; remoteConnectionId?: string; } -function virtualRemoteClient(id: string, name: string): AcpClientInfo { - return { - id, - name, - command: '', - args: [], - enabled: true, - readonly: false, - permissionMode: 'ask', - status: 'configured', - toolName: `acp__${id}__prompt`, - sessionCount: 0, - }; -} +const forcedRemoteRequirementRefreshes = new Set(); export async function loadWorkspaceAcpMenuClients( options: LoadWorkspaceAcpMenuClientsOptions = {} @@ -42,24 +23,41 @@ export async function loadWorkspaceAcpMenuClients( return []; } - const probes = await ACPClientAPI.probeClientRequirements({ + const enabledClients = clients.filter(client => client.enabled); + if (enabledClients.length === 0) { + return []; + } + + let probes = await ACPClientAPI.probeClientRequirements({ remoteConnectionId: options.remoteConnectionId, }); + if (probes.length === 0) { + probes = await ACPClientAPI.probeClientRequirements({ + remoteConnectionId: options.remoteConnectionId, + force: true, + }); + } + let visibleClients = filterRunnableClients(enabledClients, probes); + if ( + visibleClients.length === 0 && + !forcedRemoteRequirementRefreshes.has(options.remoteConnectionId) + ) { + forcedRemoteRequirementRefreshes.add(options.remoteConnectionId); + probes = await ACPClientAPI.probeClientRequirements({ + remoteConnectionId: options.remoteConnectionId, + force: true, + }); + visibleClients = filterRunnableClients(enabledClients, probes); + } + return visibleClients; +} + +function filterRunnableClients( + clients: AcpClientInfo[], + probes: Awaited> +): AcpClientInfo[] { const runnableRemoteIds = new Set( probes.filter(probe => probe.runnable).map(probe => probe.id) ); - const clientsById = new Map(clients.map(client => [client.id, client])); - return REMOTE_ACP_PRESETS - .filter(({ id }) => runnableRemoteIds.has(id)) - .map(({ id, name }) => { - const configured = clientsById.get(id); - if (!configured) { - return virtualRemoteClient(id, name); - } - return { - ...configured, - name: configured.name || name, - enabled: true, - }; - }); + return clients.filter(client => runnableRemoteIds.has(client.id)); } diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx index 888adbf15c..d64538da7c 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx @@ -168,7 +168,7 @@ describe('AcpAgentsConfig', () => { vi.clearAllMocks(); }); - it('does not probe external ACP CLIs just by opening the settings tab', async () => { + it('probes requirements when opened and does not treat missing probe data as invalid config', async () => { await act(async () => { root.render(); }); @@ -179,6 +179,7 @@ describe('AcpAgentsConfig', () => { expect(loadJsonConfigMock).toHaveBeenCalledTimes(1); expect(getClientsMock).toHaveBeenCalledTimes(1); - expect(probeClientRequirementsMock).not.toHaveBeenCalled(); + expect(probeClientRequirementsMock).toHaveBeenCalledTimes(1); + expect(container.textContent).not.toContain('registry.configInvalid'); }); }); diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx index 670d44f355..8573bc3f41 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -307,7 +307,7 @@ const AcpAgentsConfig: React.FC = () => { const status = getAgentRowStatus({ configured, enabled, - runnable: probe?.runnable === true, + runnable: probe?.runnable, probePending, }); if (registryFilter === 'installed' && status !== 'enabled' && status !== 'ready') return false; @@ -336,7 +336,7 @@ const AcpAgentsConfig: React.FC = () => { const status = getAgentRowStatus({ configured, enabled, - runnable: requirementProbe?.runnable === true, + runnable: requirementProbe?.runnable, probePending, }); if (registryFilter === 'installed' && status !== 'enabled' && status !== 'ready') return false; @@ -387,7 +387,7 @@ const AcpAgentsConfig: React.FC = () => { options: { showLoading?: boolean; refreshRequirements?: boolean } = {} ) => { const showLoading = options.showLoading ?? true; - const refreshRequirements = options.refreshRequirements ?? false; + const refreshRequirements = options.refreshRequirements ?? true; try { if (showLoading) { setLoading(true); @@ -710,7 +710,7 @@ const AcpAgentsConfig: React.FC = () => { const hasConfigEntry = Boolean(config.acpClients[preset.id]); const configured = hasConfigEntry; const enabled = clientConfig.enabled; - const runnable = requirementProbe?.runnable === true; + const runnable = requirementProbe?.runnable; const status = getAgentRowStatus({ configured, enabled, runnable, probePending }); const installing = installingClientIds.has(preset.id); @@ -783,7 +783,7 @@ const AcpAgentsConfig: React.FC = () => { const requirementProbe = probesById.get(clientId); const probePending = probingRequirements && !requirementProbe; - const runnable = requirementProbe?.runnable === true; + const runnable = requirementProbe?.runnable; const status = getAgentRowStatus({ configured: true, enabled: clientConfig.enabled !== false, diff --git a/src/web-ui/src/locales/en-US/settings/acp-agents.json b/src/web-ui/src/locales/en-US/settings/acp-agents.json index 3ee10c0f0a..218dcf28da 100644 --- a/src/web-ui/src/locales/en-US/settings/acp-agents.json +++ b/src/web-ui/src/locales/en-US/settings/acp-agents.json @@ -21,7 +21,7 @@ }, "registry": { "title": "Agents", - "description": "Installed ACP agents with ready dependencies appear in the workspace action menu.", + "description": "Installed ACP agents with ready dependencies appear in the workspace action menu. Remote workspaces reuse this local config and check commands on the remote host.", "searchPlaceholder": "Search agents...", "filterLabel": "ACP agent filter", "filters": { diff --git a/src/web-ui/src/locales/zh-CN/settings/acp-agents.json b/src/web-ui/src/locales/zh-CN/settings/acp-agents.json index b3a6e5f3d2..412331ec23 100644 --- a/src/web-ui/src/locales/zh-CN/settings/acp-agents.json +++ b/src/web-ui/src/locales/zh-CN/settings/acp-agents.json @@ -21,7 +21,7 @@ }, "registry": { "title": "Agent 列表", - "description": "安装完成且依赖就绪后,ACP Agent 会出现在工作区操作菜单里。", + "description": "安装完成且依赖就绪后,ACP Agent 会出现在工作区操作菜单里。远程工作区会复用这里的本地配置,并在远端主机检测命令。", "searchPlaceholder": "搜索 Agent...", "filterLabel": "ACP Agent 筛选", "filters": { diff --git a/src/web-ui/src/locales/zh-TW/settings/acp-agents.json b/src/web-ui/src/locales/zh-TW/settings/acp-agents.json index b229dd41f0..2d983a1ad3 100644 --- a/src/web-ui/src/locales/zh-TW/settings/acp-agents.json +++ b/src/web-ui/src/locales/zh-TW/settings/acp-agents.json @@ -21,7 +21,7 @@ }, "registry": { "title": "Agent 列表", - "description": "安裝完成且依賴就緒後,ACP Agent 會出現在工作區操作菜單裡。", + "description": "安裝完成且依賴就緒後,ACP Agent 會出現在工作區操作菜單裡。遠端工作區會複用這裡的本地配置,並在遠端主機檢測命令。", "searchPlaceholder": "搜尋 Agent...", "filterLabel": "ACP Agent 篩選", "filters": { From 487d52758e2d133a15ccf194aefd0db9310350fa Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 18 May 2026 09:36:02 +0800 Subject: [PATCH 20/76] feat: update ACP agents config --- src/apps/desktop/src/api/acp_client_api.rs | 4 +- src/crates/acp/src/client/config.rs | 24 ++ src/crates/acp/src/client/manager.rs | 185 +++++++-- src/crates/acp/src/client/mod.rs | 4 +- src/crates/acp/src/client/requirements.rs | 41 +- .../api/service-api/ACPClientAPI.ts | 1 + .../config/components/AcpAgentsConfig.scss | 47 ++- .../components/AcpAgentsConfig.test.tsx | 103 ++++- .../config/components/AcpAgentsConfig.tsx | 353 +++++++++++++++++- .../locales/en-US/settings/acp-agents.json | 8 + .../locales/zh-CN/settings/acp-agents.json | 8 + .../locales/zh-TW/settings/acp-agents.json | 8 + 12 files changed, 740 insertions(+), 46 deletions(-) diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index fb7997f5de..d16b2cf2b0 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -14,6 +14,8 @@ use tauri::{AppHandle, Emitter, State}; #[serde(rename_all = "camelCase")] pub struct AcpClientIdRequest { pub client_id: String, + #[serde(default)] + pub remote_connection_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -146,7 +148,7 @@ pub async fn install_acp_client_cli( .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())?; service - .install_client_cli(&request.client_id) + .install_client_cli(&request.client_id, request.remote_connection_id.as_deref()) .await .map_err(|e| e.to_string()) } diff --git a/src/crates/acp/src/client/config.rs b/src/crates/acp/src/client/config.rs index fc9ffdd224..6c28c26597 100644 --- a/src/crates/acp/src/client/config.rs +++ b/src/crates/acp/src/client/config.rs @@ -7,6 +7,8 @@ use serde::{Deserialize, Serialize}; pub struct AcpClientConfigFile { #[serde(default)] pub acp_clients: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub remote_overrides: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -27,6 +29,28 @@ pub struct AcpClientConfig { pub permission_mode: AcpClientPermissionMode, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AcpRemoteOverrideConfig { + #[serde(default)] + pub env: HashMap, + #[serde(default)] + pub clients: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AcpRemoteClientOverride { + #[serde(default)] + pub command: Option, + #[serde(default)] + pub args: Option>, + #[serde(default)] + pub env: HashMap, + #[serde(default)] + pub enabled: Option, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AcpClientPermissionMode { diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index 0e9e12ee60..a84c66fe32 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -36,15 +36,16 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use super::builtin_clients::{builtin_client_ids, default_config_for_builtin_client}; use super::config::{ AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, - AcpClientRequirementProbe, AcpClientStatus, RemoteAcpClientRequirementSnapshot, + AcpClientRequirementProbe, AcpClientStatus, AcpRemoteClientOverride, AcpRemoteOverrideConfig, + RemoteAcpClientRequirementSnapshot, }; use super::remote_capability_store::RemoteAcpCapabilityStore; use super::remote_session::{preferred_resume_strategies, AcpRemoteSessionStrategy}; use super::remote_shell::{remote_user_shell_command, render_remote_env_assignments, shell_escape}; use super::requirements::{ acp_requirement_spec, apply_command_environment, install_npm_cli_package, - predownload_npm_adapter, probe_executable, probe_npm_adapter, probe_remote_executable, - probe_remote_npx_adapter, resolve_configured_command, + install_remote_npm_cli_package, predownload_npm_adapter, probe_executable, probe_npm_adapter, + probe_remote_executable, probe_remote_npx_adapter, resolve_configured_command, }; use super::session_options::{model_config_id, session_options_from_state, AcpSessionOptions}; use super::session_persistence::AcpSessionPersistence; @@ -359,24 +360,31 @@ impl AcpClientService { BitFunError::service("SSH manager is not available for remote ACP".to_string()) })?; - let configs = self.load_configs().await?; - let mut ids = configs.keys().cloned().collect::>(); + let config_file = self.load_config_file().await?; + let mut ids = config_file.acp_clients.keys().cloned().collect::>(); for id in builtin_client_ids() { if !ids.iter().any(|candidate| candidate == id) { ids.push(id.to_string()); } } + if let Some(remote_override) = config_file.remote_overrides.get(remote_connection_id) { + for id in remote_override.clients.keys() { + if !ids.iter().any(|candidate| candidate == id) { + ids.push(id.clone()); + } + } + } ids.sort(); let mut probes = Vec::with_capacity(ids.len()); for id in ids { - let config = configs.get(&id); - let spec = acp_requirement_spec(&id, config); + let config = resolve_config_for_client(&config_file, &id, Some(remote_connection_id)); + let spec = acp_requirement_spec(&id, config.as_ref()); let tool = probe_remote_executable( &ssh_manager, remote_connection_id, spec.tool_command, - config.map(|config| &config.env), + config.as_ref().map(|config| &config.env), ) .await; let adapter = match spec.adapter { @@ -385,7 +393,7 @@ impl AcpClientService { &ssh_manager, remote_connection_id, adapter.package, - config.map(|config| &config.env), + config.as_ref().map(|config| &config.env), ) .await, ), @@ -443,9 +451,17 @@ impl AcpClientService { predownload_npm_adapter(adapter.package, adapter.bin).await } - pub async fn install_client_cli(self: &Arc, client_id: &str) -> BitFunResult<()> { - let configs = self.load_configs().await?; - let spec = acp_requirement_spec(client_id, configs.get(client_id)); + pub async fn install_client_cli( + self: &Arc, + client_id: &str, + remote_connection_id: Option<&str>, + ) -> BitFunResult<()> { + let remote_connection_id = remote_connection_id + .map(str::trim) + .filter(|value| !value.is_empty()); + let config_file = self.load_config_file().await?; + let config = resolve_config_for_client(&config_file, client_id, remote_connection_id); + let spec = acp_requirement_spec(client_id, config.as_ref()); let package = spec.install_package.ok_or_else(|| { BitFunError::config(format!( "ACP client '{}' does not have a known CLI installer", @@ -453,7 +469,17 @@ impl AcpClientService { )) })?; - install_npm_cli_package(package).await + if let Some(remote_connection_id) = remote_connection_id { + let remote_manager = get_remote_workspace_manager().ok_or_else(|| { + BitFunError::service("Remote workspace manager is not initialized".to_string()) + })?; + let ssh_manager = remote_manager.get_ssh_manager().await.ok_or_else(|| { + BitFunError::service("SSH manager is not available for remote ACP".to_string()) + })?; + install_remote_npm_cli_package(&ssh_manager, remote_connection_id, package).await + } else { + install_npm_cli_package(package).await + } } pub async fn start_client_for_session( @@ -1377,7 +1403,11 @@ impl AcpClientService { } async fn load_configs(&self) -> BitFunResult> { - Ok(parse_config_value(self.load_config_value().await?)?.acp_clients) + Ok(self.load_config_file().await?.acp_clients) + } + + async fn load_config_file(&self) -> BitFunResult { + parse_config_value(self.load_config_value().await?) } async fn load_config_value(&self) -> BitFunResult { @@ -1597,24 +1627,13 @@ impl AcpClientService { .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let is_remote = remote_connection_id.is_some(); - let mut used_remote_default = false; - let mut config = self - .load_configs() - .await? - .remove(client_id) - .or_else(|| { - if is_remote { - used_remote_default = true; - default_config_for_builtin_client(client_id) - } else { - None - } - }) - .ok_or_else(|| BitFunError::NotFound(format!("ACP client not found: {}", client_id)))?; + let config_file = self.load_config_file().await?; + let config = + resolve_config_for_client(&config_file, client_id, remote_connection_id.as_deref()) + .ok_or_else(|| { + BitFunError::NotFound(format!("ACP client not found: {}", client_id)) + })?; - if used_remote_default { - config.enabled = true; - } if config.command.trim().is_empty() { return Err(BitFunError::config(format!( "ACP client command is empty: {}", @@ -1639,6 +1658,64 @@ impl AcpClientService { } } +fn resolve_config_for_client( + config_file: &AcpClientConfigFile, + client_id: &str, + remote_connection_id: Option<&str>, +) -> Option { + let mut config = config_file + .acp_clients + .get(client_id) + .cloned() + .or_else(|| { + remote_connection_id.and_then(|_| default_config_for_builtin_client(client_id)) + })?; + if let Some(connection_id) = remote_connection_id { + apply_remote_override( + &mut config, + config_file.remote_overrides.get(connection_id), + client_id, + ); + } + Some(config) +} + +fn apply_remote_override( + config: &mut AcpClientConfig, + remote_override: Option<&AcpRemoteOverrideConfig>, + client_id: &str, +) { + let Some(remote_override) = remote_override else { + return; + }; + + config.env.extend(remote_override.env.clone()); + if let Some(client_override) = remote_override.clients.get(client_id) { + apply_remote_client_override(config, client_override); + } +} + +fn apply_remote_client_override( + config: &mut AcpClientConfig, + client_override: &AcpRemoteClientOverride, +) { + if let Some(command) = client_override + .command + .as_deref() + .map(str::trim) + .filter(|command| !command.is_empty()) + { + config.command = command.to_string(); + } + if let Some(args) = client_override.args.as_ref() { + config.args = args.clone(); + } + config.env.extend(client_override.env.clone()); + if let Some(enabled) = client_override.enabled { + config.enabled = enabled; + } +} + fn ensure_remote_client_supported( _client_id: &str, workspace_path: Option<&str>, @@ -2121,4 +2198,50 @@ mod tests { "cd '\\''/srv/my repo'\\'' && exec env PATH=/remote/bin:/usr/bin custom-acp --stdio '\\''with space'\\''" )); } + + #[test] + fn resolves_remote_client_config_with_remote_overrides() { + let config_file = AcpClientConfigFile { + acp_clients: HashMap::from([( + "codex".to_string(), + AcpClientConfig { + name: Some("Codex".to_string()), + command: "npx".to_string(), + args: vec![ + "--yes".to_string(), + "@zed-industries/codex-acp@latest".to_string(), + ], + env: HashMap::from([("BASE".to_string(), "1".to_string())]), + enabled: true, + readonly: false, + permission_mode: AcpClientPermissionMode::Ask, + }, + )]), + remote_overrides: HashMap::from([( + "huawei-server".to_string(), + AcpRemoteOverrideConfig { + env: HashMap::from([("REMOTE".to_string(), "1".to_string())]), + clients: HashMap::from([( + "codex".to_string(), + AcpRemoteClientOverride { + command: Some("codex".to_string()), + args: Some(vec!["acp".to_string()]), + env: HashMap::from([("CLIENT".to_string(), "1".to_string())]), + enabled: Some(false), + }, + )]), + }, + )]), + }; + + let resolved = resolve_config_for_client(&config_file, "codex", Some("huawei-server")) + .expect("config"); + + assert_eq!(resolved.command, "codex"); + assert_eq!(resolved.args, vec!["acp"]); + assert_eq!(resolved.env.get("BASE").map(String::as_str), Some("1")); + assert_eq!(resolved.env.get("REMOTE").map(String::as_str), Some("1")); + assert_eq!(resolved.env.get("CLIENT").map(String::as_str), Some("1")); + assert!(!resolved.enabled); + } } diff --git a/src/crates/acp/src/client/mod.rs b/src/crates/acp/src/client/mod.rs index 58959c3e9d..9ac351a553 100644 --- a/src/crates/acp/src/client/mod.rs +++ b/src/crates/acp/src/client/mod.rs @@ -13,8 +13,8 @@ mod tool_card_bridge; pub use config::{ AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, - AcpClientRequirementProbe, AcpClientStatus, AcpRequirementProbeItem, - RemoteAcpClientRequirementSnapshot, + AcpClientRequirementProbe, AcpClientStatus, AcpRemoteClientOverride, AcpRemoteOverrideConfig, + AcpRequirementProbeItem, RemoteAcpClientRequirementSnapshot, }; pub use manager::{ AcpClientPermissionResponse, AcpClientService, CreateAcpFlowSessionRecordResponse, diff --git a/src/crates/acp/src/client/requirements.rs b/src/crates/acp/src/client/requirements.rs index aedae55f22..8789628a2f 100644 --- a/src/crates/acp/src/client/requirements.rs +++ b/src/crates/acp/src/client/requirements.rs @@ -4,7 +4,7 @@ use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::time::Duration; -use bitfun_core::service::remote_ssh::SSHConnectionManager; +use bitfun_core::service::remote_ssh::{SSHCommandOptions, SSHConnectionManager}; use bitfun_core::util::errors::{BitFunError, BitFunResult}; use tokio::process::Command; @@ -313,6 +313,45 @@ pub(crate) async fn install_npm_cli_package(package: &str) -> BitFunResult<()> { } } +pub(crate) async fn install_remote_npm_cli_package( + ssh_manager: &SSHConnectionManager, + connection_id: &str, + package: &str, +) -> BitFunResult<()> { + let command = remote_user_shell_command(&format!("npm install -g {}", shell_escape(package))); + let timeout_ms = u64::try_from(CLI_INSTALL_TIMEOUT.as_millis()).unwrap_or(u64::MAX); + match ssh_manager + .execute_command_with_options( + connection_id, + &command, + SSHCommandOptions { + timeout_ms: Some(timeout_ms), + cancellation_token: None, + }, + ) + .await + { + Ok(output) if output.exit_code == 0 && !output.timed_out && !output.interrupted => Ok(()), + Ok(output) if output.timed_out => Err(BitFunError::service(format!( + "Failed to install remote ACP agent CLI '{}': command timed out", + package + ))), + Ok(output) if output.interrupted => Err(BitFunError::service(format!( + "Failed to install remote ACP agent CLI '{}': command was cancelled", + package + ))), + Ok(output) => Err(BitFunError::service(format!( + "Failed to install remote ACP agent CLI '{}': {}", + package, + remote_command_error_summary(&output.stderr, &output.stdout) + ))), + Err(error) => Err(BitFunError::service(format!( + "Failed to install remote ACP agent CLI '{}': {}", + package, error + ))), + } +} + pub(crate) fn resolve_configured_command( command: &str, extra_env: &HashMap, diff --git a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts index 0b4bb37252..98a30dbed1 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts @@ -35,6 +35,7 @@ export interface AcpClientRequirementProbe { export interface AcpClientIdRequest { clientId: string; + remoteConnectionId?: string; } export interface CreateAcpFlowSessionRequest { diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss index e1b7e90587..e03a0be521 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss @@ -247,13 +247,58 @@ text-align: center; } + &__remote-list { + display: flex; + flex-direction: column; + } + + &__remote-server { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--border-subtle); + + &:last-child { + border-bottom: 0; + } + } + + &__remote-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: $size-gap-3; + align-items: center; + padding: $size-gap-3; + min-width: 0; + } + + &__remote-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $size-gap-2; + flex-wrap: wrap; + } + + &__remote-agent-list { + display: flex; + flex-direction: column; + border-top: 1px solid var(--border-subtle); + + .bitfun-acp-agents__registry-row { + padding-left: $size-gap-3; + padding-right: $size-gap-3; + } + } + @media (max-width: 860px) { &__toolbar, - &__registry-row { + &__registry-row, + &__remote-head { grid-template-columns: 1fr; } &__toolbar-actions, + &__remote-actions, &__capabilities, &__status-cell, &__confirmation-cell { diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx index d64538da7c..795f3fbfd0 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx @@ -8,6 +8,8 @@ import AcpAgentsConfig from './AcpAgentsConfig'; const loadJsonConfigMock = vi.hoisted(() => vi.fn()); const getClientsMock = vi.hoisted(() => vi.fn()); const probeClientRequirementsMock = vi.hoisted(() => vi.fn()); +const installClientCliMock = vi.hoisted(() => vi.fn()); +const listSavedConnectionsMock = vi.hoisted(() => vi.fn()); const notifyErrorMock = vi.hoisted(() => vi.fn()); const notifySuccessMock = vi.hoisted(() => vi.fn()); const translate = (_key: string, options?: Record & { defaultValue?: string }) => ( @@ -96,7 +98,7 @@ vi.mock('../../api/service-api/ACPClientAPI', () => ({ loadJsonConfig: loadJsonConfigMock, getClients: getClientsMock, probeClientRequirements: probeClientRequirementsMock, - installClientCli: vi.fn(), + installClientCli: installClientCliMock, saveJsonConfig: vi.fn(), }, })); @@ -107,6 +109,12 @@ vi.mock('../../api/service-api/SystemAPI', () => ({ }, })); +vi.mock('@/features/ssh-remote/sshApi', () => ({ + sshApi: { + listSavedConnections: listSavedConnectionsMock, + }, +})); + vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ error: notifyErrorMock, @@ -117,6 +125,7 @@ vi.mock('@/shared/notification-system', () => ({ vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn(), + warn: vi.fn(), }), })); @@ -151,7 +160,9 @@ describe('AcpAgentsConfig', () => { sessionCount: 0, toolName: 'acp__opencode__prompt', }]); + listSavedConnectionsMock.mockResolvedValue([]); probeClientRequirementsMock.mockResolvedValue([]); + installClientCliMock.mockResolvedValue(undefined); container = document.createElement('div'); document.body.appendChild(container); @@ -182,4 +193,94 @@ describe('AcpAgentsConfig', () => { expect(probeClientRequirementsMock).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain('registry.configInvalid'); }); + + it('renders saved remote servers as agent rows without low-level override fields', async () => { + listSavedConnectionsMock.mockResolvedValue([{ + id: 'huawei-server', + name: 'huawei-server', + host: '119.8.182.138', + port: 22, + username: 'ssh-root', + authType: { type: 'Password' }, + }]); + + await act(async () => { + root.render(); + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('huawei-server'); + expect(container.textContent).toContain('ssh-root@119.8.182.138'); + expect(container.textContent).toContain('remote.configureJson'); + expect(container.textContent?.split('remote.configureJson').length).toBe(2); + expect(container.textContent).toContain('remote.refresh'); + expect(container.textContent).not.toContain('remote.env'); + expect(probeClientRequirementsMock).toHaveBeenCalledWith({ + remoteConnectionId: 'huawei-server', + force: undefined, + }); + }); + + it('downloads a missing remote preset on that remote server', async () => { + listSavedConnectionsMock.mockResolvedValue([{ + id: 'huawei-server', + name: 'huawei-server', + host: '119.8.182.138', + port: 22, + username: 'ssh-root', + authType: { type: 'Password' }, + }]); + probeClientRequirementsMock.mockImplementation((options?: { remoteConnectionId?: string }) => { + if (options?.remoteConnectionId === 'huawei-server') { + return Promise.resolve([ + { + id: 'opencode', + tool: { name: 'opencode', installed: true }, + runnable: true, + notes: [], + }, + { + id: 'claude-code', + tool: { name: 'claude', installed: true }, + runnable: true, + notes: [], + }, + { + id: 'codex', + tool: { name: 'codex', installed: false }, + runnable: false, + notes: [], + }, + ]); + } + return Promise.resolve([]); + }); + + await act(async () => { + root.render(); + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const downloadButtons = Array.from(container.querySelectorAll('button')) + .filter(button => button.textContent?.includes('actions.download')); + expect(downloadButtons.length).toBeGreaterThan(0); + + await act(async () => { + downloadButtons[downloadButtons.length - 1].click(); + await Promise.resolve(); + }); + + expect(installClientCliMock).toHaveBeenCalledWith({ + clientId: 'codex', + remoteConnectionId: 'huawei-server', + }); + }); }); diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx index 8573bc3f41..1f4582b7a1 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -10,6 +10,7 @@ import { RefreshCw, Save, Search, + Server, Terminal, } from 'lucide-react'; import { Button, Input, Select, Textarea } from '@/component-library'; @@ -27,6 +28,8 @@ import { type AcpRequirementProbeItem, } from '../../api/service-api/ACPClientAPI'; import { systemAPI } from '../../api/service-api/SystemAPI'; +import { sshApi } from '@/features/ssh-remote/sshApi'; +import type { SavedConnection } from '@/features/ssh-remote/types'; import { useNotification } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import './AcpAgentsConfig.scss'; @@ -45,6 +48,19 @@ interface AcpClientConfig { interface AcpClientConfigFile { acpClients: Record; + remoteOverrides: Record; +} + +interface AcpRemoteOverrideConfig { + env: Record; + clients: Record; +} + +interface AcpRemoteClientOverride { + command?: string; + args?: string[]; + env?: Record; + enabled?: boolean; } interface AcpClientPreset { @@ -148,7 +164,37 @@ function normalizeConfigValue(value: unknown): AcpClientConfigFile { }; } - return { acpClients }; + return { acpClients, remoteOverrides: normalizeRemoteOverrides(candidate.remoteOverrides) }; +} + +function normalizeRemoteOverrides(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const remoteOverrides: Record = {}; + for (const [connectionId, rawOverride] of Object.entries(value as Record)) { + if (!rawOverride || typeof rawOverride !== 'object' || Array.isArray(rawOverride)) continue; + const item = rawOverride as Record; + remoteOverrides[connectionId] = { + env: normalizeEnvObject(item.env), + clients: normalizeRemoteClientOverrides(item.clients), + }; + } + return remoteOverrides; +} + +function normalizeRemoteClientOverrides(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const clients: Record = {}; + for (const [clientId, rawOverride] of Object.entries(value as Record)) { + if (!rawOverride || typeof rawOverride !== 'object' || Array.isArray(rawOverride)) continue; + const item = rawOverride as Record; + clients[clientId] = { + command: typeof item.command === 'string' ? item.command : undefined, + args: Array.isArray(item.args) ? item.args.map(String) : undefined, + env: normalizeEnvObject(item.env), + enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined, + }; + } + return clients; } function normalizeEnvObject(value: unknown): Record { @@ -264,8 +310,9 @@ const AcpAgentsConfig: React.FC = () => { const { error: notifyError, success: notifySuccess } = useNotification(); const jsonEditorRef = useRef(null); - const [config, setConfig] = useState({ acpClients: {} }); + const [config, setConfig] = useState({ acpClients: {}, remoteOverrides: {} }); const [clients, setClients] = useState([]); + const [savedConnections, setSavedConnections] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); @@ -275,13 +322,38 @@ const AcpAgentsConfig: React.FC = () => { const [requirementProbes, setRequirementProbes] = useState( requirementProbeCache ?? [] ); + const [remoteRequirementProbes, setRemoteRequirementProbes] = useState>({}); + const [probingRemoteRequirements, setProbingRemoteRequirements] = useState>(() => new Set()); const [probingRequirements, setProbingRequirements] = useState(false); const [registrySearch, setRegistrySearch] = useState(''); const [registryFilter, setRegistryFilter] = useState('all'); const [installingClientIds, setInstallingClientIds] = useState>(() => new Set()); + const [installingRemoteClientIds, setInstallingRemoteClientIds] = useState>(() => new Set()); const requirementProbeRequestIdRef = useRef(0); + const loadedRemoteProbeIdsRef = useRef>(new Set()); const clientsById = useMemo(() => new Map(clients.map(client => [client.id, client])), [clients]); + const remoteConnectionRows = useMemo(() => { + const byId = new Map(savedConnections.map(connection => [connection.id, connection])); + for (const connectionId of Object.keys(config.remoteOverrides)) { + if (!byId.has(connectionId)) { + byId.set(connectionId, { + id: connectionId, + name: connectionId, + host: '', + port: 22, + username: '', + authType: { type: 'Password' }, + }); + } + } + return Array.from(byId.values()).sort((left, right) => { + const leftTime = left.lastConnected ?? 0; + const rightTime = right.lastConnected ?? 0; + if (leftTime !== rightTime) return rightTime - leftTime; + return (left.name || left.id).localeCompare(right.name || right.id); + }); + }, [config.remoteOverrides, savedConnections]); const probesById = useMemo( () => new Map(requirementProbes.map(probe => [probe.id, probe])), [requirementProbes] @@ -383,6 +455,45 @@ const AcpAgentsConfig: React.FC = () => { } }, [notifyError, t]); + const refreshRemoteRequirementProbes = useCallback(async ( + connectionId: string, + options: { force?: boolean; notifyOnError?: boolean } = {} + ) => { + const normalizedConnectionId = connectionId.trim(); + if (!normalizedConnectionId) return; + if (!options.force && loadedRemoteProbeIdsRef.current.has(normalizedConnectionId)) return; + + setProbingRemoteRequirements(prev => { + const next = new Set(prev); + next.add(normalizedConnectionId); + return next; + }); + try { + const nextRequirementProbes = await ACPClientAPI.probeClientRequirements({ + remoteConnectionId: normalizedConnectionId, + force: options.force, + }); + loadedRemoteProbeIdsRef.current.add(normalizedConnectionId); + setRemoteRequirementProbes(prev => ({ + ...prev, + [normalizedConnectionId]: nextRequirementProbes, + })); + } catch (error) { + log.error('Failed to probe remote ACP agent requirements', error); + if (options.notifyOnError ?? true) { + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.probeFailed'), + }); + } + } finally { + setProbingRemoteRequirements(prev => { + const next = new Set(prev); + next.delete(normalizedConnectionId); + return next; + }); + } + }, [notifyError, t]); + const loadConfig = useCallback(async ( options: { showLoading?: boolean; refreshRequirements?: boolean } = {} ) => { @@ -396,6 +507,10 @@ const AcpAgentsConfig: React.FC = () => { ACPClientAPI.loadJsonConfig(), ACPClientAPI.getClients(), ]); + const nextSavedConnections = await sshApi.listSavedConnections().catch((error) => { + log.warn('Failed to load saved SSH connections for ACP remote overrides', error); + return [] as SavedConnection[]; + }); const parsed = normalizeConfigValue(JSON.parse(rawConfig || '{}')); setConfig(parsed); setJsonConfig(formatConfig(parsed)); @@ -408,6 +523,7 @@ const AcpAgentsConfig: React.FC = () => { ) ); setClients(nextClients); + setSavedConnections(nextSavedConnections); setDirty(false); if (refreshRequirements) { void refreshRequirementProbes({ notifyOnError: false }); @@ -438,6 +554,13 @@ const AcpAgentsConfig: React.FC = () => { }; }, [loadConfig]); + useEffect(() => { + if (loading) return; + for (const connection of remoteConnectionRows) { + void refreshRemoteRequirementProbes(connection.id, { notifyOnError: false }); + } + }, [loading, refreshRemoteRequirementProbes, remoteConnectionRows]); + const patchClientConfig = (clientId: string, patch: Partial) => { setConfig(prev => { const preset = PRESET_BY_ID.get(clientId); @@ -446,6 +569,7 @@ const AcpAgentsConfig: React.FC = () => { if (!current) return prev; const next = { + ...prev, acpClients: { ...prev.acpClients, [clientId]: { @@ -460,12 +584,26 @@ const AcpAgentsConfig: React.FC = () => { setDirty(true); }; - const installPresetClient = async (preset: AcpClientPreset) => { - setInstallingClientIds(prev => new Set(prev).add(preset.id)); + const installPresetClient = async ( + preset: AcpClientPreset, + options: { remoteConnectionId?: string } = {} + ) => { + const remoteConnectionId = options.remoteConnectionId?.trim(); + const installKey = remoteConnectionId ? `${remoteConnectionId}:${preset.id}` : preset.id; + const setInstalling = remoteConnectionId ? setInstallingRemoteClientIds : setInstallingClientIds; + setInstalling(prev => new Set(prev).add(installKey)); try { - await ACPClientAPI.installClientCli({ clientId: preset.id }); - requirementProbeCache = null; - await refreshRequirementProbes({ force: true, notifyOnError: false }); + await ACPClientAPI.installClientCli({ + clientId: preset.id, + remoteConnectionId, + }); + if (remoteConnectionId) { + loadedRemoteProbeIdsRef.current.delete(remoteConnectionId); + await refreshRemoteRequirementProbes(remoteConnectionId, { force: true, notifyOnError: false }); + } else { + requirementProbeCache = null; + await refreshRequirementProbes({ force: true, notifyOnError: false }); + } notifySuccess(t('notifications.downloadSuccess')); } catch (error) { log.error('Failed to download ACP agent CLI', error); @@ -473,9 +611,9 @@ const AcpAgentsConfig: React.FC = () => { title: t('notifications.downloadFailed'), }); } finally { - setInstallingClientIds(prev => { + setInstalling(prev => { const next = new Set(prev); - next.delete(preset.id); + next.delete(installKey); return next; }); } @@ -493,6 +631,7 @@ const AcpAgentsConfig: React.FC = () => { }, ]) ), + remoteOverrides: baseConfig.remoteOverrides, }); const saveConfig = async (nextConfig = config, options: { mergeEnvDrafts?: boolean } = {}) => { @@ -509,6 +648,8 @@ const AcpAgentsConfig: React.FC = () => { setDirty(false); requirementProbeCache = null; setRequirementProbes([]); + loadedRemoteProbeIdsRef.current.clear(); + setRemoteRequirementProbes({}); notifySuccess(t('notifications.saveSuccess')); } catch (error) { log.error('Failed to save ACP agent config', error); @@ -523,6 +664,7 @@ const AcpAgentsConfig: React.FC = () => { const addPresetClient = async (preset: AcpClientPreset) => { const nextClient = defaultConfigForPreset(preset); const next = { + ...config, acpClients: { ...config.acpClients, [preset.id]: nextClient, @@ -589,6 +731,32 @@ const AcpAgentsConfig: React.FC = () => { }); }, [notifyError, t]); + const openJsonEditor = useCallback(() => { + setShowJsonEditor(true); + requestAnimationFrame(() => { + jsonEditorRef.current?.focus(); + }); + }, []); + + const remoteAgentIdsForConnection = useCallback((connectionId: string) => { + const remoteOverride = config.remoteOverrides[connectionId]; + const ids = new Set([ + ...PRESETS.map(preset => preset.id), + ...Object.keys(config.acpClients), + ...Object.keys(remoteOverride?.clients ?? {}), + ]); + return Array.from(ids).sort((left, right) => { + const leftPresetIndex = PRESETS.findIndex(preset => preset.id === left); + const rightPresetIndex = PRESETS.findIndex(preset => preset.id === right); + if (leftPresetIndex !== -1 || rightPresetIndex !== -1) { + if (leftPresetIndex === -1) return 1; + if (rightPresetIndex === -1) return -1; + return leftPresetIndex - rightPresetIndex; + } + return left.localeCompare(right); + }); + }, [config.acpClients, config.remoteOverrides]); + return ( { )} + + + {remoteConnectionRows.length === 0 ? ( +

    {t('remote.empty')}
    + ) : ( +
    + {remoteConnectionRows.map(connection => { + const remoteOverride = config.remoteOverrides[connection.id] ?? { env: {}, clients: {} }; + const hostLabel = [connection.username, connection.host] + .filter(Boolean) + .join('@'); + const remoteProbes = remoteRequirementProbes[connection.id] ?? []; + const remoteProbesById = new Map(remoteProbes.map(probe => [probe.id, probe])); + const probingRemote = probingRemoteRequirements.has(connection.id); + const remoteAgentIds = remoteAgentIdsForConnection(connection.id); + + return ( +
    +
    +
    + + + +
    + + {connection.name || connection.id} + +

    + {hostLabel || connection.id} +

    +
    +
    +
    + + +
    +
    +
    + {remoteAgentIds.map(clientId => { + const preset = PRESET_BY_ID.get(clientId); + const clientConfig = config.acpClients[clientId]; + const clientOverride = remoteOverride.clients[clientId] ?? {}; + const requirementProbe = remoteProbesById.get(clientId); + const probePending = probingRemote && !requirementProbe; + const hasConfigEntry = Boolean(clientConfig); + const hasRemoteOverride = Boolean(remoteOverride.clients[clientId]); + const effectiveConfig = clientConfig ?? (preset ? defaultConfigForPreset(preset) : undefined); + const enabled = clientOverride.enabled ?? effectiveConfig?.enabled ?? true; + const runnable = requirementProbe?.runnable; + const missingRemoteTool = requirementProbe?.tool.installed === false; + const status = getAgentRowStatus({ + configured: hasConfigEntry || hasRemoteOverride, + enabled, + runnable, + probePending, + }); + const displayName = effectiveConfig?.name || preset?.name || clientId; + const description = preset?.description ?? + (effectiveConfig ? [effectiveConfig.command, ...effectiveConfig.args].join(' ') : clientId); + const installingRemote = installingRemoteClientIds.has(`${connection.id}:${clientId}`); + + return ( +
    +
    + + + +
    + {displayName} +

    {description}

    +
    +
    +
    + } + item={requirementProbe?.tool} + label={t('requirements.tool')} + installedText={t('requirements.installed')} + missingText={t('requirements.missing')} + checking={probePending} + checkingText={t('requirements.checking')} + /> + {requirementProbe?.adapter && ( + } + item={requirementProbe.adapter} + label={t('requirements.adapter')} + installedText={t('requirements.installed')} + missingText={t('requirements.missing')} + checking={probePending} + checkingText={t('requirements.checking')} + /> + )} +
    +
    + +
    +
    + {preset && (status === 'not_installed' || missingRemoteTool) ? ( + + ) : hasConfigEntry && clientConfig ? ( + { })} size="small" /> - ) : status === 'not_installed' ? ( + ) : canInstallCli ? ( + ) : canConfigureAcp ? ( + + ) : canViewError ? ( + ) : ( + ) : null}
    ); @@ -1014,14 +1235,61 @@ const AcpAgentsConfig: React.FC = () => { ) : (
    {remoteConnectionRows.map(connection => { - const remoteOverride = config.remoteOverrides[connection.id] ?? { env: {}, clients: {} }; const hostLabel = [connection.username, connection.host] .filter(Boolean) .join('@'); const remoteProbes = remoteRequirementProbes[connection.id] ?? []; const remoteProbesById = new Map(remoteProbes.map(probe => [probe.id, probe])); + const remoteProbeLoaded = Object.prototype.hasOwnProperty.call( + remoteRequirementProbes, + connection.id + ); const probingRemote = probingRemoteRequirements.has(connection.id); - const remoteAgentIds = remoteAgentIdsForConnection(connection.id); + const remoteRows = remoteAgentIds.map(clientId => { + const preset = PRESET_BY_ID.get(clientId); + const clientConfig = config.acpClients[clientId]; + const requirementProbe = remoteProbesById.get(clientId); + const probePending = probingRemote || !remoteProbeLoaded || !requirementProbe; + const hasConfigEntry = Boolean(clientConfig); + const effectiveConfig = clientConfig ?? (preset ? defaultConfigForPreset(preset) : undefined); + const enabled = effectiveConfig?.enabled ?? true; + const requiresAdapter = Boolean(requirementProbe?.adapter || preset?.id !== 'opencode'); + const issueKind = getIssueKind({ probe: requirementProbe, requiresAdapter }); + const status = getAgentRowStatus({ + configured: hasConfigEntry, + enabled, + toolInstalled: requirementProbe?.tool.installed, + adapterInstalled: requirementProbe?.adapter?.installed, + requiresAdapter, + probePending, + }); + const displayName = effectiveConfig?.name || preset?.name || clientId; + const description = preset?.description ?? + (effectiveConfig ? [effectiveConfig.command, ...effectiveConfig.args].join(' ') : clientId); + const installingRemote = installingRemoteClientIds.has(`${connection.id}:${clientId}`); + + return { + clientId, + preset, + clientConfig, + requirementProbe, + probePending, + hasConfigEntry, + enabled, + requiresAdapter, + issueKind, + status, + displayName, + description, + installingRemote, + }; + }); + const availableCount = remoteRows.filter(row => row.status === 'enabled' || row.status === 'ready').length; + const issueCount = remoteRows.filter(row => ( + row.status === 'partial' || + row.status === 'not_installed' || + row.status === 'invalid' + )).length; return (
    @@ -1037,17 +1305,19 @@ const AcpAgentsConfig: React.FC = () => {

    {hostLabel || connection.id}

    +
    + + {getRemoteSummary(availableCount, remoteRows.length)} + + {issueCount > 0 && ( + + {t('remote.issueSummary', { count: issueCount })} + + )} +
    -
    - {remoteAgentIds.map(clientId => { - const preset = PRESET_BY_ID.get(clientId); - const clientConfig = config.acpClients[clientId]; - const clientOverride = remoteOverride.clients[clientId] ?? {}; - const requirementProbe = remoteProbesById.get(clientId); - const probePending = probingRemote && !requirementProbe; - const hasConfigEntry = Boolean(clientConfig); - const hasRemoteOverride = Boolean(remoteOverride.clients[clientId]); - const effectiveConfig = clientConfig ?? (preset ? defaultConfigForPreset(preset) : undefined); - const enabled = clientOverride.enabled ?? effectiveConfig?.enabled ?? true; - const runnable = requirementProbe?.runnable; - const missingRemoteTool = requirementProbe?.tool.installed === false; - const status = getAgentRowStatus({ - configured: hasConfigEntry || hasRemoteOverride, - enabled, - runnable, - probePending, + {remoteRows.map(row => { + const statusLabel = getStatusLabel({ + status: row.status, + issueKind: row.issueKind, + probe: row.requirementProbe, + requiresAdapter: row.requiresAdapter, }); - const displayName = effectiveConfig?.name || preset?.name || clientId; - const description = preset?.description ?? - (effectiveConfig ? [effectiveConfig.command, ...effectiveConfig.args].join(' ') : clientId); - const installingRemote = installingRemoteClientIds.has(`${connection.id}:${clientId}`); + const statusTitle = getStatusTitle({ + status: row.status, + issueKind: row.issueKind, + probe: row.requirementProbe, + requiresAdapter: row.requiresAdapter, + }); + const canInstallCli = row.preset && row.status === 'not_installed' && row.issueKind === 'cli_missing'; + const canViewError = row.status === 'invalid' || row.status === 'partial' + || row.issueKind === 'connection_failed' + || row.issueKind === 'permission_denied' + || row.issueKind === 'path_invalid' + || row.issueKind === 'version_mismatch' + || row.issueKind === 'adapter_missing'; return ( -
    +
    - {displayName} -

    {description}

    + {row.displayName} +

    {row.description}

    } - item={requirementProbe?.tool} + item={row.requirementProbe?.tool} label={t('requirements.tool')} installedText={t('requirements.installed')} missingText={t('requirements.missing')} - checking={probePending} + checking={row.probePending} checkingText={t('requirements.checking')} /> - {requirementProbe?.adapter && ( + {row.requirementProbe?.adapter && ( } - item={requirementProbe.adapter} + item={row.requirementProbe.adapter} label={t('requirements.adapter')} installedText={t('requirements.installed')} missingText={t('requirements.missing')} - checking={probePending} + checking={row.probePending} checkingText={t('requirements.checking')} /> )}
    - +
    - {preset && (status === 'not_installed' || missingRemoteTool) ? ( + {canInstallCli ? ( - ) : hasConfigEntry && clientConfig ? ( - patchClientConfig(row.clientId, { + permissionMode: normalizePermissionMode(value), + })} + size="small" + /> + ) : row.preset ? ( + ) : null + ) : canViewError ? ( + ) : ( null )} diff --git a/src/web-ui/src/locales/en-US/settings/acp-agents.json b/src/web-ui/src/locales/en-US/settings/acp-agents.json index c56e7ae3dd..ff2990ecb9 100644 --- a/src/web-ui/src/locales/en-US/settings/acp-agents.json +++ b/src/web-ui/src/locales/en-US/settings/acp-agents.json @@ -4,6 +4,9 @@ "actions": { "add": "Add", "download": "Download", + "installCli": "Install CLI", + "configureAcp": "Configure ACP", + "viewError": "View error", "save": "Save", "refresh": "Refresh", "learnMore": "Learn More", @@ -21,7 +24,7 @@ }, "registry": { "title": "Agents", - "description": "Installed ACP agents with ready dependencies appear in the workspace action menu. Remote workspaces reuse this local config and check commands on the remote host.", + "description": "Installed agents appear in the workspace action menu. Remote workspaces reuse the same list and check the remote host before showing status.", "searchPlaceholder": "Search agents...", "filterLabel": "ACP agent filter", "filters": { @@ -32,12 +35,28 @@ "configInvalid": "Invalid config" }, "enabled": "Enabled", - "ready": "Ready", + "ready": "Available", + "partial": "Partially available", "installed": "Installed", "notInstalled": "Not Installed", "configInvalid": "Invalid config", - "cliRequired": "Install CLI", "checking": "Checking", + "cliMissing": "CLI missing", + "acpMissing": "ACP missing", + "pathInvalid": "Path invalid", + "versionMismatch": "Version mismatch", + "connectionFailed": "Connection failed", + "permissionDenied": "Permission denied", + "partialDetail": "The CLI is ready, but the ACP adapter is not configured.", + "cliMissingDetail": "No CLI command was found on PATH.", + "acpMissingDetail": "The ACP adapter is not available yet.", + "pathInvalidDetail": "The command path could not be resolved.", + "versionMismatchDetail": "The detected version does not match the expected runtime.", + "connectionFailedDetail": "The remote host could not be reached.", + "permissionDeniedDetail": "The command could not run because of a permission problem.", + "configInvalidDetail": "The current ACP configuration cannot be used as-is.", + "toolPath": "Path", + "toolVersion": "Version", "empty": "No matching ACP agents." }, "clients": { @@ -45,11 +64,12 @@ }, "remote": { "title": "Remote Servers", - "description": "Each saved SSH server reuses the ACP agent config above and checks requirements on the remote host. Use JSON remoteOverrides only when a server needs different runtime details.", + "description": "Saved SSH servers reuse the same ACP agent list and probe each remote host automatically.", "empty": "No saved SSH servers.", - "noAgents": "Add an ACP agent before configuring remote overrides.", - "configureJson": "Configure JSON", - "refresh": "Refresh remote" + "noAgents": "Add an ACP agent before checking remote servers.", + "refreshDetection": "Refresh detection", + "summary": "{{available}} / {{total}} available", + "issueSummary": "{{count}} issue(s)" }, "fields": { "name": "Name", @@ -80,6 +100,10 @@ "openLinkFailed": "Failed to open link", "invalidJson": "Invalid ACP JSON", "invalidEnv": "Invalid environment value", + "installSuccess": "ACP CLI installed", + "installFailed": "Failed to install ACP CLI", + "configureSuccess": "ACP adapter prepared", + "configureFailed": "Failed to prepare ACP adapter", "downloadSuccess": "ACP agent CLI downloaded", "downloadFailed": "Failed to download ACP agent CLI", "predownloadSuccess": "ACP adapter downloaded", diff --git a/src/web-ui/src/locales/zh-CN/settings/acp-agents.json b/src/web-ui/src/locales/zh-CN/settings/acp-agents.json index 594779a85d..2bca2e9936 100644 --- a/src/web-ui/src/locales/zh-CN/settings/acp-agents.json +++ b/src/web-ui/src/locales/zh-CN/settings/acp-agents.json @@ -4,6 +4,9 @@ "actions": { "add": "添加", "download": "下载", + "installCli": "安装 CLI", + "configureAcp": "配置 ACP", + "viewError": "查看错误", "save": "保存", "refresh": "刷新", "learnMore": "了解更多", @@ -21,7 +24,7 @@ }, "registry": { "title": "Agent 列表", - "description": "安装完成且依赖就绪后,ACP Agent 会出现在工作区操作菜单里。远程工作区会复用这里的本地配置,并在远端主机检测命令。", + "description": "已安装的 Agent 会出现在工作区操作菜单里。远程工作区会复用同一份列表,并先在远端主机检测状态。", "searchPlaceholder": "搜索 Agent...", "filterLabel": "ACP Agent 筛选", "filters": { @@ -32,12 +35,28 @@ "configInvalid": "配置异常" }, "enabled": "已启用", - "ready": "依赖就绪", + "ready": "可用", + "partial": "部分可用", "installed": "已安装", "notInstalled": "未安装", "configInvalid": "配置异常", - "cliRequired": "需安装 CLI", "checking": "检测中", + "cliMissing": "CLI 未安装", + "acpMissing": "ACP 未配置", + "pathInvalid": "路径无效", + "versionMismatch": "版本异常", + "connectionFailed": "连接失败", + "permissionDenied": "权限不足", + "partialDetail": "CLI 已就绪,但 ACP 适配器还未配置。", + "cliMissingDetail": "未检测到对应命令,请确认已安装并加入 PATH。", + "acpMissingDetail": "ACP 适配器暂不可用。", + "pathInvalidDetail": "命令路径无法解析,请确认路径有效。", + "versionMismatchDetail": "检测到的版本与当前运行环境不匹配。", + "connectionFailedDetail": "远端主机当前无法连接。", + "permissionDeniedDetail": "命令无法执行,可能是权限不足。", + "configInvalidDetail": "当前 ACP 配置暂时无法直接使用。", + "toolPath": "路径", + "toolVersion": "版本", "empty": "没有匹配的 ACP Agent。" }, "clients": { @@ -45,11 +64,12 @@ }, "remote": { "title": "远程服务器", - "description": "每台已保存的 SSH 服务器会复用上面的 ACP Agent 配置,并在远端检测依赖。需要为某台服务器覆盖运行细节时,通过 JSON 编辑 remoteOverrides。", + "description": "已保存的 SSH 服务器会复用同一份 ACP Agent 列表,并自动检测每台远端主机的状态。", "empty": "没有已保存的 SSH 服务器。", - "noAgents": "请先添加 ACP Agent,再配置远程覆盖。", - "configureJson": "配置 JSON", - "refresh": "刷新远端" + "noAgents": "请先添加 ACP Agent,再检测远程服务器。", + "refreshDetection": "刷新检测", + "summary": "{{available}} / {{total}} 可用", + "issueSummary": "{{count}} 个异常" }, "fields": { "name": "名称", @@ -80,6 +100,10 @@ "openLinkFailed": "打开链接失败", "invalidJson": "ACP JSON 无效", "invalidEnv": "环境变量无效", + "installSuccess": "ACP CLI 已安装", + "installFailed": "安装 ACP CLI 失败", + "configureSuccess": "ACP 适配器已就绪", + "configureFailed": "配置 ACP 适配器失败", "downloadSuccess": "ACP Agent CLI 已下载", "downloadFailed": "下载 ACP Agent CLI 失败", "predownloadSuccess": "ACP 适配器已下载", diff --git a/src/web-ui/src/locales/zh-TW/settings/acp-agents.json b/src/web-ui/src/locales/zh-TW/settings/acp-agents.json index de537bbabe..59a7f895a6 100644 --- a/src/web-ui/src/locales/zh-TW/settings/acp-agents.json +++ b/src/web-ui/src/locales/zh-TW/settings/acp-agents.json @@ -4,6 +4,9 @@ "actions": { "add": "添加", "download": "下載", + "installCli": "安裝 CLI", + "configureAcp": "配置 ACP", + "viewError": "查看錯誤", "save": "保存", "refresh": "刷新", "learnMore": "了解更多", @@ -21,7 +24,7 @@ }, "registry": { "title": "Agent 列表", - "description": "安裝完成且依賴就緒後,ACP Agent 會出現在工作區操作菜單裡。遠端工作區會複用這裡的本地配置,並在遠端主機檢測命令。", + "description": "已安裝的 Agent 會出現在工作區操作菜單裡。遠端工作區會複用同一份列表,並先在遠端主機檢測狀態。", "searchPlaceholder": "搜尋 Agent...", "filterLabel": "ACP Agent 篩選", "filters": { @@ -32,12 +35,28 @@ "configInvalid": "配置異常" }, "enabled": "已啟用", - "ready": "依賴就緒", + "ready": "可用", + "partial": "部分可用", "installed": "已安裝", "notInstalled": "未安裝", "configInvalid": "配置異常", - "cliRequired": "需安裝 CLI", "checking": "檢測中", + "cliMissing": "CLI 未安裝", + "acpMissing": "ACP 未配置", + "pathInvalid": "路徑無效", + "versionMismatch": "版本異常", + "connectionFailed": "連線失敗", + "permissionDenied": "權限不足", + "partialDetail": "CLI 已就緒,但 ACP 適配器尚未配置。", + "cliMissingDetail": "未檢測到對應命令,請確認已安裝並加入 PATH。", + "acpMissingDetail": "ACP 適配器暫不可用。", + "pathInvalidDetail": "命令路徑無法解析,請確認路徑有效。", + "versionMismatchDetail": "偵測到的版本與目前執行環境不匹配。", + "connectionFailedDetail": "遠端主機目前無法連線。", + "permissionDeniedDetail": "命令無法執行,可能是權限不足。", + "configInvalidDetail": "目前 ACP 設定暫時無法直接使用。", + "toolPath": "路徑", + "toolVersion": "版本", "empty": "沒有匹配的 ACP Agent。" }, "clients": { @@ -45,11 +64,12 @@ }, "remote": { "title": "遠端伺服器", - "description": "每台已保存的 SSH 伺服器會複用上面的 ACP Agent 配置,並在遠端檢測依賴。需要為某台伺服器覆蓋運行細節時,通過 JSON 編輯 remoteOverrides。", + "description": "已保存的 SSH 伺服器會複用同一份 ACP Agent 列表,並自動檢測每台遠端主機的狀態。", "empty": "沒有已保存的 SSH 伺服器。", - "noAgents": "請先添加 ACP Agent,再配置遠端覆蓋。", - "configureJson": "配置 JSON", - "refresh": "刷新遠端" + "noAgents": "請先添加 ACP Agent,再檢測遠端伺服器。", + "refreshDetection": "刷新檢測", + "summary": "{{available}} / {{total}} 可用", + "issueSummary": "{{count}} 個異常" }, "fields": { "name": "名稱", @@ -80,6 +100,10 @@ "openLinkFailed": "打開鏈接失敗", "invalidJson": "ACP JSON 無效", "invalidEnv": "環境變量無效", + "installSuccess": "ACP CLI 已安裝", + "installFailed": "安裝 ACP CLI 失敗", + "configureSuccess": "ACP 適配器已就緒", + "configureFailed": "配置 ACP 適配器失敗", "downloadSuccess": "ACP Agent CLI 已下載", "downloadFailed": "下載 ACP Agent CLI 失敗", "predownloadSuccess": "ACP 適配器已下載", From 6fd4312d620ab8c9999cbcba072fad87c75a772f Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 18 May 2026 11:22:23 +0800 Subject: [PATCH 22/76] feat(miniapp): remember declined built-in updates --- src/apps/desktop/src/api/miniapp_api.rs | 25 ++++ src/apps/desktop/src/lib.rs | 1 + src/crates/core/src/miniapp/builtin/mod.rs | 137 ++++++++++++++++-- src/crates/core/src/miniapp/manager.rs | 107 +++++++++++++- src/crates/core/src/miniapp/mod.rs | 2 +- src/crates/core/src/miniapp/storage.rs | 1 + .../src/miniapp/customization.rs | 41 ++++++ .../src/app/scenes/miniapps/MiniAppScene.scss | 7 + .../customization/MiniAppCustomizePanel.tsx | 40 +++++ .../miniAppCustomizationMetadata.test.ts | 3 + .../miniAppCustomizationMetadata.ts | 6 +- .../api/service-api/MiniAppAPI.ts | 26 ++++ .../src/locales/en-US/scenes/miniapp.json | 2 + .../src/locales/zh-CN/scenes/miniapp.json | 2 + .../src/locales/zh-TW/scenes/miniapp.json | 2 + 15 files changed, 384 insertions(+), 18 deletions(-) diff --git a/src/apps/desktop/src/api/miniapp_api.rs b/src/apps/desktop/src/api/miniapp_api.rs index 86d546e4f6..2a65fb2d29 100644 --- a/src/apps/desktop/src/api/miniapp_api.rs +++ b/src/apps/desktop/src/api/miniapp_api.rs @@ -235,6 +235,14 @@ pub struct MiniAppDraftStorageSetRequest { pub value: Value, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppDeclineBuiltinUpdateRequest { + pub app_id: String, + pub builtin_version: u32, + pub source_hash: String, +} + #[derive(Debug, Serialize)] pub struct RuntimeStatus { pub available: bool, @@ -1082,6 +1090,23 @@ pub async fn miniapp_get_customization_metadata( .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn miniapp_decline_builtin_update( + state: State<'_, AppState>, + request: MiniAppDeclineBuiltinUpdateRequest, +) -> Result, String> { + state + .miniapp_manager + .decline_builtin_update( + &request.app_id, + request.builtin_version, + &request.source_hash, + now_ms() as i64, + ) + .await + .map_err(|e| e.to_string()) +} + // ============== AI commands ============== /// Active AI stream cancellation flags: stream_id → cancel flag. diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index f8ebc3c489..23ec40cb42 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -990,6 +990,7 @@ pub async fn run() { api::miniapp_api::miniapp_draft_host_call, api::miniapp_api::miniapp_draft_worker_stop, api::miniapp_api::miniapp_get_customization_metadata, + api::miniapp_api::miniapp_decline_builtin_update, api::miniapp_api::miniapp_ai_complete, api::miniapp_api::miniapp_ai_chat, api::miniapp_api::miniapp_ai_cancel, diff --git a/src/crates/core/src/miniapp/builtin/mod.rs b/src/crates/core/src/miniapp/builtin/mod.rs index b7e44d7fa8..269b39f310 100644 --- a/src/crates/core/src/miniapp/builtin/mod.rs +++ b/src/crates/core/src/miniapp/builtin/mod.rs @@ -119,8 +119,8 @@ async fn seed_one(manager: &Arc, app: &BuiltinApp) -> BitFunResu let now = Utc::now().timestamp_millis(); match manager.load_customization_metadata(app.id).await { Ok(Some(metadata)) if metadata.local_override => { - manager - .mark_builtin_update_available(app.id, app.version, now) + let recorded = manager + .mark_builtin_update_available(app.id, app.version, &content_hash, now) .await?; let marker = BuiltinInstallMarker { version: app.version, @@ -132,11 +132,19 @@ async fn seed_one(manager: &Arc, app: &BuiltinApp) -> BitFunResu &app.version.to_string(), ) .await?; - log::info!( - "preserved customized builtin miniapp '{}' and recorded bundled update v{}", - app.id, - app.version - ); + if recorded { + log::info!( + "preserved customized builtin miniapp '{}' and recorded bundled update v{}", + app.id, + app.version + ); + } else { + log::info!( + "preserved customized builtin miniapp '{}' and skipped previously declined bundled update v{}", + app.id, + app.version + ); + } return Ok(()); } Ok(_) => {} @@ -327,6 +335,18 @@ mod tests { Arc::new(MiniAppManager::new(path_manager)) } + async fn write_outdated_builtin_marker(app_dir: &std::path::Path) { + write_builtin_install_marker( + &app_dir.join(BUILTIN_MARKER), + &BuiltinInstallMarker { + version: 0, + hash: "sha256:outdated".to_string(), + }, + ) + .await + .unwrap(); + } + #[tokio::test] async fn builtin_reseed_preserves_local_override_and_records_available_update() { let manager = test_manager(); @@ -350,14 +370,13 @@ mod tests { local_override: true, last_applied_draft_id: Some("draft-1".to_string()), available_builtin_update: None, + declined_builtin_updates: Vec::new(), updated_at: Utc::now().timestamp_millis(), }, ) .await .unwrap(); - tokio::fs::write(app_dir.join(BUILTIN_MARKER), "0") - .await - .unwrap(); + write_outdated_builtin_marker(&app_dir).await; seed_builtin_miniapps(&manager).await.unwrap(); @@ -372,11 +391,103 @@ mod tests { .unwrap() .unwrap(); assert!(metadata.local_override); + let update = metadata.available_builtin_update.unwrap(); + assert_eq!(update.builtin_version, builtin.version); + assert!(!update.source_hash.is_empty()); + } + + #[tokio::test] + async fn builtin_reseed_skips_declined_update_until_local_override_changes() { + let manager = test_manager(); + let builtin = &BUILTIN_APPS[0]; + seed_builtin_miniapps(&manager).await.unwrap(); + + let custom_css = "body { background: #fafafa; }"; + let app_dir = manager.path_manager().miniapp_dir(builtin.id); + tokio::fs::write(app_dir.join("source").join("style.css"), custom_css) + .await + .unwrap(); + manager + .save_customization_metadata( + builtin.id, + &MiniAppCustomizationMetadata { + origin: MiniAppCustomizationOrigin { + kind: MiniAppCustomizationOriginKind::Builtin, + builtin_id: Some(builtin.id.to_string()), + builtin_version: Some(builtin.version), + }, + local_override: true, + last_applied_draft_id: Some("draft-1".to_string()), + available_builtin_update: None, + declined_builtin_updates: Vec::new(), + updated_at: Utc::now().timestamp_millis(), + }, + ) + .await + .unwrap(); + + write_outdated_builtin_marker(&app_dir).await; + seed_builtin_miniapps(&manager).await.unwrap(); + let first_metadata = manager + .load_customization_metadata(builtin.id) + .await + .unwrap() + .unwrap(); + let first_update = first_metadata.available_builtin_update.unwrap(); + let source_hash = first_update.source_hash.clone(); + + manager + .decline_builtin_update(builtin.id, first_update.builtin_version, &source_hash, 1234) + .await + .unwrap(); + write_outdated_builtin_marker(&app_dir).await; + seed_builtin_miniapps(&manager).await.unwrap(); + + let declined_metadata = manager + .load_customization_metadata(builtin.id) + .await + .unwrap() + .unwrap(); + assert!(declined_metadata.available_builtin_update.is_none()); + assert_eq!(declined_metadata.declined_builtin_updates.len(), 1); + assert_eq!( + declined_metadata.declined_builtin_updates[0].source_hash, + source_hash + ); + let repeated_same_source = manager + .mark_builtin_update_available(builtin.id, builtin.version + 1, &source_hash, 5678) + .await + .unwrap(); + assert!(!repeated_same_source); + let css = tokio::fs::read_to_string(app_dir.join("source").join("style.css")) + .await + .unwrap(); + assert_eq!(css, custom_css); + + tokio::fs::write( + app_dir.join("source").join("style.css"), + "body { background: #ffffff; }", + ) + .await + .unwrap(); + manager + .sync_from_fs(builtin.id, "dark", None) + .await + .unwrap(); + write_outdated_builtin_marker(&app_dir).await; + seed_builtin_miniapps(&manager).await.unwrap(); + + let updated_metadata = manager + .load_customization_metadata(builtin.id) + .await + .unwrap() + .unwrap(); assert_eq!( - metadata + updated_metadata .available_builtin_update - .map(|update| update.builtin_version), - Some(builtin.version) + .as_ref() + .map(|update| (update.builtin_version, update.source_hash.as_str())), + Some((builtin.version, source_hash.as_str())) ); } diff --git a/src/crates/core/src/miniapp/manager.rs b/src/crates/core/src/miniapp/manager.rs index 192585858b..c04f22afdf 100644 --- a/src/crates/core/src/miniapp/manager.rs +++ b/src/crates/core/src/miniapp/manager.rs @@ -9,7 +9,8 @@ use crate::miniapp::types::{ use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_product_domains::miniapp::customization::{ diff_permissions, MiniAppAvailableBuiltinUpdate, MiniAppCustomizationMetadata, - MiniAppCustomizationOrigin, MiniAppCustomizationOriginKind, MiniAppPermissionDiff, + MiniAppCustomizationOrigin, MiniAppCustomizationOriginKind, MiniAppDeclinedBuiltinUpdate, + MiniAppPermissionDiff, }; use bitfun_product_domains::miniapp::lifecycle::{ build_deps_revision, build_runtime_state, build_source_revision, build_worker_revision, @@ -24,6 +25,7 @@ use tokio::sync::RwLock; use uuid::Uuid; static GLOBAL_MINIAPP_MANAGER: OnceLock> = OnceLock::new(); +const MAX_DECLINED_BUILTIN_UPDATES: usize = 16; /// Initialize the global MiniAppManager (called once at startup from Tauri app_state). pub fn initialize_global_miniapp_manager(manager: Arc) { @@ -610,6 +612,7 @@ impl MiniAppManager { local_override: true, last_applied_draft_id: None, available_builtin_update: None, + declined_builtin_updates: Vec::new(), updated_at: now, } } else { @@ -622,6 +625,7 @@ impl MiniAppManager { local_override: false, last_applied_draft_id: None, available_builtin_update: None, + declined_builtin_updates: Vec::new(), updated_at: now, } }; @@ -650,19 +654,116 @@ impl MiniAppManager { &self, app_id: &str, builtin_version: u32, + source_hash: &str, detected_at: i64, - ) -> BitFunResult<()> { + ) -> BitFunResult { if let Some(mut metadata) = self.storage.load_customization_metadata(app_id).await? { + if self + .has_matching_declined_builtin_update(app_id, &metadata, source_hash) + .await? + { + if metadata.available_builtin_update.is_some() { + metadata.available_builtin_update = None; + self.storage + .save_customization_metadata(app_id, &metadata) + .await?; + } + return Ok(false); + } + metadata.available_builtin_update = Some(MiniAppAvailableBuiltinUpdate { builtin_version, + source_hash: source_hash.to_string(), detected_at, }); metadata.updated_at = detected_at; self.storage .save_customization_metadata(app_id, &metadata) .await?; + return Ok(true); + } + Ok(false) + } + + async fn has_matching_declined_builtin_update( + &self, + app_id: &str, + metadata: &MiniAppCustomizationMetadata, + source_hash: &str, + ) -> BitFunResult { + let Some(record) = metadata + .declined_builtin_updates + .iter() + .rev() + .find(|record| record.source_hash == source_hash) + else { + return Ok(false); + }; + + if let (Some(record_version), Some(record_updated_at)) = + (record.local_app_version, record.local_app_updated_at) + { + if let Ok(app) = self.storage.load(app_id).await { + return Ok(app.version == record_version && app.updated_at == record_updated_at); + } + } + + Ok(record.last_applied_draft_id == metadata.last_applied_draft_id) + } + + pub async fn decline_builtin_update( + &self, + app_id: &str, + builtin_version: u32, + source_hash: &str, + declined_at: i64, + ) -> BitFunResult> { + let Some(mut metadata) = self.storage.load_customization_metadata(app_id).await? else { + return Ok(None); + }; + + let app_snapshot = self + .storage + .load(app_id) + .await + .ok() + .map(|app| (app.version, app.updated_at)); + let (local_app_version, local_app_updated_at) = app_snapshot + .map(|(version, updated_at)| (Some(version), Some(updated_at))) + .unwrap_or((None, None)); + + if let Some(record) = metadata.declined_builtin_updates.iter_mut().find(|record| { + record.builtin_version == builtin_version + && record.source_hash == source_hash + && record.last_applied_draft_id == metadata.last_applied_draft_id + }) { + record.declined_at = declined_at; + record.local_app_version = local_app_version; + record.local_app_updated_at = local_app_updated_at; + } else { + metadata + .declined_builtin_updates + .push(MiniAppDeclinedBuiltinUpdate { + builtin_version, + source_hash: source_hash.to_string(), + declined_at, + local_app_version, + local_app_updated_at, + last_applied_draft_id: metadata.last_applied_draft_id.clone(), + }); + if metadata.declined_builtin_updates.len() > MAX_DECLINED_BUILTIN_UPDATES { + let remove_count = + metadata.declined_builtin_updates.len() - MAX_DECLINED_BUILTIN_UPDATES; + metadata.declined_builtin_updates.drain(0..remove_count); + } } - Ok(()) + + metadata.available_builtin_update = None; + metadata.updated_at = declined_at; + self.storage + .save_customization_metadata(app_id, &metadata) + .await?; + Ok(Some(metadata)) } pub async fn mark_deps_installed(&self, app_id: &str) -> BitFunResult { diff --git a/src/crates/core/src/miniapp/mod.rs b/src/crates/core/src/miniapp/mod.rs index 253d1206db..2426e27fb0 100644 --- a/src/crates/core/src/miniapp/mod.rs +++ b/src/crates/core/src/miniapp/mod.rs @@ -11,7 +11,7 @@ pub mod runtime_detect; pub mod storage; pub use bitfun_product_domains::miniapp::customization::{ MiniAppAvailableBuiltinUpdate, MiniAppCustomizationMetadata, MiniAppCustomizationOrigin, - MiniAppCustomizationOriginKind, MiniAppPermissionDiff, + MiniAppCustomizationOriginKind, MiniAppDeclinedBuiltinUpdate, MiniAppPermissionDiff, }; pub use bitfun_product_domains::miniapp::{bridge_builder, permission_policy, types}; diff --git a/src/crates/core/src/miniapp/storage.rs b/src/crates/core/src/miniapp/storage.rs index bb08c567d3..0ea000d670 100644 --- a/src/crates/core/src/miniapp/storage.rs +++ b/src/crates/core/src/miniapp/storage.rs @@ -1154,6 +1154,7 @@ mod tests { local_override: true, last_applied_draft_id: Some("draft_one".to_string()), available_builtin_update: None, + declined_builtin_updates: Vec::new(), updated_at: 123, }; diff --git a/src/crates/product-domains/src/miniapp/customization.rs b/src/crates/product-domains/src/miniapp/customization.rs index 625d059b77..74b8f0be10 100644 --- a/src/crates/product-domains/src/miniapp/customization.rs +++ b/src/crates/product-domains/src/miniapp/customization.rs @@ -19,15 +19,33 @@ pub struct MiniAppCustomizationOrigin { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MiniAppAvailableBuiltinUpdate { pub builtin_version: u32, + #[serde(default)] + pub source_hash: String, pub detected_at: i64, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MiniAppDeclinedBuiltinUpdate { + pub builtin_version: u32, + pub source_hash: String, + pub declined_at: i64, + #[serde(default)] + pub local_app_version: Option, + #[serde(default)] + pub local_app_updated_at: Option, + #[serde(default)] + pub last_applied_draft_id: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MiniAppCustomizationMetadata { pub origin: MiniAppCustomizationOrigin, pub local_override: bool, pub last_applied_draft_id: Option, + #[serde(default)] pub available_builtin_update: Option, + #[serde(default)] + pub declined_builtin_updates: Vec, pub updated_at: i64, } @@ -163,6 +181,7 @@ fn diff_string_list( #[cfg(test)] mod tests { + use crate::miniapp::customization::MiniAppCustomizationMetadata; use crate::miniapp::types::{ AiPermissions, FsPermissions, MiniAppPermissions, NetPermissions, NodePermissions, ShellPermissions, @@ -172,6 +191,28 @@ mod tests { MiniAppPermissions::default() } + #[test] + fn customization_metadata_defaults_declined_updates_for_existing_files() { + let metadata: MiniAppCustomizationMetadata = serde_json::from_value(serde_json::json!({ + "origin": { + "kind": "builtin", + "builtin_id": "builtin-demo", + "builtin_version": 1 + }, + "local_override": true, + "last_applied_draft_id": "draft-1", + "available_builtin_update": { + "builtin_version": 2, + "detected_at": 123 + }, + "updated_at": 124 + })) + .unwrap(); + + assert!(metadata.declined_builtin_updates.is_empty()); + assert_eq!(metadata.available_builtin_update.unwrap().source_hash, ""); + } + #[test] fn permission_diff_marks_fs_write_addition_high_risk() { let active = empty_permissions(); diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppScene.scss b/src/web-ui/src/app/scenes/miniapps/MiniAppScene.scss index 11daa729f2..1f71b2de6d 100644 --- a/src/web-ui/src/app/scenes/miniapps/MiniAppScene.scss +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppScene.scss @@ -269,6 +269,13 @@ line-height: $line-height-relaxed; } + &-actions { + display: flex; + flex-wrap: wrap; + gap: $size-gap-2; + margin-top: $size-gap-2; + } + &--update { border-bottom-color: color-mix(in srgb, var(--color-warning) 28%, var(--border-subtle)); background: color-mix(in srgb, var(--color-warning) 12%, var(--color-bg-subtle)); diff --git a/src/web-ui/src/app/scenes/miniapps/customization/MiniAppCustomizePanel.tsx b/src/web-ui/src/app/scenes/miniapps/customization/MiniAppCustomizePanel.tsx index b340ead89a..227e4be8e8 100644 --- a/src/web-ui/src/app/scenes/miniapps/customization/MiniAppCustomizePanel.tsx +++ b/src/web-ui/src/app/scenes/miniapps/customization/MiniAppCustomizePanel.tsx @@ -69,6 +69,7 @@ export const MiniAppCustomizePanel: React.FC = ({ const [previewKey, setPreviewKey] = useState(0); const [discarding, setDiscarding] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [dismissingBuiltinUpdate, setDismissingBuiltinUpdate] = useState(false); const [customizationMetadata, setCustomizationMetadata] = useState(null); const theme = themeType ?? 'dark'; @@ -84,6 +85,7 @@ export const MiniAppCustomizePanel: React.FC = ({ setState(initialState); setUserRequest(''); setPreviewKey(0); + setDismissingBuiltinUpdate(false); setCustomizationMetadata(null); onPreviewChange(null); }, [app.id, onPreviewChange]); @@ -289,6 +291,30 @@ export const MiniAppCustomizePanel: React.FC = ({ } }, [app.id, discarding, onClose, onPreviewChange, state.customizationSessionId, state.draft, t]); + const handleDismissBuiltinUpdate = useCallback(async () => { + if (!builtinUpdateNotice?.sourceHash || dismissingBuiltinUpdate) { + return; + } + + setDismissingBuiltinUpdate(true); + try { + const metadata = await miniAppAPI.declineBuiltinUpdate( + app.id, + builtinUpdateNotice.builtinVersion, + builtinUpdateNotice.sourceHash, + ); + setCustomizationMetadata(metadata); + } catch (error) { + log.error('MiniApp builtin update dismissal failed', error); + setState((prev) => ({ + ...prev, + error: t('customize.dismissBuiltinUpdateFailed', { error: formatError(error) }), + })); + } finally { + setDismissingBuiltinUpdate(false); + } + }, [app.id, builtinUpdateNotice, dismissingBuiltinUpdate, t]); + const handleClose = useCallback(() => { if (busy) { return; @@ -372,6 +398,20 @@ export const MiniAppCustomizePanel: React.FC = ({
    {t('customize.builtinUpdateTitle', { version: builtinUpdateNotice.builtinVersion })}

    {t('customize.builtinUpdateBody')}

    + {builtinUpdateNotice.sourceHash && ( +
    + +
    + )}
    )} diff --git a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationMetadata.test.ts b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationMetadata.test.ts index 7821395d92..d512528258 100644 --- a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationMetadata.test.ts +++ b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationMetadata.test.ts @@ -12,8 +12,10 @@ const baseMetadata: MiniAppCustomizationMetadata = { last_applied_draft_id: 'draft-1', available_builtin_update: { builtin_version: 12, + source_hash: 'abc123', detected_at: 1710000000000, }, + declined_builtin_updates: [], updated_at: 1710000000000, }; @@ -21,6 +23,7 @@ describe('getMiniAppBuiltinUpdateNotice', () => { it('returns the available bundled version for customized built-in apps', () => { expect(getMiniAppBuiltinUpdateNotice(baseMetadata)).toEqual({ builtinVersion: 12, + sourceHash: 'abc123', }); }); diff --git a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationMetadata.ts b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationMetadata.ts index 118c0414ca..43d581c0c8 100644 --- a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationMetadata.ts +++ b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationMetadata.ts @@ -2,6 +2,7 @@ import type { MiniAppCustomizationMetadata } from '@/infrastructure/api/service- export interface MiniAppBuiltinUpdateNotice { builtinVersion: number; + sourceHash: string; } export function getMiniAppBuiltinUpdateNotice( @@ -17,5 +18,8 @@ export function getMiniAppBuiltinUpdateNotice( if (typeof builtinVersion !== 'number') { return null; } - return { builtinVersion }; + return { + builtinVersion, + sourceHash: metadata.available_builtin_update?.source_hash ?? '', + }; } diff --git a/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts b/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts index d187510bfa..0f570ceef4 100644 --- a/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts @@ -192,8 +192,17 @@ export interface MiniAppCustomizationMetadata { last_applied_draft_id?: string; available_builtin_update?: { builtin_version: number; + source_hash: string; detected_at: number; }; + declined_builtin_updates?: Array<{ + builtin_version: number; + source_hash: string; + declined_at: number; + local_app_version?: number | null; + local_app_updated_at?: number | null; + last_applied_draft_id?: string | null; + }>; updated_at: number; } @@ -514,6 +523,23 @@ export class MiniAppAPI { } } + async declineBuiltinUpdate( + appId: string, + builtinVersion: number, + sourceHash: string, + ): Promise { + try { + return await api.invoke('miniapp_decline_builtin_update', { + request: { appId, builtinVersion, sourceHash } + }); + } catch (error) { + throw createTauriCommandError('miniapp_decline_builtin_update', error, { + appId, + builtinVersion, + }); + } + } + // ─── AI commands ──────────────────────────────────────────────────────────── async aiComplete(appId: string, prompt: string, options?: AiCompleteOptions): Promise { diff --git a/src/web-ui/src/locales/en-US/scenes/miniapp.json b/src/web-ui/src/locales/en-US/scenes/miniapp.json index f20a83b2af..de80f97651 100644 --- a/src/web-ui/src/locales/en-US/scenes/miniapp.json +++ b/src/web-ui/src/locales/en-US/scenes/miniapp.json @@ -56,8 +56,10 @@ "workspaceRequired": "A workspace is required to launch the MiniApp editor.", "builtinUpdateTitle": "Built-in update v{{version}} is available", "builtinUpdateBody": "This app has local changes. Updating to the bundled version can overwrite those edits, so keep the local copy until you explicitly choose to update.", + "dismissBuiltinUpdate": "Do not remind for this version", "launchFailed": "Preview copy is ready, but the editor failed to start: {{error}}", "refreshFailed": "Preview refresh failed: {{error}}", + "dismissBuiltinUpdateFailed": "Could not dismiss this update: {{error}}", "permissionCheckFailed": "Permission check failed: {{error}}", "applyFailed": "Apply failed: {{error}}", "discardFailed": "Discard failed: {{error}}", diff --git a/src/web-ui/src/locales/zh-CN/scenes/miniapp.json b/src/web-ui/src/locales/zh-CN/scenes/miniapp.json index 81c7298930..3ee9a6dc45 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/miniapp.json +++ b/src/web-ui/src/locales/zh-CN/scenes/miniapp.json @@ -55,8 +55,10 @@ "workspaceRequired": "需要先打开一个工作区,才能启动小应用编辑会话。", "builtinUpdateTitle": "检测到内置版本 v{{version}} 可更新", "builtinUpdateBody": "这个小应用已有本地修改。更新到内置版本可能覆盖这些修改,建议在明确确认前继续保留本地版本。", + "dismissBuiltinUpdate": "不再提醒此版本", "launchFailed": "预览副本已准备好,但编辑会话启动失败:{{error}}", "refreshFailed": "预览刷新失败:{{error}}", + "dismissBuiltinUpdateFailed": "忽略此更新失败:{{error}}", "permissionCheckFailed": "权限检查失败:{{error}}", "applyFailed": "应用变更失败:{{error}}", "discardFailed": "取消变更失败:{{error}}", diff --git a/src/web-ui/src/locales/zh-TW/scenes/miniapp.json b/src/web-ui/src/locales/zh-TW/scenes/miniapp.json index 5d1581cf7f..11cfd9d1ab 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/miniapp.json +++ b/src/web-ui/src/locales/zh-TW/scenes/miniapp.json @@ -55,8 +55,10 @@ "workspaceRequired": "需要先開啟一個工作區,才能啟動小應用編輯會話。", "builtinUpdateTitle": "偵測到內建版本 v{{version}} 可更新", "builtinUpdateBody": "這個小應用已有本機修改。更新到內建版本可能覆蓋這些修改,建議在明確確認前繼續保留本機版本。", + "dismissBuiltinUpdate": "不再提醒此版本", "launchFailed": "預覽副本已準備好,但編輯會話啟動失敗:{{error}}", "refreshFailed": "預覽重新整理失敗:{{error}}", + "dismissBuiltinUpdateFailed": "忽略此更新失敗:{{error}}", "permissionCheckFailed": "權限檢查失敗:{{error}}", "applyFailed": "套用變更失敗:{{error}}", "discardFailed": "取消變更失敗:{{error}}", From 0a45ffb9af295ec42c92a9354eba1126a1cfc002 Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 18 May 2026 11:59:35 +0800 Subject: [PATCH 23/76] refactor(product-domains): extend MiniApp readiness helpers Move MiniApp draft/storage/runtime search-plan helpers, customization update metadata policy, and pure function-agent diff truncation into product-domains while keeping core-owned IO, worker execution, host dispatch, built-in source lookup, and Git/AI calls unchanged. --- AGENTS.md | 9 +- docs/architecture/core-decomposition.md | 8 + docs/plans/core-decomposition-plan.md | 2 + scripts/check-core-boundaries.mjs | 164 +++++++++- .../git-func-agent/ai_service.rs | 6 +- src/crates/core/src/miniapp/manager.rs | 241 +++++---------- src/crates/core/src/miniapp/mod.rs | 6 +- src/crates/core/src/miniapp/runtime_detect.rs | 28 +- src/crates/core/src/miniapp/storage.rs | 42 +-- src/crates/product-domains/AGENTS.md | 18 +- .../function_agents/git_func_agent/utils.rs | 11 + .../src/miniapp/customization.rs | 190 ++++++++++++ .../product-domains/src/miniapp/draft.rs | 71 +++++ src/crates/product-domains/src/miniapp/mod.rs | 1 + .../product-domains/src/miniapp/runtime.rs | 22 +- .../product-domains/src/miniapp/storage.rs | 43 +++ .../tests/function_agent_contracts.rs | 31 +- .../tests/miniapp_contracts.rs | 287 +++++++++++++++++- 18 files changed, 938 insertions(+), 242 deletions(-) create mode 100644 src/crates/product-domains/src/miniapp/draft.rs diff --git a/AGENTS.md b/AGENTS.md index a1d72904ec..e3f62660f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,9 +159,12 @@ and milestone verification gates. - Remote-SSH path, session identity, mirror path, and unresolved-session layout helpers belong in `bitfun-services-integrations`; core may inject `PathManager` and hold SSH manager / remote FS / terminal assembly. -- MiniApp storage shape belongs in `bitfun-product-domains`; core storage - keeps filesystem IO, worker runtime, `PathManager`, and port adapters until a - reviewed runtime migration exists. +- MiniApp storage/draft shape, runtime search-plan helpers, customization + metadata policy including built-in update/decline decisions, and + function-agent pure prompt/diff parsing helpers belong in + `bitfun-product-domains`; core keeps filesystem IO, worker runtime, + built-in source-hash lookup, `PathManager`, Git/AI calls, and port adapters + until reviewed runtime migrations exist. - Remote-connect port baselines live in `bitfun-runtime-ports` and `bitfun-services-integrations`; tracker state and tracker event reduction belong in `bitfun-services-integrations`. Remote command/response wire DTOs, diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index ff17fa1c97..6c6c0f37d2 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -171,6 +171,14 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate contract,并承载 function-agent 的纯 prompt / AI response parsing policy;core-owned adapter 只能在不改变执行路径的前提下委托现有 service,并先补等价测试。IO/进程/AI/Git 执行 owner 迁移仍属于后续高风险步骤。 + - 2026-05-18 product-domain readiness update: MiniApp draft DTO/response shape, + draft/customization storage path shape, runtime executable search-plan helpers, + customization draft-apply / built-in update-decline metadata policy, and + Git function-agent diff truncation policy now live in + `bitfun-product-domains`. Core still owns MiniApp filesystem IO, worker + process execution, built-in asset seeding/source-hash lookup, + host-dispatch execution, `PathManager` integration, function-agent Git/AI + calls, prompt templates, JSON extraction, and error mapping. - 高风险:`ToolUseContext`、product tool registry / manifest / exposure / `GetToolSpec` owner 化、 MCP concrete tool integration、remote-connect、remote SSH runtime、miniapp / function-agent runtime、 agent registry、`bitfun-core default = []` diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 6947730be0..33035919f9 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1121,7 +1121,9 @@ product-full = ["miniapp", "function-agents"] - 已迁移到 `bitfun-product-domains::miniapp`:`types`、`bridge_builder`、`permission_policy`,core 旧路径继续 re-export。 - 已迁移到 `bitfun-product-domains::miniapp`:纯 compiler、export DTO、runtime detection DTO、runtime search path plan、worker install result DTO、worker install 命令选择、package.json storage-shape helper、lifecycle / revision helper、host routing string / allowlist policy helper、customization metadata / permission diff,以及 runtime/storage port contract;core `miniapp::compiler::compile` 继续映射为原 `BitFunResult` API,runtime detection / exporter / host dispatch 执行 / customization draft 存储与应用 / worker pool / storage IO 执行逻辑仍留在 core,目前仅通过 core-owned storage/runtime adapter 和等价测试保护现有路径。 +- 2026-05-18 update: MiniApp draft manifest/response DTO, draft/customization storage path helpers, runtime executable search-plan helpers, customization draft-apply metadata policy, and built-in update/decline metadata decisions have been moved to `bitfun-product-domains::miniapp`; core continues to own draft filesystem IO, built-in asset seeding/source-hash lookup, host dispatch execution, `PathManager` integration, worker process execution, and compatibility facades. - 已迁移到 `bitfun-product-domains::function_agents`:公共 `common` 类型、git/startchat function-agent 的纯 DTO 类型、git function-agent 的纯路径 / 变更分类 / commit summary / message assembly / prompt format / commit type parser / AI response parsing policy、startchat prompt / action / AI response parsing policy / git porcelain / diff combine / time-of-day helper、Git/AI port contract,以及只读本地文件的 project context analyzer;core-owned Git snapshot adapter 已由等价测试覆盖,AI client、Git service、prompt template、AI request、JSON extraction、错误映射与分析运行逻辑仍留在 core。 +- 2026-05-18 update: Git function-agent diff truncation is now an owner-crate helper used by core; AI client calls, prompt template ownership, JSON extraction, error mapping, and runtime analysis execution remain core-owned. - boundary check 已补充 product-domain owner anchor:`MiniAppStoragePort` / `MiniAppRuntimePort` 的 core adapter、MiniApp host/customization 纯 contract、function-agent Git adapter 与 AI response parsing helper 必须存在,防止把 port contract 或 pure parser 误读成 storage IO、worker process、host dispatch、customization draft runtime、Git/AI service runtime 已完成迁移。 - miniapp runtime/storage/manager/host dispatch/exporter/builtin 与 function-agent 运行逻辑继续迁移前,需要先确认 agent/tool/provider port 和 Git/AI service 边界。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 97746d8120..6181b74ceb 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -1834,6 +1834,60 @@ const requiredContentRules = [ regex: /\bpub fn versions_dir\b/, message: 'missing MiniApp versions directory layout helper', }, + { + regex: /\bpub const DRAFT_JSON\b/, + message: 'missing MiniApp draft manifest filename contract', + }, + { + regex: /\bpub fn draft_dir\b/, + message: 'missing MiniApp draft directory layout helper', + }, + { + regex: /\bpub fn customization_path\b/, + message: 'missing MiniApp customization metadata path helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/draft.rs', + reason: + 'product-domains owns MiniApp draft DTO and response shape while core keeps draft filesystem IO', + patterns: [ + { + regex: /\bpub struct MiniAppDraftManifest\b/, + message: 'missing MiniApp draft manifest DTO', + }, + { + regex: /\bpub struct MiniAppDraft\b/, + message: 'missing MiniApp draft response DTO', + }, + { + regex: /\bpub fn build_draft_manifest\b/, + message: 'missing MiniApp draft manifest helper', + }, + { + regex: /\bpub fn build_draft_response\b/, + message: 'missing MiniApp draft response helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/runtime.rs', + reason: + 'product-domains owns MiniApp runtime search-plan contracts while core keeps executable lookup and version process execution', + patterns: [ + { + regex: /\bpub fn runtime_lookup_order\b/, + message: 'missing MiniApp runtime lookup order contract', + }, + { + regex: /\bpub fn candidate_executable_path\b/, + message: 'missing MiniApp runtime candidate executable helper', + }, + { + regex: /\bpub fn versioned_executable_candidate\b/, + message: 'missing MiniApp version-manager executable helper', + }, ], }, { @@ -1858,12 +1912,16 @@ const requiredContentRules = [ { path: 'src/crates/product-domains/src/miniapp/customization.rs', reason: - 'product-domains owns MiniApp customization metadata and permission-diff contracts while core keeps draft storage/runtime', + 'product-domains owns MiniApp customization metadata, built-in update policy, and permission-diff contracts while core keeps draft storage/runtime', patterns: [ { regex: /\bpub struct MiniAppCustomizationMetadata\b/, message: 'missing MiniApp customization metadata contract', }, + { + regex: /\bpub struct MiniAppDeclinedBuiltinUpdate\b/, + message: 'missing MiniApp declined built-in update contract', + }, { regex: /\bpub struct MiniAppPermissionDiff\b/, message: 'missing MiniApp permission diff contract', @@ -1872,6 +1930,45 @@ const requiredContentRules = [ regex: /\bpub fn diff_permissions\b/, message: 'missing MiniApp permission diff helper', }, + { + regex: /\bpub fn apply_draft_customization_metadata\b/, + message: 'missing MiniApp customization draft-apply helper', + }, + { + regex: /\bpub fn mark_builtin_update_available_metadata\b/, + message: 'missing MiniApp built-in update availability helper', + }, + { + regex: /\bpub fn decline_builtin_update_metadata\b/, + message: 'missing MiniApp built-in update decline helper', + }, + { + regex: /\bpub fn is_current_declined_builtin_update\b/, + message: 'missing MiniApp declined update current-state helper', + }, + ], + }, + { + path: 'src/crates/core/src/miniapp/manager.rs', + reason: + 'core MiniApp manager must use product-domain customization metadata policy while retaining storage IO and built-in source-hash lookup', + patterns: [ + { + regex: /\bapply_draft_customization_metadata\b/, + message: 'missing product-domain draft customization helper use', + }, + { + regex: /\bmark_builtin_update_available_metadata\b/, + message: 'missing product-domain built-in update availability helper use', + }, + { + regex: /\bdecline_builtin_update_metadata\b/, + message: 'missing product-domain built-in update decline helper use', + }, + { + regex: /\bstorage\.load_customization_metadata\b/, + message: 'missing core-owned customization metadata storage IO', + }, ], }, { @@ -1898,6 +1995,33 @@ const requiredContentRules = [ regex: /\bpub fn parse_commit_analysis_value\b/, message: 'missing Git function-agent commit analysis value parser', }, + { + regex: /\bpub fn truncate_diff_for_commit_prompt\b/, + message: 'missing Git function-agent diff truncation helper', + }, + ], + }, + { + path: 'src/crates/core/src/miniapp/runtime_detect.rs', + reason: + 'core MiniApp runtime detection must use product-domain search-plan helpers while retaining process-backed executable/version checks', + patterns: [ + { + regex: /\bruntime_lookup_order\b/, + message: 'missing product-domain runtime lookup order use', + }, + { + regex: /\bcandidate_executable_path\b/, + message: 'missing product-domain candidate executable helper use', + }, + { + regex: /\bversioned_executable_candidate\b/, + message: 'missing product-domain version-manager executable helper use', + }, + { + regex: /\bget_version\b/, + message: 'missing core-owned version process execution', + }, ], }, { @@ -2560,7 +2684,23 @@ function runManifestParserSelfTest() { }, { path: 'src/crates/product-domains/src/miniapp/storage.rs', - contracts: ['MiniAppStorageLayout', 'META_JSON', 'source_file_path', 'versions_dir'], + contracts: [ + 'MiniAppStorageLayout', + 'META_JSON', + 'source_file_path', + 'versions_dir', + 'DRAFT_JSON', + 'draft_dir', + 'customization_path', + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/draft.rs', + contracts: ['MiniAppDraftManifest', 'MiniAppDraft', 'build_draft_manifest', 'build_draft_response'], + }, + { + path: 'src/crates/product-domains/src/miniapp/runtime.rs', + contracts: ['runtime_lookup_order', 'candidate_executable_path', 'versioned_executable_candidate'], }, { path: 'src/crates/product-domains/src/miniapp/host_routing.rs', @@ -2574,8 +2714,22 @@ function runManifestParserSelfTest() { path: 'src/crates/product-domains/src/miniapp/customization.rs', contracts: [ 'MiniAppCustomizationMetadata', + 'MiniAppDeclinedBuiltinUpdate', 'MiniAppPermissionDiff', 'diff_permissions', + 'apply_draft_customization_metadata', + 'mark_builtin_update_available_metadata', + 'decline_builtin_update_metadata', + 'is_current_declined_builtin_update', + ], + }, + { + path: 'src/crates/core/src/miniapp/manager.rs', + contracts: [ + 'apply_draft_customization_metadata', + 'mark_builtin_update_available_metadata', + 'decline_builtin_update_metadata', + 'storage.load_customization_metadata', ], }, { @@ -2584,7 +2738,11 @@ function runManifestParserSelfTest() { }, { path: 'src/crates/product-domains/src/function_agents/git_func_agent/utils.rs', - contracts: ['parse_commit_analysis_value'], + contracts: ['parse_commit_analysis_value', 'truncate_diff_for_commit_prompt'], + }, + { + path: 'src/crates/core/src/miniapp/runtime_detect.rs', + contracts: ['runtime_lookup_order', 'candidate_executable_path', 'versioned_executable_candidate', 'get_version'], }, ]; for (const { path, contracts } of requiredContentContracts) { diff --git a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs index 17cd3ecba6..c40435fe68 100644 --- a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs +++ b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs @@ -7,6 +7,7 @@ use crate::util::types::Message; * * Handles AI client interaction and provides intelligent analysis for commit message generation */ +use bitfun_product_domains::function_agents::git_func_agent::truncate_diff_for_commit_prompt; use log::{debug, error, warn}; use std::sync::Arc; @@ -119,9 +120,6 @@ impl AIAnalysisService { max_chars ); - let mut truncated = diff.chars().take(max_chars - 100).collect::(); - truncated.push_str("\n\n... [content truncated] ..."); - - truncated + truncate_diff_for_commit_prompt(diff, max_chars) } } diff --git a/src/crates/core/src/miniapp/manager.rs b/src/crates/core/src/miniapp/manager.rs index c04f22afdf..5e942c54b0 100644 --- a/src/crates/core/src/miniapp/manager.rs +++ b/src/crates/core/src/miniapp/manager.rs @@ -8,16 +8,19 @@ use crate::miniapp::types::{ }; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_product_domains::miniapp::customization::{ - diff_permissions, MiniAppAvailableBuiltinUpdate, MiniAppCustomizationMetadata, - MiniAppCustomizationOrigin, MiniAppCustomizationOriginKind, MiniAppDeclinedBuiltinUpdate, - MiniAppPermissionDiff, + MiniAppCustomizationBaseline, MiniAppCustomizationLocalSnapshot, MiniAppCustomizationMetadata, + MiniAppPermissionDiff, apply_draft_customization_metadata, decline_builtin_update_metadata, + declined_builtin_update_needs_local_snapshot, diff_permissions, + is_current_declined_builtin_update, mark_builtin_update_available_metadata, +}; +use bitfun_product_domains::miniapp::draft::{ + MiniAppDraft, MiniAppDraftManifest, build_draft_manifest, build_draft_response, }; use bitfun_product_domains::miniapp::lifecycle::{ build_deps_revision, build_runtime_state, build_source_revision, build_worker_revision, ensure_runtime_state, workspace_dir_string, }; use chrono::Utc; -use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; @@ -25,7 +28,6 @@ use tokio::sync::RwLock; use uuid::Uuid; static GLOBAL_MINIAPP_MANAGER: OnceLock> = OnceLock::new(); -const MAX_DECLINED_BUILTIN_UPDATES: usize = 16; /// Initialize the global MiniAppManager (called once at startup from Tauri app_state). pub fn initialize_global_miniapp_manager(manager: Arc) { @@ -45,30 +47,6 @@ pub struct MiniAppManager { granted_paths: RwLock>>, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MiniAppDraftManifest { - pub app_id: String, - pub draft_id: String, - pub source_version: u32, - pub status: String, - pub created_at: i64, - pub updated_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MiniAppDraft { - pub app_id: String, - pub draft_id: String, - pub source_version: u32, - pub status: String, - pub created_at: i64, - pub updated_at: i64, - pub draft_root: String, - pub app: MiniApp, -} - impl MiniAppManager { pub fn new(path_manager: Arc) -> Self { let storage = MiniAppStorage::new(path_manager.clone()); @@ -382,14 +360,7 @@ impl MiniAppManager { )?; ensure_runtime_state(&mut app); - let manifest = MiniAppDraftManifest { - app_id: app_id.to_string(), - draft_id, - source_version: app.version, - status: "draft".to_string(), - created_at: now, - updated_at: now, - }; + let manifest = build_draft_manifest(app_id, draft_id, app.version, now); self.save_draft_with_manifest(app_id, app, manifest).await } @@ -549,22 +520,12 @@ impl MiniAppManager { app: MiniApp, manifest: MiniAppDraftManifest, ) -> MiniAppDraft { - let draft_id = manifest.draft_id; let draft_root = self .storage - .draft_dir(app_id, &draft_id) + .draft_dir(app_id, &manifest.draft_id) .to_string_lossy() .to_string(); - MiniAppDraft { - app_id: manifest.app_id, - draft_id, - source_version: manifest.source_version, - status: manifest.status, - created_at: manifest.created_at, - updated_at: manifest.updated_at, - draft_root, - app, - } + build_draft_response(draft_root, app, manifest) } async fn load_draft_manifest( @@ -596,55 +557,19 @@ impl MiniAppManager { draft_id: &str, now: i64, ) -> BitFunResult<()> { - let mut metadata = - if let Some(existing) = self.storage.load_customization_metadata(app_id).await? { - existing - } else if let Some(builtin) = crate::miniapp::BUILTIN_APPS - .iter() - .find(|builtin| builtin.id == app_id) - { - MiniAppCustomizationMetadata { - origin: MiniAppCustomizationOrigin { - kind: MiniAppCustomizationOriginKind::Builtin, - builtin_id: Some(builtin.id.to_string()), - builtin_version: Some(builtin.version), - }, - local_override: true, - last_applied_draft_id: None, - available_builtin_update: None, - declined_builtin_updates: Vec::new(), - updated_at: now, - } - } else { - MiniAppCustomizationMetadata { - origin: MiniAppCustomizationOrigin { - kind: MiniAppCustomizationOriginKind::UserCreated, - builtin_id: None, - builtin_version: None, - }, - local_override: false, - last_applied_draft_id: None, - available_builtin_update: None, - declined_builtin_updates: Vec::new(), - updated_at: now, - } - }; - - if matches!( - metadata.origin.kind, - MiniAppCustomizationOriginKind::Builtin - ) { - metadata.local_override = true; - if let Some(builtin) = crate::miniapp::BUILTIN_APPS - .iter() - .find(|builtin| builtin.id == app_id) - { - metadata.origin.builtin_version = Some(builtin.version); - metadata.available_builtin_update = None; + let existing = self.storage.load_customization_metadata(app_id).await?; + let baseline = if let Some(builtin) = crate::miniapp::BUILTIN_APPS + .iter() + .find(|builtin| builtin.id == app_id) + { + MiniAppCustomizationBaseline::Builtin { + builtin_id: builtin.id.to_string(), + builtin_version: builtin.version, } - } - metadata.last_applied_draft_id = Some(draft_id.to_string()); - metadata.updated_at = now; + } else { + MiniAppCustomizationBaseline::UserCreated + }; + let metadata = apply_draft_customization_metadata(existing, baseline, draft_id, now); self.storage .save_customization_metadata(app_id, &metadata) .await @@ -657,30 +582,23 @@ impl MiniAppManager { source_hash: &str, detected_at: i64, ) -> BitFunResult { - if let Some(mut metadata) = self.storage.load_customization_metadata(app_id).await? { - if self + if let Some(metadata) = self.storage.load_customization_metadata(app_id).await? { + let declined_update_current = self .has_matching_declined_builtin_update(app_id, &metadata, source_hash) - .await? - { - if metadata.available_builtin_update.is_some() { - metadata.available_builtin_update = None; - self.storage - .save_customization_metadata(app_id, &metadata) - .await?; - } - return Ok(false); - } - - metadata.available_builtin_update = Some(MiniAppAvailableBuiltinUpdate { + .await?; + let decision = mark_builtin_update_available_metadata( + metadata, builtin_version, - source_hash: source_hash.to_string(), + source_hash, detected_at, - }); - metadata.updated_at = detected_at; - self.storage - .save_customization_metadata(app_id, &metadata) - .await?; - return Ok(true); + declined_update_current, + ); + if decision.metadata_changed { + self.storage + .save_customization_metadata(app_id, &decision.metadata) + .await?; + } + return Ok(decision.should_surface_update); } Ok(false) } @@ -691,24 +609,25 @@ impl MiniAppManager { metadata: &MiniAppCustomizationMetadata, source_hash: &str, ) -> BitFunResult { - let Some(record) = metadata - .declined_builtin_updates - .iter() - .rev() - .find(|record| record.source_hash == source_hash) - else { - return Ok(false); - }; - - if let (Some(record_version), Some(record_updated_at)) = - (record.local_app_version, record.local_app_updated_at) + let local_snapshot = if declined_builtin_update_needs_local_snapshot(metadata, source_hash) { - if let Ok(app) = self.storage.load(app_id).await { - return Ok(app.version == record_version && app.updated_at == record_updated_at); - } - } + self.storage + .load(app_id) + .await + .ok() + .map(|app| MiniAppCustomizationLocalSnapshot { + version: app.version, + updated_at: app.updated_at, + }) + } else { + None + }; - Ok(record.last_applied_draft_id == metadata.last_applied_draft_id) + Ok(is_current_declined_builtin_update( + metadata, + source_hash, + local_snapshot, + )) } pub async fn decline_builtin_update( @@ -718,48 +637,26 @@ impl MiniAppManager { source_hash: &str, declined_at: i64, ) -> BitFunResult> { - let Some(mut metadata) = self.storage.load_customization_metadata(app_id).await? else { + let Some(metadata) = self.storage.load_customization_metadata(app_id).await? else { return Ok(None); }; - let app_snapshot = self - .storage - .load(app_id) - .await - .ok() - .map(|app| (app.version, app.updated_at)); - let (local_app_version, local_app_updated_at) = app_snapshot - .map(|(version, updated_at)| (Some(version), Some(updated_at))) - .unwrap_or((None, None)); - - if let Some(record) = metadata.declined_builtin_updates.iter_mut().find(|record| { - record.builtin_version == builtin_version - && record.source_hash == source_hash - && record.last_applied_draft_id == metadata.last_applied_draft_id - }) { - record.declined_at = declined_at; - record.local_app_version = local_app_version; - record.local_app_updated_at = local_app_updated_at; - } else { - metadata - .declined_builtin_updates - .push(MiniAppDeclinedBuiltinUpdate { - builtin_version, - source_hash: source_hash.to_string(), - declined_at, - local_app_version, - local_app_updated_at, - last_applied_draft_id: metadata.last_applied_draft_id.clone(), + let local_snapshot = + self.storage + .load(app_id) + .await + .ok() + .map(|app| MiniAppCustomizationLocalSnapshot { + version: app.version, + updated_at: app.updated_at, }); - if metadata.declined_builtin_updates.len() > MAX_DECLINED_BUILTIN_UPDATES { - let remove_count = - metadata.declined_builtin_updates.len() - MAX_DECLINED_BUILTIN_UPDATES; - metadata.declined_builtin_updates.drain(0..remove_count); - } - } - - metadata.available_builtin_update = None; - metadata.updated_at = declined_at; + let metadata = decline_builtin_update_metadata( + metadata, + builtin_version, + source_hash, + declined_at, + local_snapshot, + ); self.storage .save_customization_metadata(app_id, &metadata) .await?; diff --git a/src/crates/core/src/miniapp/mod.rs b/src/crates/core/src/miniapp/mod.rs index 2426e27fb0..870ef81b8f 100644 --- a/src/crates/core/src/miniapp/mod.rs +++ b/src/crates/core/src/miniapp/mod.rs @@ -13,15 +13,15 @@ pub use bitfun_product_domains::miniapp::customization::{ MiniAppAvailableBuiltinUpdate, MiniAppCustomizationMetadata, MiniAppCustomizationOrigin, MiniAppCustomizationOriginKind, MiniAppDeclinedBuiltinUpdate, MiniAppPermissionDiff, }; +pub use bitfun_product_domains::miniapp::draft::{MiniAppDraft, MiniAppDraftManifest}; pub use bitfun_product_domains::miniapp::{bridge_builder, permission_policy, types}; -pub use builtin::{seed_builtin_miniapps, BuiltinApp, BUILTIN_APPS}; +pub use builtin::{BUILTIN_APPS, BuiltinApp, seed_builtin_miniapps}; pub use exporter::{ExportCheckResult, ExportOptions, ExportResult, ExportTarget, MiniAppExporter}; pub use host_dispatch::{dispatch_host, is_host_primitive}; pub use js_worker_pool::{InstallResult, JsWorkerPool}; pub use manager::{ - initialize_global_miniapp_manager, try_get_global_miniapp_manager, MiniAppDraft, - MiniAppDraftManifest, MiniAppManager, + MiniAppManager, initialize_global_miniapp_manager, try_get_global_miniapp_manager, }; pub use permission_policy::resolve_policy; pub use runtime_detect::{DetectedRuntime, RuntimeKind}; diff --git a/src/crates/core/src/miniapp/runtime_detect.rs b/src/crates/core/src/miniapp/runtime_detect.rs index a247f74917..6fc56c01e8 100644 --- a/src/crates/core/src/miniapp/runtime_detect.rs +++ b/src/crates/core/src/miniapp/runtime_detect.rs @@ -14,24 +14,24 @@ use std::path::{Path, PathBuf}; -use bitfun_product_domains::miniapp::runtime::{candidate_dirs, version_manager_roots}; pub use bitfun_product_domains::miniapp::runtime::{DetectedRuntime, RuntimeKind}; +use bitfun_product_domains::miniapp::runtime::{ + candidate_dirs, candidate_executable_path, runtime_kind_for_executable, runtime_lookup_order, + version_manager_roots, versioned_executable_candidate, +}; /// Detect available JS runtime: Bun first, then Node.js. Returns None if neither is available. pub fn detect_runtime() -> Option { - if let Some(p) = find_executable("bun") { + for name in runtime_lookup_order() { + let Some(kind) = runtime_kind_for_executable(name) else { + continue; + }; + let Some(p) = find_executable(name) else { + continue; + }; if let Ok(version) = get_version(&p) { return Some(DetectedRuntime { - kind: RuntimeKind::Bun, - path: p, - version, - }); - } - } - if let Some(p) = find_executable("node") { - if let Ok(version) = get_version(&p) { - return Some(DetectedRuntime { - kind: RuntimeKind::Node, + kind, path: p, version, }); @@ -46,7 +46,7 @@ fn find_executable(name: &str) -> Option { } let home = home_dir(); for candidate in candidate_dirs(home.as_deref()) { - let exe = candidate.join(name); + let exe = candidate_executable_path(candidate, name); if is_executable(&exe) { return Some(exe); } @@ -55,7 +55,7 @@ fn find_executable(name: &str) -> Option { for root in version_manager_roots(home.as_deref()) { if let Ok(read) = std::fs::read_dir(&root) { for entry in read.flatten() { - let exe = entry.path().join("bin").join(name); + let exe = versioned_executable_candidate(entry.path(), name); if is_executable(&exe) { return Some(exe); } diff --git a/src/crates/core/src/miniapp/storage.rs b/src/crates/core/src/miniapp/storage.rs index 0ea000d670..19f47bc8b5 100644 --- a/src/crates/core/src/miniapp/storage.rs +++ b/src/crates/core/src/miniapp/storage.rs @@ -7,19 +7,15 @@ use bitfun_product_domains::miniapp::ports::{ MiniAppPortError, MiniAppPortErrorKind, MiniAppPortFuture, MiniAppStoragePort, }; use bitfun_product_domains::miniapp::storage::{ - build_package_json, parse_npm_dependencies, MiniAppStorageLayout, COMPILED_HTML, ESM_DEPS_JSON, - INDEX_HTML, META_JSON, PACKAGE_JSON, SOURCE_DIR, STORAGE_JSON, STYLE_CSS, UI_JS, WORKER_JS, + COMPILED_HTML, DRAFT_JSON, DRAFTS_CLEANUP_MARKER, DRAFTS_CLEANUP_PREFIX, DRAFTS_DIR, + ESM_DEPS_JSON, INDEX_HTML, META_JSON, MiniAppStorageLayout, PACKAGE_JSON, STORAGE_JSON, + STYLE_CSS, UI_JS, WORKER_JS, build_package_json, parse_npm_dependencies, }; use serde_json; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -const DRAFTS_DIR: &str = ".drafts"; -const DRAFTS_CLEANUP_PREFIX: &str = ".drafts.cleanup-"; -const DRAFTS_CLEANUP_MARKER: &str = ".cleanup-pending"; -const DRAFT_JSON: &str = "draft.json"; -const CUSTOMIZATION_JSON: &str = ".customization.json"; /// MiniApp storage service (file-based under path_manager.miniapps_dir). pub struct MiniAppStorage { path_manager: Arc, @@ -59,23 +55,22 @@ impl MiniAppStorage { } pub fn drafts_root(&self) -> PathBuf { - self.path_manager.miniapps_dir().join(DRAFTS_DIR) + MiniAppStorageLayout::drafts_root(self.path_manager.miniapps_dir()) } pub fn app_drafts_dir(&self, app_id: &str) -> PathBuf { - self.drafts_root().join(app_id) + MiniAppStorageLayout::app_drafts_dir(self.path_manager.miniapps_dir(), app_id) } pub fn draft_dir(&self, app_id: &str, draft_id: &str) -> PathBuf { - self.app_drafts_dir(app_id).join(draft_id) + MiniAppStorageLayout::draft_dir(self.path_manager.miniapps_dir(), app_id, draft_id) } fn cleanup_drafts_root(&self) -> PathBuf { - self.path_manager.miniapps_dir().join(format!( - "{}{}", - DRAFTS_CLEANUP_PREFIX, - uuid::Uuid::new_v4() - )) + MiniAppStorageLayout::cleanup_drafts_root( + self.path_manager.miniapps_dir(), + &uuid::Uuid::new_v4().to_string(), + ) } fn cleanup_marker_path(&self, drafts_root: &Path) -> PathBuf { @@ -94,11 +89,11 @@ impl MiniAppStorage { } fn draft_source_dir(&self, app_id: &str, draft_id: &str) -> PathBuf { - self.draft_dir(app_id, draft_id).join(SOURCE_DIR) + MiniAppStorageLayout::draft_source_dir(self.path_manager.miniapps_dir(), app_id, draft_id) } fn customization_path(&self, app_id: &str) -> PathBuf { - self.app_dir(app_id).join(CUSTOMIZATION_JSON) + self.layout(app_id).customization_path() } /// Ensure app directory and source subdir exist. @@ -861,11 +856,7 @@ mod tests { impl TestTempDir { fn new(prefix: &str) -> Self { - let path = std::env::temp_dir().join(format!( - "{}-{}", - prefix, - uuid::Uuid::new_v4() - )); + let path = std::env::temp_dir().join(format!("{}-{}", prefix, uuid::Uuid::new_v4())); fs::create_dir_all(&path).expect("test root should be created"); Self { path } } @@ -884,10 +875,9 @@ mod tests { #[tokio::test] async fn storage_port_adapter_preserves_existing_file_lifecycle() { let root = TestTempDir::new("bitfun-miniapp-storage-port"); - let path_manager = - Arc::new(crate::infrastructure::PathManager::with_user_root_for_tests( - root.path().to_path_buf(), - )); + let path_manager = Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests(root.path().to_path_buf()), + ); let storage = MiniAppStorage::new(path_manager); let port: &dyn MiniAppStoragePort = &storage; let app = sample_app("demo_app"); diff --git a/src/crates/product-domains/AGENTS.md b/src/crates/product-domains/AGENTS.md index e1b6c56615..424a62d995 100644 --- a/src/crates/product-domains/AGENTS.md +++ b/src/crates/product-domains/AGENTS.md @@ -16,8 +16,8 @@ moves here gradually. service, desktop, network, process, AI, or tool-runtime dependencies. - This crate may own pure DTOs, enums, serialization contracts, search plans, command-selection decisions, host-routing string rules, storage-shape parsers, - small helpers, and file-shape analyzers that use `std` or feature-gated - lightweight dependencies only. + draft/metadata response shapes, small helpers, and file-shape analyzers that + use `std` or feature-gated lightweight dependencies only. - This crate may define product-domain port traits for future runtime migration, but concrete adapters that perform IO, process execution, AI calls, Git service calls, or platform integration still belong outside this crate. @@ -31,6 +31,20 @@ moves here gradually. dependencies, `function-agents` may use function-agent-only dependencies, and `product-full` should only aggregate existing product-domain feature groups. +## Current owners + +- `miniapp` owns MiniApp DTOs, compiler/bridge helpers, storage and draft file + shapes, runtime search-plan helpers, worker install command selection, + lifecycle/revision helpers, host-routing string policy, customization + metadata policy including built-in update/decline decisions, and port traits. +- `function-agents` owns pure function-agent DTOs, prompt assembly helpers, + AI-response parsing policy, diff truncation policy, local file-shape analysis, + and Git/AI port traits. +- Core still owns MiniApp filesystem IO, worker process execution, host dispatch + execution, built-in asset seeding/source-hash lookup, `PathManager` + integration, function-agent Git/AI calls, prompt templates, JSON extraction, + and error mapping. + ## Verification Use the smallest matching check for the changed surface: diff --git a/src/crates/product-domains/src/function_agents/git_func_agent/utils.rs b/src/crates/product-domains/src/function_agents/git_func_agent/utils.rs index b026d622f7..0d1708db18 100644 --- a/src/crates/product-domains/src/function_agents/git_func_agent/utils.rs +++ b/src/crates/product-domains/src/function_agents/git_func_agent/utils.rs @@ -225,6 +225,17 @@ pub fn build_commit_prompt( .replace("{max_title_length}", &options.max_title_length.to_string()) } +pub fn truncate_diff_for_commit_prompt(diff: &str, max_chars: usize) -> String { + if diff.len() <= max_chars { + return diff.to_string(); + } + + let keep_chars = max_chars.saturating_sub(100); + let mut truncated = diff.chars().take(keep_chars).collect::(); + truncated.push_str("\n\n... [content truncated] ..."); + truncated +} + pub fn parse_commit_type_label(label: &str) -> CommitType { match label.to_lowercase().as_str() { "feat" | "feature" => CommitType::Feat, diff --git a/src/crates/product-domains/src/miniapp/customization.rs b/src/crates/product-domains/src/miniapp/customization.rs index 74b8f0be10..9ce12a89e1 100644 --- a/src/crates/product-domains/src/miniapp/customization.rs +++ b/src/crates/product-domains/src/miniapp/customization.rs @@ -49,6 +49,196 @@ pub struct MiniAppCustomizationMetadata { pub updated_at: i64, } +pub const MAX_DECLINED_BUILTIN_UPDATES: usize = 16; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MiniAppCustomizationLocalSnapshot { + pub version: u32, + pub updated_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiniAppBuiltinUpdateAvailabilityDecision { + pub metadata: MiniAppCustomizationMetadata, + pub should_surface_update: bool, + pub metadata_changed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MiniAppCustomizationBaseline { + Builtin { + builtin_id: String, + builtin_version: u32, + }, + UserCreated, +} + +pub fn apply_draft_customization_metadata( + existing: Option, + baseline: MiniAppCustomizationBaseline, + draft_id: &str, + now: i64, +) -> MiniAppCustomizationMetadata { + let mut metadata = existing.unwrap_or_else(|| match baseline.clone() { + MiniAppCustomizationBaseline::Builtin { + builtin_id, + builtin_version, + } => MiniAppCustomizationMetadata { + origin: MiniAppCustomizationOrigin { + kind: MiniAppCustomizationOriginKind::Builtin, + builtin_id: Some(builtin_id), + builtin_version: Some(builtin_version), + }, + local_override: true, + last_applied_draft_id: None, + available_builtin_update: None, + declined_builtin_updates: Vec::new(), + updated_at: now, + }, + MiniAppCustomizationBaseline::UserCreated => MiniAppCustomizationMetadata { + origin: MiniAppCustomizationOrigin { + kind: MiniAppCustomizationOriginKind::UserCreated, + builtin_id: None, + builtin_version: None, + }, + local_override: false, + last_applied_draft_id: None, + available_builtin_update: None, + declined_builtin_updates: Vec::new(), + updated_at: now, + }, + }); + + if matches!( + metadata.origin.kind, + MiniAppCustomizationOriginKind::Builtin + ) { + metadata.local_override = true; + if let MiniAppCustomizationBaseline::Builtin { + builtin_version, .. + } = baseline + { + metadata.origin.builtin_version = Some(builtin_version); + metadata.available_builtin_update = None; + } + } + + metadata.last_applied_draft_id = Some(draft_id.to_string()); + metadata.updated_at = now; + metadata +} + +pub fn declined_builtin_update_needs_local_snapshot( + metadata: &MiniAppCustomizationMetadata, + source_hash: &str, +) -> bool { + metadata + .declined_builtin_updates + .iter() + .rev() + .find(|record| record.source_hash == source_hash) + .is_some_and(|record| { + record.local_app_version.is_some() && record.local_app_updated_at.is_some() + }) +} + +pub fn is_current_declined_builtin_update( + metadata: &MiniAppCustomizationMetadata, + source_hash: &str, + local_snapshot: Option, +) -> bool { + let Some(record) = metadata + .declined_builtin_updates + .iter() + .rev() + .find(|record| record.source_hash == source_hash) + else { + return false; + }; + + if let (Some(record_version), Some(record_updated_at), Some(snapshot)) = ( + record.local_app_version, + record.local_app_updated_at, + local_snapshot, + ) { + return snapshot.version == record_version && snapshot.updated_at == record_updated_at; + } + + record.last_applied_draft_id == metadata.last_applied_draft_id +} + +pub fn mark_builtin_update_available_metadata( + mut metadata: MiniAppCustomizationMetadata, + builtin_version: u32, + source_hash: &str, + detected_at: i64, + declined_update_current: bool, +) -> MiniAppBuiltinUpdateAvailabilityDecision { + if declined_update_current { + let metadata_changed = metadata.available_builtin_update.take().is_some(); + return MiniAppBuiltinUpdateAvailabilityDecision { + metadata, + should_surface_update: false, + metadata_changed, + }; + } + + metadata.available_builtin_update = Some(MiniAppAvailableBuiltinUpdate { + builtin_version, + source_hash: source_hash.to_string(), + detected_at, + }); + metadata.updated_at = detected_at; + MiniAppBuiltinUpdateAvailabilityDecision { + metadata, + should_surface_update: true, + metadata_changed: true, + } +} + +pub fn decline_builtin_update_metadata( + mut metadata: MiniAppCustomizationMetadata, + builtin_version: u32, + source_hash: &str, + declined_at: i64, + local_snapshot: Option, +) -> MiniAppCustomizationMetadata { + let (local_app_version, local_app_updated_at) = local_snapshot + .map(|snapshot| (Some(snapshot.version), Some(snapshot.updated_at))) + .unwrap_or((None, None)); + let last_applied_draft_id = metadata.last_applied_draft_id.clone(); + + if let Some(record) = metadata.declined_builtin_updates.iter_mut().find(|record| { + record.builtin_version == builtin_version + && record.source_hash == source_hash + && record.last_applied_draft_id == last_applied_draft_id + }) { + record.declined_at = declined_at; + record.local_app_version = local_app_version; + record.local_app_updated_at = local_app_updated_at; + } else { + metadata + .declined_builtin_updates + .push(MiniAppDeclinedBuiltinUpdate { + builtin_version, + source_hash: source_hash.to_string(), + declined_at, + local_app_version, + local_app_updated_at, + last_applied_draft_id, + }); + if metadata.declined_builtin_updates.len() > MAX_DECLINED_BUILTIN_UPDATES { + let remove_count = + metadata.declined_builtin_updates.len() - MAX_DECLINED_BUILTIN_UPDATES; + metadata.declined_builtin_updates.drain(0..remove_count); + } + } + + metadata.available_builtin_update = None; + metadata.updated_at = declined_at; + metadata +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MiniAppPermissionDiff { pub high_risk: bool, diff --git a/src/crates/product-domains/src/miniapp/draft.rs b/src/crates/product-domains/src/miniapp/draft.rs new file mode 100644 index 0000000000..7e03f41aee --- /dev/null +++ b/src/crates/product-domains/src/miniapp/draft.rs @@ -0,0 +1,71 @@ +//! MiniApp draft DTOs and pure response helpers. + +use crate::miniapp::types::MiniApp; +use serde::{Deserialize, Serialize}; + +pub const MINIAPP_DRAFT_STATUS_DRAFT: &str = "draft"; +pub const MINIAPP_DRAFT_STATUS_APPLIED: &str = "applied"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppDraftManifest { + pub app_id: String, + pub draft_id: String, + pub source_version: u32, + pub status: String, + pub created_at: i64, + pub updated_at: i64, +} + +impl MiniAppDraftManifest { + pub fn mark_applied(&mut self, applied_at: i64) { + self.status = MINIAPP_DRAFT_STATUS_APPLIED.to_string(); + self.updated_at = applied_at; + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppDraft { + pub app_id: String, + pub draft_id: String, + pub source_version: u32, + pub status: String, + pub created_at: i64, + pub updated_at: i64, + pub draft_root: String, + pub app: MiniApp, +} + +pub fn build_draft_manifest( + app_id: impl Into, + draft_id: impl Into, + source_version: u32, + now: i64, +) -> MiniAppDraftManifest { + MiniAppDraftManifest { + app_id: app_id.into(), + draft_id: draft_id.into(), + source_version, + status: MINIAPP_DRAFT_STATUS_DRAFT.to_string(), + created_at: now, + updated_at: now, + } +} + +pub fn build_draft_response( + draft_root: impl Into, + app: MiniApp, + manifest: MiniAppDraftManifest, +) -> MiniAppDraft { + MiniAppDraft { + app_id: manifest.app_id, + draft_id: manifest.draft_id, + source_version: manifest.source_version, + status: manifest.status, + created_at: manifest.created_at, + updated_at: manifest.updated_at, + draft_root: draft_root.into(), + app, + } +} diff --git a/src/crates/product-domains/src/miniapp/mod.rs b/src/crates/product-domains/src/miniapp/mod.rs index 07d7e70035..2e64af6964 100644 --- a/src/crates/product-domains/src/miniapp/mod.rs +++ b/src/crates/product-domains/src/miniapp/mod.rs @@ -3,6 +3,7 @@ pub mod bridge_builder; pub mod compiler; pub mod customization; +pub mod draft; pub mod exporter; pub mod host_routing; pub mod lifecycle; diff --git a/src/crates/product-domains/src/miniapp/runtime.rs b/src/crates/product-domains/src/miniapp/runtime.rs index ace43f3aae..005aa67c8d 100644 --- a/src/crates/product-domains/src/miniapp/runtime.rs +++ b/src/crates/product-domains/src/miniapp/runtime.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RuntimeKind { Bun, Node, @@ -15,6 +15,26 @@ pub struct DetectedRuntime { pub version: String, } +pub fn runtime_lookup_order() -> &'static [&'static str] { + &["bun", "node"] +} + +pub fn runtime_kind_for_executable(name: &str) -> Option { + match name { + "bun" => Some(RuntimeKind::Bun), + "node" => Some(RuntimeKind::Node), + _ => None, + } +} + +pub fn candidate_executable_path(dir: impl AsRef, name: &str) -> PathBuf { + dir.as_ref().join(name) +} + +pub fn versioned_executable_candidate(version_dir: impl AsRef, name: &str) -> PathBuf { + version_dir.as_ref().join("bin").join(name) +} + /// Common executable directories checked after PATH lookup. pub fn candidate_dirs(home: Option<&Path>) -> Vec { let mut dirs = vec![ diff --git a/src/crates/product-domains/src/miniapp/storage.rs b/src/crates/product-domains/src/miniapp/storage.rs index d8411e7140..af5922e19a 100644 --- a/src/crates/product-domains/src/miniapp/storage.rs +++ b/src/crates/product-domains/src/miniapp/storage.rs @@ -14,6 +14,11 @@ pub const ESM_DEPS_JSON: &str = "esm_dependencies.json"; pub const COMPILED_HTML: &str = "compiled.html"; pub const STORAGE_JSON: &str = "storage.json"; pub const VERSIONS_DIR: &str = "versions"; +pub const DRAFTS_DIR: &str = ".drafts"; +pub const DRAFTS_CLEANUP_PREFIX: &str = ".drafts.cleanup-"; +pub const DRAFTS_CLEANUP_MARKER: &str = ".cleanup-pending"; +pub const DRAFT_JSON: &str = "draft.json"; +pub const CUSTOMIZATION_JSON: &str = ".customization.json"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct MiniAppStorageLayout { @@ -49,6 +54,10 @@ impl MiniAppStorageLayout { self.app_dir().join(STORAGE_JSON) } + pub fn customization_path(&self) -> PathBuf { + self.app_dir().join(CUSTOMIZATION_JSON) + } + pub fn source_file_path(&self, file_name: &str) -> PathBuf { self.source_dir().join(file_name) } @@ -64,6 +73,40 @@ impl MiniAppStorageLayout { pub fn version_path(&self, version: u32) -> PathBuf { self.versions_dir().join(format!("v{}.json", version)) } + + pub fn drafts_root(miniapps_root: impl AsRef) -> PathBuf { + miniapps_root.as_ref().join(DRAFTS_DIR) + } + + pub fn app_drafts_dir(miniapps_root: impl AsRef, app_id: &str) -> PathBuf { + Self::drafts_root(miniapps_root).join(app_id) + } + + pub fn draft_dir(miniapps_root: impl AsRef, app_id: &str, draft_id: &str) -> PathBuf { + Self::app_drafts_dir(miniapps_root, app_id).join(draft_id) + } + + pub fn draft_source_dir( + miniapps_root: impl AsRef, + app_id: &str, + draft_id: &str, + ) -> PathBuf { + Self::draft_dir(miniapps_root, app_id, draft_id).join(SOURCE_DIR) + } + + pub fn draft_manifest_path( + miniapps_root: impl AsRef, + app_id: &str, + draft_id: &str, + ) -> PathBuf { + Self::draft_dir(miniapps_root, app_id, draft_id).join(DRAFT_JSON) + } + + pub fn cleanup_drafts_root(miniapps_root: impl AsRef, cleanup_id: &str) -> PathBuf { + miniapps_root + .as_ref() + .join(format!("{}{}", DRAFTS_CLEANUP_PREFIX, cleanup_id)) + } } /// Parse package.json dependencies using the legacy MiniApp storage contract. diff --git a/src/crates/product-domains/tests/function_agent_contracts.rs b/src/crates/product-domains/tests/function_agent_contracts.rs index ccfad912fc..eae9a8852a 100644 --- a/src/crates/product-domains/tests/function_agent_contracts.rs +++ b/src/crates/product-domains/tests/function_agent_contracts.rs @@ -1,23 +1,23 @@ #![cfg(feature = "function-agents")] use bitfun_product_domains::function_agents::{ + Language, git_func_agent::{ - assemble_commit_message, build_changes_summary_from_paths, build_commit_prompt, - detect_change_patterns, extract_module_name, infer_file_type, parse_commit_analysis_value, - parse_commit_type_label, ChangePattern, CommitFormat, CommitMessageOptions, CommitType, - FileChange, FileChangeType, ProjectContext, + ChangePattern, CommitFormat, CommitMessageOptions, CommitType, FileChange, FileChangeType, + ProjectContext, assemble_commit_message, build_changes_summary_from_paths, + build_commit_prompt, detect_change_patterns, extract_module_name, infer_file_type, + parse_commit_analysis_value, parse_commit_type_label, truncate_diff_for_commit_prompt, }, ports::{ CommitAiAnalysisRequest, FunctionAgentAiPort, FunctionAgentFuture, FunctionAgentGitPort, GitCommitSnapshot, StartchatGitSnapshot, WorkStateAiAnalysisRequest, }, startchat_func_agent::{ + ActionPriority, GitWorkState, QuickActionType, TimeOfDay, WorkStateOptions, build_complete_analysis_prompt, combine_git_diffs, limit_quick_actions, normalize_predicted_actions, parse_complete_analysis_value, parse_git_status_porcelain, parse_predicted_actions_from_values, parse_quick_actions_from_values, time_of_day_for_hour, - ActionPriority, GitWorkState, QuickActionType, TimeOfDay, WorkStateOptions, }, - Language, }; struct FunctionAgentPortStub; @@ -154,9 +154,11 @@ fn git_function_agent_summary_helpers_preserve_commit_shape() { assert_eq!(summary.file_changes[0].path, "src/crates/core/lib.rs"); assert_eq!(summary.file_changes[0].file_type, "rs"); assert!(summary.affected_modules.contains(&"core".to_string())); - assert!(summary - .change_patterns - .contains(&ChangePattern::DocumentationUpdate)); + assert!( + summary + .change_patterns + .contains(&ChangePattern::DocumentationUpdate) + ); let message = assemble_commit_message( "feat(core): add boundary helper", @@ -203,6 +205,17 @@ fn git_function_agent_analysis_parser_preserves_defaults_and_required_title() { assert_eq!(missing_title.unwrap_err(), "Missing title field"); } +#[test] +fn git_function_agent_diff_truncation_preserves_legacy_marker() { + let short = "diff --git a/lib.rs b/lib.rs"; + assert_eq!(truncate_diff_for_commit_prompt(short, 50), short); + + let long = "a".repeat(140); + let truncated = truncate_diff_for_commit_prompt(&long, 120); + assert!(truncated.starts_with(&"a".repeat(20))); + assert!(truncated.ends_with("\n\n... [content truncated] ...")); +} + #[test] fn startchat_options_preserve_existing_defaults() { let options = WorkStateOptions::default(); diff --git a/src/crates/product-domains/tests/miniapp_contracts.rs b/src/crates/product-domains/tests/miniapp_contracts.rs index ce0fd85119..fc346c64d4 100644 --- a/src/crates/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/product-domains/tests/miniapp_contracts.rs @@ -2,6 +2,17 @@ use bitfun_product_domains::miniapp::bridge_builder::{build_bridge_script, build_csp_content}; use bitfun_product_domains::miniapp::compiler::compile; +use bitfun_product_domains::miniapp::customization::{ + MAX_DECLINED_BUILTIN_UPDATES, MiniAppCustomizationBaseline, MiniAppCustomizationLocalSnapshot, + MiniAppCustomizationMetadata, MiniAppCustomizationOrigin, MiniAppCustomizationOriginKind, + apply_draft_customization_metadata, decline_builtin_update_metadata, + declined_builtin_update_needs_local_snapshot, is_current_declined_builtin_update, + mark_builtin_update_available_metadata, +}; +use bitfun_product_domains::miniapp::draft::{ + MINIAPP_DRAFT_STATUS_APPLIED, MINIAPP_DRAFT_STATUS_DRAFT, build_draft_manifest, + build_draft_response, +}; use bitfun_product_domains::miniapp::exporter::{ExportCheckResult, ExportTarget}; use bitfun_product_domains::miniapp::host_routing::{ command_basename_allowed, command_basename_for_allowlist, host_allowed_by_allowlist, @@ -17,18 +28,20 @@ use bitfun_product_domains::miniapp::ports::{ MiniAppRuntimePort, }; use bitfun_product_domains::miniapp::runtime::{ - candidate_dirs, version_manager_roots, RuntimeKind, + RuntimeKind, candidate_dirs, candidate_executable_path, runtime_lookup_order, + version_manager_roots, versioned_executable_candidate, }; use bitfun_product_domains::miniapp::storage::{ - build_package_json, parse_npm_dependencies, MiniAppStorageLayout, COMPILED_HTML, ESM_DEPS_JSON, - INDEX_HTML, META_JSON, PACKAGE_JSON, SOURCE_DIR, STORAGE_JSON, STYLE_CSS, UI_JS, VERSIONS_DIR, - WORKER_JS, + COMPILED_HTML, CUSTOMIZATION_JSON, DRAFT_JSON, DRAFTS_CLEANUP_MARKER, DRAFTS_CLEANUP_PREFIX, + DRAFTS_DIR, ESM_DEPS_JSON, INDEX_HTML, META_JSON, MiniAppStorageLayout, PACKAGE_JSON, + SOURCE_DIR, STORAGE_JSON, STYLE_CSS, UI_JS, VERSIONS_DIR, WORKER_JS, build_package_json, + parse_npm_dependencies, }; use bitfun_product_domains::miniapp::types::{ FsPermissions, MiniApp, MiniAppPermissions, MiniAppRuntimeState, MiniAppSource, NetPermissions, NotificationPermissions, NpmDep, }; -use bitfun_product_domains::miniapp::worker::{install_command_for_runtime, InstallResult}; +use bitfun_product_domains::miniapp::worker::{InstallResult, install_command_for_runtime}; use std::path::{Path, PathBuf}; struct RuntimePortStub; @@ -189,6 +202,11 @@ fn miniapp_storage_layout_preserves_file_shape_contract() { assert_eq!(COMPILED_HTML, "compiled.html"); assert_eq!(STORAGE_JSON, "storage.json"); assert_eq!(VERSIONS_DIR, "versions"); + assert_eq!(DRAFTS_DIR, ".drafts"); + assert_eq!(DRAFTS_CLEANUP_PREFIX, ".drafts.cleanup-"); + assert_eq!(DRAFTS_CLEANUP_MARKER, ".cleanup-pending"); + assert_eq!(DRAFT_JSON, "draft.json"); + assert_eq!(CUSTOMIZATION_JSON, ".customization.json"); assert_eq!(layout.app_dir(), root.join("app-1")); assert_eq!(layout.meta_path(), root.join("app-1").join(META_JSON)); @@ -201,6 +219,36 @@ fn miniapp_storage_layout_preserves_file_shape_contract() { root.join("app-1").join(VERSIONS_DIR).join("v3.json") ); assert_eq!(layout.versions_dir(), root.join("app-1").join(VERSIONS_DIR)); + assert_eq!( + layout.customization_path(), + root.join("app-1").join(CUSTOMIZATION_JSON) + ); + assert_eq!( + MiniAppStorageLayout::drafts_root(&root), + root.join(DRAFTS_DIR) + ); + assert_eq!( + MiniAppStorageLayout::draft_dir(&root, "app-1", "draft-1"), + root.join(DRAFTS_DIR).join("app-1").join("draft-1") + ); + assert_eq!( + MiniAppStorageLayout::draft_source_dir(&root, "app-1", "draft-1"), + root.join(DRAFTS_DIR) + .join("app-1") + .join("draft-1") + .join(SOURCE_DIR) + ); + assert_eq!( + MiniAppStorageLayout::draft_manifest_path(&root, "app-1", "draft-1"), + root.join(DRAFTS_DIR) + .join("app-1") + .join("draft-1") + .join(DRAFT_JSON) + ); + assert_eq!( + MiniAppStorageLayout::cleanup_drafts_root(&root, "cleanup-id"), + root.join(".drafts.cleanup-cleanup-id") + ); } #[test] @@ -215,6 +263,18 @@ fn miniapp_runtime_search_plan_preserves_common_install_locations() { let roots = version_manager_roots(Some(&home)); assert_eq!(roots[0], home.join(".nvm").join("versions").join("node")); assert!(roots.contains(&home.join(".fnm").join("node-versions"))); + + assert_eq!(runtime_lookup_order(), &["bun", "node"]); + assert_eq!( + candidate_executable_path(Path::new("/usr/local/bin"), "node"), + PathBuf::from("/usr/local/bin").join("node") + ); + assert_eq!( + versioned_executable_candidate(Path::new("/home/bitfun/.nvm/versions/node/v20"), "node"), + PathBuf::from("/home/bitfun/.nvm/versions/node/v20") + .join("bin") + .join("node") + ); } #[test] @@ -383,6 +443,223 @@ fn miniapp_ports_keep_runtime_boundary_lightweight() { let _future = port.detect_runtime(); } +#[test] +fn miniapp_draft_contract_preserves_manifest_and_response_shape() { + let app = sample_miniapp_for_lifecycle(MiniAppSource::default()); + let manifest = build_draft_manifest("app-1", "draft-1", 7, 1234); + + assert_eq!(manifest.app_id, "app-1"); + assert_eq!(manifest.draft_id, "draft-1"); + assert_eq!(manifest.source_version, 7); + assert_eq!(manifest.status, MINIAPP_DRAFT_STATUS_DRAFT); + assert_eq!(manifest.created_at, 1234); + assert_eq!(manifest.updated_at, 1234); + + let json = serde_json::to_value(&manifest).unwrap(); + assert_eq!(json["appId"], "app-1"); + assert_eq!(json["draftId"], "draft-1"); + assert_eq!(json["sourceVersion"], 7); + + let response = build_draft_response("/tmp/draft", app, manifest.clone()); + assert_eq!(response.app_id, "app-1"); + assert_eq!(response.draft_root, "/tmp/draft"); + assert_eq!(response.app.id, "demo"); + + let mut applied = manifest; + applied.mark_applied(2345); + assert_eq!(applied.status, MINIAPP_DRAFT_STATUS_APPLIED); + assert_eq!(applied.updated_at, 2345); +} + +#[test] +fn miniapp_customization_apply_helper_preserves_builtin_override_policy() { + let metadata = apply_draft_customization_metadata( + None, + MiniAppCustomizationBaseline::Builtin { + builtin_id: "builtin-pr-review".to_string(), + builtin_version: 4, + }, + "draft-1", + 1234, + ); + + assert_eq!( + metadata.origin.kind, + MiniAppCustomizationOriginKind::Builtin + ); + assert_eq!( + metadata.origin.builtin_id.as_deref(), + Some("builtin-pr-review") + ); + assert_eq!(metadata.origin.builtin_version, Some(4)); + assert!(metadata.local_override); + assert_eq!(metadata.last_applied_draft_id.as_deref(), Some("draft-1")); + assert!(metadata.available_builtin_update.is_none()); + assert_eq!(metadata.updated_at, 1234); + + let updated = apply_draft_customization_metadata( + Some(metadata), + MiniAppCustomizationBaseline::Builtin { + builtin_id: "builtin-pr-review".to_string(), + builtin_version: 5, + }, + "draft-2", + 2345, + ); + + assert_eq!(updated.origin.builtin_version, Some(5)); + assert!(updated.local_override); + assert_eq!(updated.last_applied_draft_id.as_deref(), Some("draft-2")); + assert!(updated.available_builtin_update.is_none()); + + let user_created = MiniAppCustomizationMetadata { + origin: MiniAppCustomizationOrigin { + kind: MiniAppCustomizationOriginKind::UserCreated, + builtin_id: None, + builtin_version: None, + }, + local_override: false, + last_applied_draft_id: None, + available_builtin_update: None, + declined_builtin_updates: Vec::new(), + updated_at: 10, + }; + let user_created_update = apply_draft_customization_metadata( + Some(user_created), + MiniAppCustomizationBaseline::Builtin { + builtin_id: "builtin-pr-review".to_string(), + builtin_version: 6, + }, + "draft-3", + 3456, + ); + + assert_eq!( + user_created_update.origin.kind, + MiniAppCustomizationOriginKind::UserCreated + ); + assert!(!user_created_update.local_override); + assert_eq!( + user_created_update.last_applied_draft_id.as_deref(), + Some("draft-3") + ); + assert_eq!(user_created_update.updated_at, 3456); +} + +#[test] +fn miniapp_customization_builtin_update_policy_preserves_decline_contract() { + let mut metadata = apply_draft_customization_metadata( + None, + MiniAppCustomizationBaseline::Builtin { + builtin_id: "builtin-pr-review".to_string(), + builtin_version: 4, + }, + "draft-1", + 1234, + ); + + let available = mark_builtin_update_available_metadata(metadata, 5, "hash-v5", 2000, false); + assert!(available.should_surface_update); + assert!(available.metadata_changed); + metadata = available.metadata; + assert_eq!( + metadata + .available_builtin_update + .as_ref() + .unwrap() + .source_hash, + "hash-v5" + ); + + metadata = decline_builtin_update_metadata( + metadata, + 5, + "hash-v5", + 2100, + Some(MiniAppCustomizationLocalSnapshot { + version: 7, + updated_at: 2200, + }), + ); + + assert!(metadata.available_builtin_update.is_none()); + assert_eq!(metadata.updated_at, 2100); + assert_eq!(metadata.declined_builtin_updates.len(), 1); + assert_eq!( + metadata.declined_builtin_updates[0] + .last_applied_draft_id + .as_deref(), + Some("draft-1") + ); + assert!(declined_builtin_update_needs_local_snapshot( + &metadata, "hash-v5" + )); + assert!(is_current_declined_builtin_update( + &metadata, + "hash-v5", + Some(MiniAppCustomizationLocalSnapshot { + version: 7, + updated_at: 2200, + }), + )); + assert!(!is_current_declined_builtin_update( + &metadata, + "hash-v5", + Some(MiniAppCustomizationLocalSnapshot { + version: 8, + updated_at: 2200, + }), + )); + + let suppressed = + mark_builtin_update_available_metadata(metadata.clone(), 5, "hash-v5", 2300, true); + assert!(!suppressed.should_surface_update); + assert!(!suppressed.metadata_changed); + assert!(suppressed.metadata.available_builtin_update.is_none()); + + let fallback = is_current_declined_builtin_update(&metadata, "hash-v5", None); + assert!(fallback); +} + +#[test] +fn miniapp_customization_decline_policy_updates_existing_and_trims_old_records() { + let mut metadata = apply_draft_customization_metadata( + None, + MiniAppCustomizationBaseline::Builtin { + builtin_id: "builtin-pr-review".to_string(), + builtin_version: 4, + }, + "draft-1", + 1000, + ); + + metadata = decline_builtin_update_metadata(metadata, 5, "hash-v5", 2000, None); + metadata = decline_builtin_update_metadata(metadata, 5, "hash-v5", 2500, None); + assert_eq!(metadata.declined_builtin_updates.len(), 1); + assert_eq!(metadata.declined_builtin_updates[0].declined_at, 2500); + + for idx in 0..=MAX_DECLINED_BUILTIN_UPDATES { + metadata = decline_builtin_update_metadata( + metadata, + 6 + idx as u32, + &format!("hash-{}", idx), + 3000 + idx as i64, + None, + ); + } + + assert_eq!( + metadata.declined_builtin_updates.len(), + MAX_DECLINED_BUILTIN_UPDATES + ); + assert!( + !metadata + .declined_builtin_updates + .iter() + .any(|record| record.source_hash == "hash-v5") + ); +} + fn sample_miniapp_for_lifecycle(source: MiniAppSource) -> MiniApp { MiniApp { id: "demo".to_string(), From 2f867eae213fccd37a98c429ee8d4c8b48982de8 Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 18 May 2026 15:00:03 +0800 Subject: [PATCH 24/76] fix(core): avoid session auto-save lock contention --- .../src/agentic/session/session_manager.rs | 229 +++++++++++++++--- 1 file changed, 199 insertions(+), 30 deletions(-) diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 06452d513d..7dd67de635 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -97,6 +97,21 @@ pub struct SessionManager { config: SessionManagerConfig, } +#[derive(Clone)] +struct SessionAutoSaveSnapshot { + session_id: String, + updated_at: SystemTime, + last_activity_at: SystemTime, + session: Session, +} + +#[derive(Clone)] +struct SessionCleanupCandidate { + session_id: String, + updated_at: SystemTime, + last_activity_at: SystemTime, +} + impl SessionManager { async fn resolve_model_context_window(model_id: &str) -> Option { let trimmed = model_id.trim(); @@ -215,6 +230,99 @@ impl SessionManager { Self::should_persist_session_kind(session.kind) } + fn same_session_version( + session: &Session, + updated_at: SystemTime, + last_activity_at: SystemTime, + ) -> bool { + session.updated_at == updated_at && session.last_activity_at == last_activity_at + } + + fn collect_auto_save_snapshots( + sessions: &DashMap, + ) -> Vec { + sessions + .iter() + .filter_map(|entry| { + let session = entry.value(); + if !Self::should_persist_session(session) { + return None; + } + Some(SessionAutoSaveSnapshot { + session_id: session.session_id.clone(), + updated_at: session.updated_at, + last_activity_at: session.last_activity_at, + session: session.clone(), + }) + }) + .collect() + } + + fn auto_save_snapshot_is_current( + sessions: &DashMap, + snapshot: &SessionAutoSaveSnapshot, + ) -> bool { + sessions + .get(&snapshot.session_id) + .map(|session| { + Self::same_session_version(&session, snapshot.updated_at, snapshot.last_activity_at) + }) + .unwrap_or(false) + } + + fn auto_save_interval(interval: Duration) -> time::Interval { + time::interval_at(time::Instant::now() + interval, interval) + } + + fn is_session_expired(session: &Session, now: SystemTime, timeout: Duration) -> bool { + now.duration_since(session.last_activity_at) + .map(|idle_duration| idle_duration > timeout) + .unwrap_or(false) + } + + fn collect_expired_session_candidates( + sessions: &DashMap, + now: SystemTime, + timeout: Duration, + ) -> Vec { + sessions + .iter() + .filter_map(|entry| { + let session = entry.value(); + if !Self::is_session_expired(session, now, timeout) { + return None; + } + Some(SessionCleanupCandidate { + session_id: session.session_id.clone(), + updated_at: session.updated_at, + last_activity_at: session.last_activity_at, + }) + }) + .collect() + } + + fn cleanup_candidate_matches_session( + session: &Session, + candidate: &SessionCleanupCandidate, + now: SystemTime, + timeout: Duration, + ) -> bool { + Self::same_session_version(session, candidate.updated_at, candidate.last_activity_at) + && Self::is_session_expired(session, now, timeout) + } + + fn cleanup_snapshot_for_candidate( + sessions: &DashMap, + candidate: &SessionCleanupCandidate, + now: SystemTime, + timeout: Duration, + ) -> Option { + sessions.get(&candidate.session_id).and_then(|session| { + Self::cleanup_candidate_matches_session(&session, candidate, now, timeout) + .then(|| session.clone()) + }) + } + pub fn should_persist_session_id(&self, session_id: &str) -> bool { self.config.enable_persistence && self @@ -2784,23 +2892,28 @@ impl SessionManager { let interval = self.config.auto_save_interval; tokio::spawn(async move { - let mut ticker = time::interval(interval); + let mut ticker = Self::auto_save_interval(interval); loop { ticker.tick().await; - for entry in sessions.iter() { - let session = entry.value(); - if !Self::should_persist_session(session) { + for snapshot in Self::collect_auto_save_snapshots(&sessions) { + if !Self::auto_save_snapshot_is_current(&sessions, &snapshot) { continue; } if let Some(workspace_path) = - Self::effective_workspace_path_from_config(&session.config).await + Self::effective_workspace_path_from_config(&snapshot.session.config).await { - if let Err(e) = persistence.save_session(&workspace_path, session).await { + if !Self::auto_save_snapshot_is_current(&sessions, &snapshot) { + continue; + } + if let Err(e) = persistence + .save_session(&workspace_path, &snapshot.session) + .await + { error!( "Failed to auto-save session: session_id={}, error={}", - session.session_id, e + snapshot.session_id, e ); } } @@ -2826,38 +2939,55 @@ impl SessionManager { ticker.tick().await; let now = SystemTime::now(); - let mut expired_sessions = Vec::new(); + let candidates = Self::collect_expired_session_candidates(&sessions, now, timeout); - for entry in sessions.iter() { - let session = entry.value(); - if let Ok(idle_duration) = now.duration_since(session.last_activity_at) { - if idle_duration > timeout { - expired_sessions.push(session.session_id.clone()); - } - } - } + for candidate in candidates { + debug!( + "Cleaning up expired session: session_id={}", + candidate.session_id + ); - for session_id in expired_sessions { - debug!("Cleaning up expired session: session_id={}", session_id); + let cleanup_now = SystemTime::now(); + let Some(session) = Self::cleanup_snapshot_for_candidate( + &sessions, + &candidate, + cleanup_now, + timeout, + ) else { + continue; + }; - // Save before deleting - if enable_persistence { - if let Some(session) = sessions.get(&session_id) { - if !Self::should_persist_session(&session) { - context_store.delete_session(&session_id); - sessions.remove(&session_id); - continue; - } - if let Some(workspace_path) = - Self::effective_workspace_path_from_config(&session.config).await + if enable_persistence && Self::should_persist_session(&session) { + if let Some(workspace_path) = + Self::effective_workspace_path_from_config(&session.config).await + { + if Self::cleanup_snapshot_for_candidate( + &sessions, + &candidate, + SystemTime::now(), + timeout, + ) + .is_some() { let _ = persistence.save_session(&workspace_path, &session).await; } } } - context_store.delete_session(&session_id); - sessions.remove(&session_id); + let removal_now = SystemTime::now(); + if sessions + .remove_if(&candidate.session_id, |_, session| { + Self::cleanup_candidate_matches_session( + session, + &candidate, + removal_now, + timeout, + ) + }) + .is_some() + { + context_store.delete_session(&candidate.session_id); + } } } }); @@ -2877,6 +3007,7 @@ mod tests { use crate::service::session::{ DialogTurnData, ModelRoundData, ToolCallData, ToolItemData, ToolResultData, UserMessageData, }; + use dashmap::try_result::TryResult; use serde_json::json; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -2942,6 +3073,44 @@ mod tests { ) } + #[tokio::test] + async fn auto_save_interval_waits_before_first_tick() { + let mut ticker = SessionManager::auto_save_interval(Duration::from_millis(40)); + let started = tokio::time::Instant::now(); + + ticker.tick().await; + + assert!(started.elapsed() >= Duration::from_millis(30)); + } + + #[tokio::test] + async fn auto_save_snapshot_collection_releases_session_map_guards() { + let workspace = TestWorkspace::new(); + let manager = in_memory_test_manager(); + let session = manager + .create_session( + "Auto-save snapshot".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + let snapshots = SessionManager::collect_auto_save_snapshots(&manager.sessions); + assert!(snapshots + .iter() + .any(|snapshot| snapshot.session_id == session.session_id)); + + match manager.sessions.try_get_mut(&session.session_id) { + TryResult::Present(_) => {} + TryResult::Absent => panic!("session should remain present"), + TryResult::Locked => panic!("snapshot collection should not retain session map guards"), + }; + } + #[tokio::test] async fn reset_session_state_if_processing_ignores_a_newer_turn() { let manager = in_memory_test_manager(); From 7fa4281c29db521e78d34ddb4d9d489adc41043d Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 18 May 2026 14:47:47 +0800 Subject: [PATCH 25/76] refactor(product-domains): extract runtime baseline helpers Move pure MiniApp lifecycle/import file-shape helpers and Git function-agent prompt preparation into the product-domain owner crate while keeping IO, worker runtime, prompt templates, AI calls, JSON extraction, and error mapping in core. --- AGENTS.md | 13 +- docs/architecture/core-decomposition.md | 14 +- docs/plans/core-decomposition-plan.md | 6 +- scripts/check-core-boundaries.mjs | 100 +++++++++++- .../git-func-agent/ai_service.rs | 49 ++---- src/crates/core/src/miniapp/manager.rs | 126 +++++++-------- src/crates/product-domains/AGENTS.md | 13 +- .../function_agents/git_func_agent/utils.rs | 25 +++ .../product-domains/src/miniapp/lifecycle.rs | 66 ++++++++ .../product-domains/src/miniapp/storage.rs | 66 ++++++++ .../tests/function_agent_contracts.rs | 29 +++- .../tests/miniapp_contracts.rs | 143 +++++++++++++++--- 12 files changed, 504 insertions(+), 146 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e3f62660f6..4151b7f64f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,12 +159,13 @@ and milestone verification gates. - Remote-SSH path, session identity, mirror path, and unresolved-session layout helpers belong in `bitfun-services-integrations`; core may inject `PathManager` and hold SSH manager / remote FS / terminal assembly. -- MiniApp storage/draft shape, runtime search-plan helpers, customization - metadata policy including built-in update/decline decisions, and - function-agent pure prompt/diff parsing helpers belong in - `bitfun-product-domains`; core keeps filesystem IO, worker runtime, - built-in source-hash lookup, `PathManager`, Git/AI calls, and port adapters - until reviewed runtime migrations exist. +- MiniApp storage/draft/import file shape, import fallback payload, lifecycle + state-transition helpers, runtime search-plan helpers, customization metadata + policy including built-in update/decline decisions, and function-agent pure + prompt/diff preparation helpers belong in `bitfun-product-domains`; core + keeps filesystem IO, worker runtime, built-in source-hash lookup, + `PathManager`, Git/AI calls, prompt templates, JSON extraction, error + mapping, and port adapters until reviewed runtime migrations exist. - Remote-connect port baselines live in `bitfun-runtime-ports` and `bitfun-services-integrations`; tracker state and tracker event reduction belong in `bitfun-services-integrations`. Remote command/response wire DTOs, diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 6c6c0f37d2..dc667505d5 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -172,11 +172,12 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate 只能在不改变执行路径的前提下委托现有 service,并先补等价测试。IO/进程/AI/Git 执行 owner 迁移仍属于后续高风险步骤。 - 2026-05-18 product-domain readiness update: MiniApp draft DTO/response shape, - draft/customization storage path shape, runtime executable search-plan helpers, - customization draft-apply / built-in update-decline metadata policy, and - Git function-agent diff truncation policy now live in - `bitfun-product-domains`. Core still owns MiniApp filesystem IO, worker - process execution, built-in asset seeding/source-hash lookup, + draft/customization storage path shape, import layout / fallback payload + contracts, manager lifecycle state-transition helpers, runtime executable + search-plan helpers, customization draft-apply / built-in update-decline + metadata policy, and Git function-agent diff truncation / commit prompt + preparation now live in `bitfun-product-domains`. Core still owns MiniApp + filesystem IO, worker process execution, built-in asset seeding/source-hash lookup, host-dispatch execution, `PathManager` integration, function-agent Git/AI calls, prompt templates, JSON extraction, and error mapping. - 高风险:`ToolUseContext`、product tool registry / manifest / exposure / `GetToolSpec` owner 化、 @@ -209,7 +210,8 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate adapter 等价和 remote search fallback gate;这些都是 behavior-locking tests,不移动 runtime owner。 - 当前 PR2 `Services/Product Runtime Owner Closure` 只收口已经有 port/contract 保护的低风险 owner: remote-SSH session identity / mirror path / unresolved-session layout 归属 - `bitfun-services-integrations`,MiniApp storage file layout 归属 `bitfun-product-domains`。 + `bitfun-services-integrations`,MiniApp storage/import file layout、fallback payload + 和纯 lifecycle state transition 归属 `bitfun-product-domains`。 core 继续持有 SSH manager、remote FS / terminal、MiniApp filesystem IO、worker runtime、 `PathManager` 注入和兼容 facade;不声明 remote-connect、MiniApp IO、function-agent Git/AI runtime 或 tool runtime 已迁移。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 33035919f9..eb0d0cadb5 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1121,9 +1121,9 @@ product-full = ["miniapp", "function-agents"] - 已迁移到 `bitfun-product-domains::miniapp`:`types`、`bridge_builder`、`permission_policy`,core 旧路径继续 re-export。 - 已迁移到 `bitfun-product-domains::miniapp`:纯 compiler、export DTO、runtime detection DTO、runtime search path plan、worker install result DTO、worker install 命令选择、package.json storage-shape helper、lifecycle / revision helper、host routing string / allowlist policy helper、customization metadata / permission diff,以及 runtime/storage port contract;core `miniapp::compiler::compile` 继续映射为原 `BitFunResult` API,runtime detection / exporter / host dispatch 执行 / customization draft 存储与应用 / worker pool / storage IO 执行逻辑仍留在 core,目前仅通过 core-owned storage/runtime adapter 和等价测试保护现有路径。 -- 2026-05-18 update: MiniApp draft manifest/response DTO, draft/customization storage path helpers, runtime executable search-plan helpers, customization draft-apply metadata policy, and built-in update/decline metadata decisions have been moved to `bitfun-product-domains::miniapp`; core continues to own draft filesystem IO, built-in asset seeding/source-hash lookup, host dispatch execution, `PathManager` integration, worker process execution, and compatibility facades. +- 2026-05-18 update: MiniApp draft manifest/response DTO, draft/customization storage path helpers, import layout / fallback payload contracts, manager lifecycle state-transition helpers, runtime executable search-plan helpers, customization draft-apply metadata policy, and built-in update/decline metadata decisions have been moved to `bitfun-product-domains::miniapp`; core continues to own draft/import filesystem IO, compile orchestration, built-in asset seeding/source-hash lookup, host dispatch execution, `PathManager` integration, worker process execution, and compatibility facades. - 已迁移到 `bitfun-product-domains::function_agents`:公共 `common` 类型、git/startchat function-agent 的纯 DTO 类型、git function-agent 的纯路径 / 变更分类 / commit summary / message assembly / prompt format / commit type parser / AI response parsing policy、startchat prompt / action / AI response parsing policy / git porcelain / diff combine / time-of-day helper、Git/AI port contract,以及只读本地文件的 project context analyzer;core-owned Git snapshot adapter 已由等价测试覆盖,AI client、Git service、prompt template、AI request、JSON extraction、错误映射与分析运行逻辑仍留在 core。 -- 2026-05-18 update: Git function-agent diff truncation is now an owner-crate helper used by core; AI client calls, prompt template ownership, JSON extraction, error mapping, and runtime analysis execution remain core-owned. +- 2026-05-18 update: Git function-agent diff truncation and commit prompt preparation are now owner-crate helpers used by core; AI client calls, prompt template ownership, JSON extraction, error mapping, and runtime analysis execution remain core-owned. - boundary check 已补充 product-domain owner anchor:`MiniAppStoragePort` / `MiniAppRuntimePort` 的 core adapter、MiniApp host/customization 纯 contract、function-agent Git adapter 与 AI response parsing helper 必须存在,防止把 port contract 或 pure parser 误读成 storage IO、worker process、host dispatch、customization draft runtime、Git/AI service runtime 已完成迁移。 - miniapp runtime/storage/manager/host dispatch/exporter/builtin 与 function-agent 运行逻辑继续迁移前,需要先确认 agent/tool/provider port 和 Git/AI service 边界。 @@ -1538,7 +1538,7 @@ cargo check --workspace - 未声明完成的 P2/后续剩余部分:remote-ssh runtime、remote-connect 等重 service 迁移、`ToolUseContext` 外移、tool exposure / manifest / `GetToolSpec` owner 化、concrete tool implementation 迁移、product registry / manifest / provider assembly、miniapp/function-agent 运行逻辑迁移。这些会触碰 `PathManager`、`ToolUseContext`、workspace service、snapshot wrapper、prompt-visible tool catalog、`AgentSubmissionPort` 或 AI service 边界,需要在继续前显式确认。 - 本次 rebase 后重新核对最新主干 Deep Review capacity/cost/queue、context profile、evidence ledger 与 session manifest 变更:当前 PR 已完成 Git feature group 的 owner crate 归属迁移,但未改动这些 Deep Review 行为路径;后续迁移必须补端口设计和等价测试后再推进。 - 本次 rebase 后重新核对最新主干 tool 变更:on-demand tool spec discovery 新增 collapsed/expanded manifest、`GetToolSpec`、context-aware schema/description 与 unlock state。这不要求回退当前 P2 已完成内容,但要求后续 tool/provider 迁移先补 manifest / catalog / unlock 等价保护,且不得和 PR5 product-domain runtime 收口混合。 -- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、lifecycle / revision helper、host routing string / allowlist policy helper、customization metadata / permission diff、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / AI response parsing policy / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper;core 只保留 AI client 调用、JSON 提取、错误映射、Git service adapter 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试。PathManager、Git/AI service、prompt template、builtin asset seeding、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 +- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、import layout / fallback payload contract、lifecycle / revision helper、manager 纯状态转换 helper、host routing string / allowlist policy helper、customization metadata / permission diff、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / commit prompt preparation / AI response parsing policy / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper;core 只保留 AI client 调用、JSON 提取、错误映射、Git service adapter 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试。PathManager、Git/AI service、prompt template、builtin asset seeding、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 - 本次 P2 后续复核结论:上述高耦合剩余项不是纯文件搬迁;若继续迁移会改变依赖方向或需要新增 port/provider 行为合约。因此当前 PR 将它们显式保留为 core-owned runtime,只完成低风险 owner container 化,并通过 boundary check 防止已拆 owner crate 回流依赖 core。 **后续风险重排(2026-05-13):** diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 6181b74ceb..e5f57a3140 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -1838,6 +1838,22 @@ const requiredContentRules = [ regex: /\bpub const DRAFT_JSON\b/, message: 'missing MiniApp draft manifest filename contract', }, + { + regex: /\bpub const REQUIRED_SOURCE_FILES\b/, + message: 'missing MiniApp import source file list contract', + }, + { + regex: /\bpub const PLACEHOLDER_COMPILED_HTML\b/, + message: 'missing MiniApp placeholder compiled HTML contract', + }, + { + regex: /\bpub struct MiniAppImportLayout\b/, + message: 'missing MiniApp import layout contract', + }, + { + regex: /\bpub fn build_import_fallbacks\b/, + message: 'missing MiniApp import fallback payload helper', + }, { regex: /\bpub fn draft_dir\b/, message: 'missing MiniApp draft directory layout helper', @@ -1848,6 +1864,37 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/product-domains/src/miniapp/lifecycle.rs', + reason: + 'product-domains owns pure MiniApp lifecycle state transitions while core keeps compile, storage IO, and runtime execution', + patterns: [ + { + regex: /\bpub fn mark_deps_installed_state\b/, + message: 'missing MiniApp deps-installed state helper', + }, + { + regex: /\bpub fn clear_worker_restart_required_state\b/, + message: 'missing MiniApp worker-restart clear state helper', + }, + { + regex: /\bpub fn prepare_rollback_app\b/, + message: 'missing MiniApp rollback state helper', + }, + { + regex: /\bpub fn apply_recompile_result\b/, + message: 'missing MiniApp recompile result state helper', + }, + { + regex: /\bpub fn apply_sync_from_fs_result\b/, + message: 'missing MiniApp sync-from-fs state helper', + }, + { + regex: /\bpub fn apply_import_runtime_state\b/, + message: 'missing MiniApp import runtime state helper', + }, + ], + }, { path: 'src/crates/product-domains/src/miniapp/draft.rs', reason: @@ -1951,7 +1998,7 @@ const requiredContentRules = [ { path: 'src/crates/core/src/miniapp/manager.rs', reason: - 'core MiniApp manager must use product-domain customization metadata policy while retaining storage IO and built-in source-hash lookup', + 'core MiniApp manager must use product-domain pure policy helpers while retaining compile, storage IO, and built-in source-hash lookup', patterns: [ { regex: /\bapply_draft_customization_metadata\b/, @@ -1965,6 +2012,30 @@ const requiredContentRules = [ regex: /\bdecline_builtin_update_metadata\b/, message: 'missing product-domain built-in update decline helper use', }, + { + regex: /\bmark_deps_installed_state\b/, + message: 'missing product-domain MiniApp deps-installed state helper use', + }, + { + regex: /\bapply_sync_from_fs_result\b/, + message: 'missing product-domain MiniApp sync-from-fs state helper use', + }, + { + regex: /\bREQUIRED_SOURCE_FILES\b/, + message: 'missing product-domain MiniApp import file-shape contract use', + }, + { + regex: /\bMiniAppImportLayout\b/, + message: 'missing product-domain MiniApp import layout helper use', + }, + { + regex: /\bbuild_import_fallbacks\b/, + message: 'missing product-domain MiniApp import fallback helper use', + }, + { + regex: /\bapply_import_runtime_state\b/, + message: 'missing product-domain MiniApp import runtime state helper use', + }, { regex: /\bstorage\.load_customization_metadata\b/, message: 'missing core-owned customization metadata storage IO', @@ -1999,6 +2070,10 @@ const requiredContentRules = [ regex: /\bpub fn truncate_diff_for_commit_prompt\b/, message: 'missing Git function-agent diff truncation helper', }, + { + regex: /\bpub fn prepare_commit_prompt\b/, + message: 'missing Git function-agent prompt preparation helper', + }, ], }, { @@ -2692,6 +2767,21 @@ function runManifestParserSelfTest() { 'DRAFT_JSON', 'draft_dir', 'customization_path', + 'REQUIRED_SOURCE_FILES', + 'PLACEHOLDER_COMPILED_HTML', + 'MiniAppImportLayout', + 'build_import_fallbacks', + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/lifecycle.rs', + contracts: [ + 'mark_deps_installed_state', + 'clear_worker_restart_required_state', + 'prepare_rollback_app', + 'apply_recompile_result', + 'apply_sync_from_fs_result', + 'apply_import_runtime_state', ], }, { @@ -2730,6 +2820,12 @@ function runManifestParserSelfTest() { 'mark_builtin_update_available_metadata', 'decline_builtin_update_metadata', 'storage.load_customization_metadata', + 'mark_deps_installed_state', + 'apply_sync_from_fs_result', + 'REQUIRED_SOURCE_FILES', + 'MiniAppImportLayout', + 'build_import_fallbacks', + 'apply_import_runtime_state', ], }, { @@ -2738,7 +2834,7 @@ function runManifestParserSelfTest() { }, { path: 'src/crates/product-domains/src/function_agents/git_func_agent/utils.rs', - contracts: ['parse_commit_analysis_value', 'truncate_diff_for_commit_prompt'], + contracts: ['parse_commit_analysis_value', 'truncate_diff_for_commit_prompt', 'prepare_commit_prompt'], }, { path: 'src/crates/core/src/miniapp/runtime_detect.rs', diff --git a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs index c40435fe68..3127fc20c8 100644 --- a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs +++ b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs @@ -7,7 +7,7 @@ use crate::util::types::Message; * * Handles AI client interaction and provides intelligent analysis for commit message generation */ -use bitfun_product_domains::function_agents::git_func_agent::truncate_diff_for_commit_prompt; +use bitfun_product_domains::function_agents::git_func_agent::prepare_commit_prompt; use log::{debug, error, warn}; use std::sync::Arc; @@ -47,11 +47,22 @@ impl AIAnalysisService { return Err(AgentError::invalid_input("Code changes are empty")); } - let processed_diff = self.truncate_diff_if_needed(diff_content, 50000); - - let prompt = self.build_commit_prompt(&processed_diff, project_context, options); + let prepared_prompt = prepare_commit_prompt( + COMMIT_MESSAGE_PROMPT, + diff_content, + project_context, + options, + 50000, + ); + if prepared_prompt.truncated { + warn!( + "Diff too large ({} chars), truncating to {} chars", + diff_content.len(), + 50000 + ); + } - let ai_response = self.call_ai(&prompt).await?; + let ai_response = self.call_ai(&prepared_prompt.prompt).await?; self.parse_commit_response(&ai_response) } @@ -84,20 +95,6 @@ impl AIAnalysisService { } } - fn build_commit_prompt( - &self, - diff_content: &str, - project_context: &ProjectContext, - options: &CommitMessageOptions, - ) -> String { - super::utils::build_commit_prompt( - COMMIT_MESSAGE_PROMPT, - diff_content, - project_context, - options, - ) - } - fn parse_commit_response(&self, response: &str) -> AgentResult { let json_str = crate::util::extract_json_from_ai_response(response) .ok_or_else(|| AgentError::analysis_error("Cannot extract JSON from response"))?; @@ -108,18 +105,4 @@ impl AIAnalysisService { super::utils::parse_commit_analysis_value(&value).map_err(AgentError::analysis_error) } - - fn truncate_diff_if_needed(&self, diff: &str, max_chars: usize) -> String { - if diff.len() <= max_chars { - return diff.to_string(); - } - - warn!( - "Diff too large ({} chars), truncating to {} chars", - diff.len(), - max_chars - ); - - truncate_diff_for_commit_prompt(diff, max_chars) - } } diff --git a/src/crates/core/src/miniapp/manager.rs b/src/crates/core/src/miniapp/manager.rs index 5e942c54b0..6b1dddc221 100644 --- a/src/crates/core/src/miniapp/manager.rs +++ b/src/crates/core/src/miniapp/manager.rs @@ -8,17 +8,24 @@ use crate::miniapp::types::{ }; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_product_domains::miniapp::customization::{ - MiniAppCustomizationBaseline, MiniAppCustomizationLocalSnapshot, MiniAppCustomizationMetadata, - MiniAppPermissionDiff, apply_draft_customization_metadata, decline_builtin_update_metadata, + apply_draft_customization_metadata, decline_builtin_update_metadata, declined_builtin_update_needs_local_snapshot, diff_permissions, is_current_declined_builtin_update, mark_builtin_update_available_metadata, + MiniAppCustomizationBaseline, MiniAppCustomizationLocalSnapshot, MiniAppCustomizationMetadata, + MiniAppPermissionDiff, }; use bitfun_product_domains::miniapp::draft::{ - MiniAppDraft, MiniAppDraftManifest, build_draft_manifest, build_draft_response, + build_draft_manifest, build_draft_response, MiniAppDraft, MiniAppDraftManifest, }; use bitfun_product_domains::miniapp::lifecycle::{ + apply_import_runtime_state, apply_recompile_result, apply_sync_from_fs_result, build_deps_revision, build_runtime_state, build_source_revision, build_worker_revision, - ensure_runtime_state, workspace_dir_string, + clear_worker_restart_required_state, ensure_runtime_state, mark_deps_installed_state, + prepare_rollback_app, workspace_dir_string, +}; +use bitfun_product_domains::miniapp::storage::{ + build_import_fallbacks, MiniAppImportLayout, COMPILED_HTML, ESM_DEPS_JSON, META_JSON, + PACKAGE_JSON, REQUIRED_SOURCE_FILES, SOURCE_DIR, STORAGE_JSON, }; use chrono::Utc; use std::collections::HashMap; @@ -665,18 +672,14 @@ impl MiniAppManager { pub async fn mark_deps_installed(&self, app_id: &str) -> BitFunResult { let mut app = self.storage.load(app_id).await?; - ensure_runtime_state(&mut app); - app.runtime.deps_dirty = false; - app.runtime.worker_restart_required = true; + mark_deps_installed_state(&mut app); self.storage.save(&app).await?; Ok(app) } pub async fn clear_worker_restart_required(&self, app_id: &str) -> BitFunResult { let mut app = self.storage.load(app_id).await?; - ensure_runtime_state(&mut app); - if app.runtime.worker_restart_required { - app.runtime.worker_restart_required = false; + if clear_worker_restart_required_state(&mut app) { self.storage.save(&app).await?; } Ok(app) @@ -690,17 +693,9 @@ impl MiniAppManager { /// Rollback app to a previous version (loads version snapshot, saves as current). pub async fn rollback(&self, app_id: &str, version: u32) -> BitFunResult { let current = self.storage.load(app_id).await?; - let mut app = self.storage.load_version(app_id, version).await?; + let app = self.storage.load_version(app_id, version).await?; let now = Utc::now().timestamp_millis(); - app.version = current.version + 1; - app.updated_at = now; - app.runtime = build_runtime_state( - app.version, - app.updated_at, - &app.source, - !app.source.npm_dependencies.is_empty(), - true, - ); + let app = prepare_rollback_app(¤t, app, now); self.storage .save_version(app_id, current.version, ¤t) .await?; @@ -716,11 +711,9 @@ impl MiniAppManager { workspace_root: Option<&Path>, ) -> BitFunResult { let mut app = self.storage.load(app_id).await?; - app.compiled_html = + let compiled_html = self.compile_source(app_id, &app.source, &app.permissions, theme, workspace_root)?; - app.updated_at = Utc::now().timestamp_millis(); - ensure_runtime_state(&mut app); - app.runtime.ui_recompile_required = false; + apply_recompile_result(&mut app, compiled_html, Utc::now().timestamp_millis()); self.storage.save(&app).await?; Ok(app) } @@ -732,19 +725,19 @@ impl MiniAppManager { workspace_root: Option<&Path>, ) -> BitFunResult { let previous_app = self.storage.load(app_id).await?; - let mut app = previous_app.clone(); - app.source = self.storage.load_source_only(app_id).await?; - app.version += 1; - app.updated_at = Utc::now().timestamp_millis(); - - app.compiled_html = - self.compile_source(app_id, &app.source, &app.permissions, theme, workspace_root)?; - app.runtime = build_runtime_state( - app.version, - app.updated_at, - &app.source, - !app.source.npm_dependencies.is_empty(), - true, + let source = self.storage.load_source_only(app_id).await?; + let compiled_html = self.compile_source( + app_id, + &source, + &previous_app.permissions, + theme, + workspace_root, + )?; + let app = apply_sync_from_fs_result( + &previous_app, + source, + compiled_html, + Utc::now().timestamp_millis(), ); self.storage .save_version(app_id, previous_app.version, &previous_app) @@ -769,8 +762,9 @@ impl MiniAppManager { ))); } - let meta_path = src.join("meta.json"); - let source_dir = src.join("source"); + let import_layout = MiniAppImportLayout::new(src); + let meta_path = import_layout.meta_path(); + let source_dir = import_layout.source_dir(); if !meta_path.exists() { return Err(BitFunError::validation(format!( "Missing meta.json in {}", @@ -783,8 +777,8 @@ impl MiniAppManager { src.display() ))); } - for required in &["index.html", "style.css", "ui.js", "worker.js"] { - if !source_dir.join(required).exists() { + for (required, path) in import_layout.required_source_file_paths() { + if !path.exists() { return Err(BitFunError::validation(format!( "Missing source/{} in {}", required, @@ -805,18 +799,19 @@ impl MiniAppManager { meta.created_at = now; meta.updated_at = now; + let fallbacks = build_import_fallbacks(&id); let dest_dir = self.path_manager.miniapp_dir(&id); - let dest_source = dest_dir.join("source"); + let dest_source = dest_dir.join(SOURCE_DIR); tokio::fs::create_dir_all(&dest_source) .await .map_err(|e| BitFunError::io(format!("Failed to create app dir: {}", e)))?; let meta_json = serde_json::to_string_pretty(&meta).map_err(BitFunError::from)?; - tokio::fs::write(dest_dir.join("meta.json"), meta_json) + tokio::fs::write(dest_dir.join(META_JSON), meta_json) .await .map_err(|e| BitFunError::io(format!("Failed to write meta.json: {}", e)))?; - for name in &["index.html", "style.css", "ui.js", "worker.js"] { + for name in REQUIRED_SOURCE_FILES { let from = source_dir.join(name); let to = dest_source.join(name); if from.exists() { @@ -825,62 +820,53 @@ impl MiniAppManager { .map_err(|e| BitFunError::io(format!("Failed to copy {}: {}", name, e)))?; } } - let esm_path = source_dir.join("esm_dependencies.json"); + let esm_path = import_layout.esm_dependencies_path(); if esm_path.exists() { - tokio::fs::copy(&esm_path, dest_source.join("esm_dependencies.json")) + tokio::fs::copy(&esm_path, dest_source.join(ESM_DEPS_JSON)) .await .map_err(|e| { BitFunError::io(format!("Failed to copy esm_dependencies.json: {}", e)) })?; } else { - tokio::fs::write(dest_source.join("esm_dependencies.json"), "[]") - .await - .map_err(|_e| BitFunError::io("Failed to write esm_dependencies.json"))?; + tokio::fs::write( + dest_source.join(ESM_DEPS_JSON), + fallbacks.esm_dependencies_json, + ) + .await + .map_err(|_e| BitFunError::io("Failed to write esm_dependencies.json"))?; } - let pkg_src = src.join("package.json"); + let pkg_src = import_layout.package_json_path(); if pkg_src.exists() { - tokio::fs::copy(&pkg_src, dest_dir.join("package.json")) + tokio::fs::copy(&pkg_src, dest_dir.join(PACKAGE_JSON)) .await .map_err(|e| BitFunError::io(format!("Failed to copy package.json: {}", e)))?; } else { - let pkg = serde_json::json!({ - "name": format!("miniapp-{}", id), - "private": true, - "dependencies": {} - }); tokio::fs::write( - dest_dir.join("package.json"), - serde_json::to_string_pretty(&pkg).map_err(BitFunError::from)?, + dest_dir.join(PACKAGE_JSON), + serde_json::to_string_pretty(&fallbacks.package_json).map_err(BitFunError::from)?, ) .await .map_err(|_e| BitFunError::io("Failed to write package.json"))?; } - let storage_src = src.join("storage.json"); + let storage_src = import_layout.storage_json_path(); if storage_src.exists() { - tokio::fs::copy(&storage_src, dest_dir.join("storage.json")) + tokio::fs::copy(&storage_src, dest_dir.join(STORAGE_JSON)) .await .map_err(|e| BitFunError::io(format!("Failed to copy storage.json: {}", e)))?; } else { - tokio::fs::write(dest_dir.join("storage.json"), "{}") + tokio::fs::write(dest_dir.join(STORAGE_JSON), fallbacks.storage_json) .await .map_err(|_e| BitFunError::io("Failed to write storage.json"))?; } - let placeholder_html = "Loading..."; - tokio::fs::write(dest_dir.join("compiled.html"), placeholder_html) + tokio::fs::write(dest_dir.join(COMPILED_HTML), fallbacks.compiled_html) .await .map_err(|_e| BitFunError::io("Failed to write placeholder compiled.html"))?; let mut app = self.recompile(&id, "dark", workspace_root).await?; - app.runtime = build_runtime_state( - app.version, - app.updated_at, - &app.source, - !app.source.npm_dependencies.is_empty(), - true, - ); + apply_import_runtime_state(&mut app); self.storage.save(&app).await?; Ok(app) } diff --git a/src/crates/product-domains/AGENTS.md b/src/crates/product-domains/AGENTS.md index 424a62d995..9dfe57096b 100644 --- a/src/crates/product-domains/AGENTS.md +++ b/src/crates/product-domains/AGENTS.md @@ -33,13 +33,14 @@ moves here gradually. ## Current owners -- `miniapp` owns MiniApp DTOs, compiler/bridge helpers, storage and draft file - shapes, runtime search-plan helpers, worker install command selection, - lifecycle/revision helpers, host-routing string policy, customization - metadata policy including built-in update/decline decisions, and port traits. +- `miniapp` owns MiniApp DTOs, compiler/bridge helpers, storage/draft/import + file shapes, import fallback payloads, runtime search-plan helpers, worker + install command selection, lifecycle/revision and manager state-transition + helpers, host-routing string policy, customization metadata policy including + built-in update/decline decisions, and port traits. - `function-agents` owns pure function-agent DTOs, prompt assembly helpers, - AI-response parsing policy, diff truncation policy, local file-shape analysis, - and Git/AI port traits. + commit prompt preparation, AI-response parsing policy, diff truncation policy, + local file-shape analysis, and Git/AI port traits. - Core still owns MiniApp filesystem IO, worker process execution, host dispatch execution, built-in asset seeding/source-hash lookup, `PathManager` integration, function-agent Git/AI calls, prompt templates, JSON extraction, diff --git a/src/crates/product-domains/src/function_agents/git_func_agent/utils.rs b/src/crates/product-domains/src/function_agents/git_func_agent/utils.rs index 0d1708db18..8d8241b434 100644 --- a/src/crates/product-domains/src/function_agents/git_func_agent/utils.rs +++ b/src/crates/product-domains/src/function_agents/git_func_agent/utils.rs @@ -236,6 +236,31 @@ pub fn truncate_diff_for_commit_prompt(diff: &str, max_chars: usize) -> String { truncated } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedCommitPrompt { + pub prompt: String, + pub diff_content: String, + pub truncated: bool, +} + +pub fn prepare_commit_prompt( + template: &str, + diff_content: &str, + project_context: &ProjectContext, + options: &CommitMessageOptions, + max_chars: usize, +) -> PreparedCommitPrompt { + let truncated = diff_content.len() > max_chars; + let diff_content = truncate_diff_for_commit_prompt(diff_content, max_chars); + let prompt = build_commit_prompt(template, &diff_content, project_context, options); + + PreparedCommitPrompt { + prompt, + diff_content, + truncated, + } +} + pub fn parse_commit_type_label(label: &str) -> CommitType { match label.to_lowercase().as_str() { "feat" | "feature" => CommitType::Feat, diff --git a/src/crates/product-domains/src/miniapp/lifecycle.rs b/src/crates/product-domains/src/miniapp/lifecycle.rs index 518979080a..b21a0cdc66 100644 --- a/src/crates/product-domains/src/miniapp/lifecycle.rs +++ b/src/crates/product-domains/src/miniapp/lifecycle.rs @@ -48,6 +48,72 @@ pub fn ensure_runtime_state(app: &mut MiniApp) -> bool { changed } +pub fn mark_deps_installed_state(app: &mut MiniApp) { + ensure_runtime_state(app); + app.runtime.deps_dirty = false; + app.runtime.worker_restart_required = true; +} + +pub fn clear_worker_restart_required_state(app: &mut MiniApp) -> bool { + ensure_runtime_state(app); + if app.runtime.worker_restart_required { + app.runtime.worker_restart_required = false; + return true; + } + false +} + +pub fn prepare_rollback_app(current: &MiniApp, mut target: MiniApp, now: i64) -> MiniApp { + target.version = current.version + 1; + target.updated_at = now; + target.runtime = build_runtime_state( + target.version, + target.updated_at, + &target.source, + !target.source.npm_dependencies.is_empty(), + true, + ); + target +} + +pub fn apply_recompile_result(app: &mut MiniApp, compiled_html: String, now: i64) { + app.compiled_html = compiled_html; + app.updated_at = now; + ensure_runtime_state(app); + app.runtime.ui_recompile_required = false; +} + +pub fn apply_sync_from_fs_result( + previous: &MiniApp, + source: MiniAppSource, + compiled_html: String, + now: i64, +) -> MiniApp { + let mut app = previous.clone(); + app.source = source; + app.version += 1; + app.updated_at = now; + app.compiled_html = compiled_html; + app.runtime = build_runtime_state( + app.version, + app.updated_at, + &app.source, + !app.source.npm_dependencies.is_empty(), + true, + ); + app +} + +pub fn apply_import_runtime_state(app: &mut MiniApp) { + app.runtime = build_runtime_state( + app.version, + app.updated_at, + &app.source, + !app.source.npm_dependencies.is_empty(), + true, + ); +} + pub fn build_worker_revision(app: &MiniApp, policy_json: &str) -> String { format!( "{}::{}::{}", diff --git a/src/crates/product-domains/src/miniapp/storage.rs b/src/crates/product-domains/src/miniapp/storage.rs index af5922e19a..8dcf93a1d7 100644 --- a/src/crates/product-domains/src/miniapp/storage.rs +++ b/src/crates/product-domains/src/miniapp/storage.rs @@ -9,10 +9,15 @@ pub const INDEX_HTML: &str = "index.html"; pub const STYLE_CSS: &str = "style.css"; pub const UI_JS: &str = "ui.js"; pub const WORKER_JS: &str = "worker.js"; +pub const REQUIRED_SOURCE_FILES: &[&str] = &[INDEX_HTML, STYLE_CSS, UI_JS, WORKER_JS]; pub const PACKAGE_JSON: &str = "package.json"; pub const ESM_DEPS_JSON: &str = "esm_dependencies.json"; pub const COMPILED_HTML: &str = "compiled.html"; pub const STORAGE_JSON: &str = "storage.json"; +pub const EMPTY_ESM_DEPENDENCIES_JSON: &str = "[]"; +pub const EMPTY_STORAGE_JSON: &str = "{}"; +pub const PLACEHOLDER_COMPILED_HTML: &str = + "Loading..."; pub const VERSIONS_DIR: &str = "versions"; pub const DRAFTS_DIR: &str = ".drafts"; pub const DRAFTS_CLEANUP_PREFIX: &str = ".drafts.cleanup-"; @@ -20,6 +25,50 @@ pub const DRAFTS_CLEANUP_MARKER: &str = ".cleanup-pending"; pub const DRAFT_JSON: &str = "draft.json"; pub const CUSTOMIZATION_JSON: &str = ".customization.json"; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiniAppImportLayout { + root: PathBuf, +} + +impl MiniAppImportLayout { + pub fn new(root: impl AsRef) -> Self { + Self { + root: root.as_ref().to_path_buf(), + } + } + + pub fn meta_path(&self) -> PathBuf { + self.root.join(META_JSON) + } + + pub fn source_dir(&self) -> PathBuf { + self.root.join(SOURCE_DIR) + } + + pub fn source_file_path(&self, file_name: &str) -> PathBuf { + self.source_dir().join(file_name) + } + + pub fn required_source_file_paths(&self) -> Vec<(&'static str, PathBuf)> { + REQUIRED_SOURCE_FILES + .iter() + .map(|file_name| (*file_name, self.source_file_path(file_name))) + .collect() + } + + pub fn esm_dependencies_path(&self) -> PathBuf { + self.source_file_path(ESM_DEPS_JSON) + } + + pub fn package_json_path(&self) -> PathBuf { + self.root.join(PACKAGE_JSON) + } + + pub fn storage_json_path(&self) -> PathBuf { + self.root.join(STORAGE_JSON) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MiniAppStorageLayout { miniapps_root: PathBuf, @@ -144,3 +193,20 @@ pub fn build_package_json(app_id: &str, deps: &[NpmDep]) -> serde_json::Value { "dependencies": dependencies }) } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiniAppImportFallbacks { + pub esm_dependencies_json: &'static str, + pub storage_json: &'static str, + pub compiled_html: &'static str, + pub package_json: serde_json::Value, +} + +pub fn build_import_fallbacks(app_id: &str) -> MiniAppImportFallbacks { + MiniAppImportFallbacks { + esm_dependencies_json: EMPTY_ESM_DEPENDENCIES_JSON, + storage_json: EMPTY_STORAGE_JSON, + compiled_html: PLACEHOLDER_COMPILED_HTML, + package_json: build_package_json(app_id, &[]), + } +} diff --git a/src/crates/product-domains/tests/function_agent_contracts.rs b/src/crates/product-domains/tests/function_agent_contracts.rs index eae9a8852a..9363b99a87 100644 --- a/src/crates/product-domains/tests/function_agent_contracts.rs +++ b/src/crates/product-domains/tests/function_agent_contracts.rs @@ -6,7 +6,8 @@ use bitfun_product_domains::function_agents::{ ChangePattern, CommitFormat, CommitMessageOptions, CommitType, FileChange, FileChangeType, ProjectContext, assemble_commit_message, build_changes_summary_from_paths, build_commit_prompt, detect_change_patterns, extract_module_name, infer_file_type, - parse_commit_analysis_value, parse_commit_type_label, truncate_diff_for_commit_prompt, + parse_commit_analysis_value, parse_commit_type_label, prepare_commit_prompt, + truncate_diff_for_commit_prompt, }, ports::{ CommitAiAnalysisRequest, FunctionAgentAiPort, FunctionAgentFuture, FunctionAgentGitPort, @@ -216,6 +217,32 @@ fn git_function_agent_diff_truncation_preserves_legacy_marker() { assert!(truncated.ends_with("\n\n... [content truncated] ...")); } +#[test] +fn git_function_agent_commit_prompt_preparation_preserves_truncation_boundary() { + let context = ProjectContext { + project_type: "library".to_string(), + tech_stack: vec!["Rust".to_string(), "Cargo".to_string()], + project_docs: Some("Use conventional commits.".to_string()), + code_standards: Some("Keep modules small.".to_string()), + }; + let options = CommitMessageOptions::default(); + let template = "Project: {project_type}\nStack: {tech_stack}\nDiff: {diff_content}\nLanguage: {language_desc}\n"; + let prepared = prepare_commit_prompt(template, &"x".repeat(140), &context, &options, 120); + + assert!(prepared.truncated); + assert!( + prepared + .diff_content + .ends_with("\n\n... [content truncated] ...") + ); + assert!(prepared.prompt.contains("Diff: ")); + assert!(prepared.prompt.contains("library")); + + let short = prepare_commit_prompt(template, "short diff", &context, &options, 120); + assert!(!short.truncated); + assert_eq!(short.diff_content, "short diff"); +} + #[test] fn startchat_options_preserve_existing_defaults() { let options = WorkStateOptions::default(); diff --git a/src/crates/product-domains/tests/miniapp_contracts.rs b/src/crates/product-domains/tests/miniapp_contracts.rs index fc346c64d4..efb59e9e15 100644 --- a/src/crates/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/product-domains/tests/miniapp_contracts.rs @@ -3,15 +3,15 @@ use bitfun_product_domains::miniapp::bridge_builder::{build_bridge_script, build_csp_content}; use bitfun_product_domains::miniapp::compiler::compile; use bitfun_product_domains::miniapp::customization::{ - MAX_DECLINED_BUILTIN_UPDATES, MiniAppCustomizationBaseline, MiniAppCustomizationLocalSnapshot, - MiniAppCustomizationMetadata, MiniAppCustomizationOrigin, MiniAppCustomizationOriginKind, apply_draft_customization_metadata, decline_builtin_update_metadata, declined_builtin_update_needs_local_snapshot, is_current_declined_builtin_update, - mark_builtin_update_available_metadata, + mark_builtin_update_available_metadata, MiniAppCustomizationBaseline, + MiniAppCustomizationLocalSnapshot, MiniAppCustomizationMetadata, MiniAppCustomizationOrigin, + MiniAppCustomizationOriginKind, MAX_DECLINED_BUILTIN_UPDATES, }; use bitfun_product_domains::miniapp::draft::{ - MINIAPP_DRAFT_STATUS_APPLIED, MINIAPP_DRAFT_STATUS_DRAFT, build_draft_manifest, - build_draft_response, + build_draft_manifest, build_draft_response, MINIAPP_DRAFT_STATUS_APPLIED, + MINIAPP_DRAFT_STATUS_DRAFT, }; use bitfun_product_domains::miniapp::exporter::{ExportCheckResult, ExportTarget}; use bitfun_product_domains::miniapp::host_routing::{ @@ -19,8 +19,10 @@ use bitfun_product_domains::miniapp::host_routing::{ is_host_primitive, }; use bitfun_product_domains::miniapp::lifecycle::{ + apply_import_runtime_state, apply_recompile_result, apply_sync_from_fs_result, build_deps_revision, build_runtime_state, build_source_revision, build_worker_revision, - ensure_runtime_state, workspace_dir_string, + clear_worker_restart_required_state, ensure_runtime_state, mark_deps_installed_state, + prepare_rollback_app, workspace_dir_string, }; use bitfun_product_domains::miniapp::permission_policy::resolve_policy; use bitfun_product_domains::miniapp::ports::{ @@ -28,20 +30,21 @@ use bitfun_product_domains::miniapp::ports::{ MiniAppRuntimePort, }; use bitfun_product_domains::miniapp::runtime::{ - RuntimeKind, candidate_dirs, candidate_executable_path, runtime_lookup_order, - version_manager_roots, versioned_executable_candidate, + candidate_dirs, candidate_executable_path, runtime_lookup_order, version_manager_roots, + versioned_executable_candidate, RuntimeKind, }; use bitfun_product_domains::miniapp::storage::{ - COMPILED_HTML, CUSTOMIZATION_JSON, DRAFT_JSON, DRAFTS_CLEANUP_MARKER, DRAFTS_CLEANUP_PREFIX, - DRAFTS_DIR, ESM_DEPS_JSON, INDEX_HTML, META_JSON, MiniAppStorageLayout, PACKAGE_JSON, - SOURCE_DIR, STORAGE_JSON, STYLE_CSS, UI_JS, VERSIONS_DIR, WORKER_JS, build_package_json, - parse_npm_dependencies, + build_import_fallbacks, build_package_json, parse_npm_dependencies, MiniAppImportLayout, + MiniAppStorageLayout, COMPILED_HTML, CUSTOMIZATION_JSON, DRAFTS_CLEANUP_MARKER, + DRAFTS_CLEANUP_PREFIX, DRAFTS_DIR, DRAFT_JSON, EMPTY_ESM_DEPENDENCIES_JSON, EMPTY_STORAGE_JSON, + ESM_DEPS_JSON, INDEX_HTML, META_JSON, PACKAGE_JSON, PLACEHOLDER_COMPILED_HTML, + REQUIRED_SOURCE_FILES, SOURCE_DIR, STORAGE_JSON, STYLE_CSS, UI_JS, VERSIONS_DIR, WORKER_JS, }; use bitfun_product_domains::miniapp::types::{ FsPermissions, MiniApp, MiniAppPermissions, MiniAppRuntimeState, MiniAppSource, NetPermissions, NotificationPermissions, NpmDep, }; -use bitfun_product_domains::miniapp::worker::{InstallResult, install_command_for_runtime}; +use bitfun_product_domains::miniapp::worker::{install_command_for_runtime, InstallResult}; use std::path::{Path, PathBuf}; struct RuntimePortStub; @@ -376,6 +379,60 @@ fn miniapp_lifecycle_helpers_preserve_runtime_revision_contract() { assert_eq!(workspace_dir_string(None), ""); } +#[test] +fn miniapp_lifecycle_manager_state_helpers_preserve_core_transitions() { + let source = MiniAppSource { + npm_dependencies: vec![NpmDep { + name: "lodash".to_string(), + version: "^4.17.21".to_string(), + }], + ..MiniAppSource::default() + }; + let mut app = sample_miniapp_for_lifecycle(source.clone()); + + mark_deps_installed_state(&mut app); + assert_eq!(app.runtime.source_revision, "src:3:1234"); + assert_eq!(app.runtime.deps_revision, "lodash@^4.17.21"); + assert!(!app.runtime.deps_dirty); + assert!(app.runtime.worker_restart_required); + + assert!(clear_worker_restart_required_state(&mut app)); + assert!(!app.runtime.worker_restart_required); + assert!(!clear_worker_restart_required_state(&mut app)); + + apply_recompile_result(&mut app, "fresh".to_string(), 2000); + assert_eq!(app.compiled_html, "fresh"); + assert_eq!(app.updated_at, 2000); + assert!(!app.runtime.ui_recompile_required); + assert_eq!(app.runtime.source_revision, "src:3:1234"); + + let current = sample_miniapp_for_lifecycle(MiniAppSource::default()); + let rollback_target = sample_miniapp_for_lifecycle(source.clone()); + let rolled_back = prepare_rollback_app(¤t, rollback_target, 3000); + assert_eq!(rolled_back.version, current.version + 1); + assert_eq!(rolled_back.updated_at, 3000); + assert!(rolled_back.runtime.deps_dirty); + assert!(rolled_back.runtime.worker_restart_required); + assert_eq!(rolled_back.runtime.deps_revision, "lodash@^4.17.21"); + + let synced = + apply_sync_from_fs_result(¤t, source, "synced".to_string(), 4000); + assert_eq!(synced.version, current.version + 1); + assert_eq!(synced.updated_at, 4000); + assert_eq!(synced.compiled_html, "synced"); + assert!(synced.runtime.deps_dirty); + assert!(synced.runtime.worker_restart_required); + + let mut imported = synced.clone(); + imported.runtime.worker_restart_required = false; + imported.runtime.deps_dirty = false; + apply_import_runtime_state(&mut imported); + assert!(imported.runtime.deps_dirty); + assert!(imported.runtime.worker_restart_required); + assert_eq!(imported.runtime.source_revision, "src:4:4000"); + assert_eq!(imported.runtime.deps_revision, "lodash@^4.17.21"); +} + #[test] fn miniapp_storage_package_json_contract_remains_stable() { let deps = parse_npm_dependencies( @@ -411,6 +468,56 @@ fn miniapp_storage_package_json_contract_remains_stable() { assert_eq!(package["dependencies"]["lodash"], "^4.17.21"); } +#[test] +fn miniapp_storage_import_fallback_contract_remains_stable() { + let root = PathBuf::from("/miniapps/incoming"); + let layout = MiniAppImportLayout::new(&root); + + assert_eq!(layout.meta_path(), root.join(META_JSON)); + assert_eq!(layout.source_dir(), root.join(SOURCE_DIR)); + assert_eq!( + layout.source_file_path(INDEX_HTML), + root.join(SOURCE_DIR).join(INDEX_HTML) + ); + assert_eq!( + layout.required_source_file_paths(), + vec![ + (INDEX_HTML, root.join(SOURCE_DIR).join(INDEX_HTML)), + (STYLE_CSS, root.join(SOURCE_DIR).join(STYLE_CSS)), + (UI_JS, root.join(SOURCE_DIR).join(UI_JS)), + (WORKER_JS, root.join(SOURCE_DIR).join(WORKER_JS)), + ] + ); + assert_eq!( + layout.esm_dependencies_path(), + root.join(SOURCE_DIR).join(ESM_DEPS_JSON) + ); + assert_eq!(layout.package_json_path(), root.join(PACKAGE_JSON)); + assert_eq!(layout.storage_json_path(), root.join(STORAGE_JSON)); + + assert_eq!( + REQUIRED_SOURCE_FILES, + &[INDEX_HTML, STYLE_CSS, UI_JS, WORKER_JS] + ); + assert_eq!(EMPTY_ESM_DEPENDENCIES_JSON, "[]"); + assert_eq!(EMPTY_STORAGE_JSON, "{}"); + assert_eq!( + PLACEHOLDER_COMPILED_HTML, + "Loading..." + ); + + let package = build_package_json("imported-app", &[]); + assert_eq!(package["name"], "miniapp-imported-app"); + assert_eq!(package["private"], true); + assert_eq!(package["dependencies"], serde_json::json!({})); + + let fallbacks = build_import_fallbacks("imported-app"); + assert_eq!(fallbacks.esm_dependencies_json, "[]"); + assert_eq!(fallbacks.storage_json, "{}"); + assert_eq!(fallbacks.compiled_html, PLACEHOLDER_COMPILED_HTML); + assert_eq!(fallbacks.package_json, package); +} + #[test] fn miniapp_ports_keep_runtime_boundary_lightweight() { let decoded: MiniAppInstallDepsRequest = serde_json::from_value(serde_json::json!({ @@ -652,12 +759,10 @@ fn miniapp_customization_decline_policy_updates_existing_and_trims_old_records() metadata.declined_builtin_updates.len(), MAX_DECLINED_BUILTIN_UPDATES ); - assert!( - !metadata - .declined_builtin_updates - .iter() - .any(|record| record.source_hash == "hash-v5") - ); + assert!(!metadata + .declined_builtin_updates + .iter() + .any(|record| record.source_hash == "hash-v5")); } fn sample_miniapp_for_lifecycle(source: MiniAppSource) -> MiniApp { From 562bb01998f222892ebf3d79e8053812afb612db Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 18 May 2026 15:40:03 +0800 Subject: [PATCH 26/76] test(core): add product runtime preflight baselines Add migration-before snapshots for MiniApp manager runtime paths and function-agent Git/AI boundaries while keeping runtime ownership in core. --- AGENTS.md | 2 + docs/architecture/core-decomposition.md | 20 ++- docs/plans/core-decomposition-plan.md | 12 +- scripts/check-core-boundaries.mjs | 62 ++++++- .../git-func-agent/ai_service.rs | 70 ++++++++ .../core/src/function_agents/port_adapters.rs | 32 +++- src/crates/core/src/miniapp/manager.rs | 168 +++++++++++++++++- .../tests/miniapp_contracts.rs | 34 ++-- 8 files changed, 363 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4151b7f64f..dad1120bc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,8 @@ and milestone verification gates. keeps filesystem IO, worker runtime, built-in source-hash lookup, `PathManager`, Git/AI calls, prompt templates, JSON extraction, error mapping, and port adapters until reviewed runtime migrations exist. + Before moving those runtime owners, keep the core-owned MiniApp manager and + function-agent Git/AI boundary snapshots passing. - Remote-connect port baselines live in `bitfun-runtime-ports` and `bitfun-services-integrations`; tracker state and tracker event reduction belong in `bitfun-services-integrations`. Remote command/response wire DTOs, diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index dc667505d5..5f57272d79 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -176,10 +176,13 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate contracts, manager lifecycle state-transition helpers, runtime executable search-plan helpers, customization draft-apply / built-in update-decline metadata policy, and Git function-agent diff truncation / commit prompt - preparation now live in `bitfun-product-domains`. Core still owns MiniApp - filesystem IO, worker process execution, built-in asset seeding/source-hash lookup, - host-dispatch execution, `PathManager` integration, function-agent Git/AI - calls, prompt templates, JSON extraction, and error mapping. + preparation now live in `bitfun-product-domains`. The same PR adds + migration-before snapshots for core-owned MiniApp import / sync / recompile / + rollback / dependency state paths and function-agent Git / AI response + boundaries. Core still owns MiniApp filesystem IO, worker process execution, + built-in asset seeding/source-hash lookup, host-dispatch execution, + `PathManager` integration, function-agent Git/AI calls, prompt templates, + JSON extraction, and error mapping. - 高风险:`ToolUseContext`、product tool registry / manifest / exposure / `GetToolSpec` owner 化、 MCP concrete tool integration、remote-connect、remote SSH runtime、miniapp / function-agent runtime、 agent registry、`bitfun-core default = []` @@ -205,16 +208,21 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate `BitFunError` 映射、legacy facade、product tool wrapper 和 global registry / manifest 接入。 如果继续收敛 MCP manager 行为,必须先补 config failure、catalog invalidation、list-changed 与 dynamic tool manifest 回归测试。 -- 当前 PR3 semantic baseline 已补 config failure、catalog replacement invalidation、沿用既有 list-changed +- 已合入的 semantic baseline 已补 config failure、catalog replacement invalidation、沿用既有 list-changed helper baseline、dynamic manifest order/metadata、tool manifest / `GetToolSpec`、MiniApp storage layout adapter 等价和 remote search fallback gate;这些都是 behavior-locking tests,不移动 runtime owner。 -- 当前 PR2 `Services/Product Runtime Owner Closure` 只收口已经有 port/contract 保护的低风险 owner: +- 已合入的 `Services/Product Runtime Owner Closure` 只收口已经有 port/contract 保护的低风险 owner: remote-SSH session identity / mirror path / unresolved-session layout 归属 `bitfun-services-integrations`,MiniApp storage/import file layout、fallback payload 和纯 lifecycle state transition 归属 `bitfun-product-domains`。 core 继续持有 SSH manager、remote FS / terminal、MiniApp filesystem IO、worker runtime、 `PathManager` 注入和兼容 facade;不声明 remote-connect、MiniApp IO、function-agent Git/AI runtime 或 tool runtime 已迁移。 +- 本轮 product-domain runtime preflight PR 只补等价快照和边界锚点:MiniApp + `import_from_path` / `sync_from_fs` / `recompile` / `rollback` / deps state、 + function-agent staged diff snapshot、AI response JSON extraction 与 error mapping + 仍被记录为 core-owned 行为。后续若继续移动这些 runtime owner,必须以这些快照为 + 行为等价基线。 ## 产品表面边界(Product Surface Boundary) diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index eb0d0cadb5..51a9e81b66 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1121,10 +1121,10 @@ product-full = ["miniapp", "function-agents"] - 已迁移到 `bitfun-product-domains::miniapp`:`types`、`bridge_builder`、`permission_policy`,core 旧路径继续 re-export。 - 已迁移到 `bitfun-product-domains::miniapp`:纯 compiler、export DTO、runtime detection DTO、runtime search path plan、worker install result DTO、worker install 命令选择、package.json storage-shape helper、lifecycle / revision helper、host routing string / allowlist policy helper、customization metadata / permission diff,以及 runtime/storage port contract;core `miniapp::compiler::compile` 继续映射为原 `BitFunResult` API,runtime detection / exporter / host dispatch 执行 / customization draft 存储与应用 / worker pool / storage IO 执行逻辑仍留在 core,目前仅通过 core-owned storage/runtime adapter 和等价测试保护现有路径。 -- 2026-05-18 update: MiniApp draft manifest/response DTO, draft/customization storage path helpers, import layout / fallback payload contracts, manager lifecycle state-transition helpers, runtime executable search-plan helpers, customization draft-apply metadata policy, and built-in update/decline metadata decisions have been moved to `bitfun-product-domains::miniapp`; core continues to own draft/import filesystem IO, compile orchestration, built-in asset seeding/source-hash lookup, host dispatch execution, `PathManager` integration, worker process execution, and compatibility facades. +- 2026-05-18 update: MiniApp draft manifest/response DTO, draft/customization storage path helpers, import layout / fallback payload contracts, manager lifecycle state-transition helpers, runtime executable search-plan helpers, customization draft-apply metadata policy, and built-in update/decline metadata decisions have been moved to `bitfun-product-domains::miniapp`; core continues to own draft/import filesystem IO, compile orchestration, built-in asset seeding/source-hash lookup, host dispatch execution, `PathManager` integration, worker process execution, and compatibility facades. The current PR also records core-owned MiniApp import / sync / recompile / rollback / dependency-state behavior as migration-before tests, including the existing `sync_from_fs` snapshot boundary. - 已迁移到 `bitfun-product-domains::function_agents`:公共 `common` 类型、git/startchat function-agent 的纯 DTO 类型、git function-agent 的纯路径 / 变更分类 / commit summary / message assembly / prompt format / commit type parser / AI response parsing policy、startchat prompt / action / AI response parsing policy / git porcelain / diff combine / time-of-day helper、Git/AI port contract,以及只读本地文件的 project context analyzer;core-owned Git snapshot adapter 已由等价测试覆盖,AI client、Git service、prompt template、AI request、JSON extraction、错误映射与分析运行逻辑仍留在 core。 -- 2026-05-18 update: Git function-agent diff truncation and commit prompt preparation are now owner-crate helpers used by core; AI client calls, prompt template ownership, JSON extraction, error mapping, and runtime analysis execution remain core-owned. -- boundary check 已补充 product-domain owner anchor:`MiniAppStoragePort` / `MiniAppRuntimePort` 的 core adapter、MiniApp host/customization 纯 contract、function-agent Git adapter 与 AI response parsing helper 必须存在,防止把 port contract 或 pure parser 误读成 storage IO、worker process、host dispatch、customization draft runtime、Git/AI service runtime 已完成迁移。 +- 2026-05-18 update: Git function-agent diff truncation and commit prompt preparation are now owner-crate helpers used by core; AI client calls, prompt template ownership, JSON extraction, error mapping, and runtime analysis execution remain core-owned. The current PR adds focused core snapshots for staged-only Git commit diff collection and AI response JSON extraction / error mapping before any Git/AI runtime migration. +- boundary check 已补充 product-domain owner anchor:`MiniAppStoragePort` / `MiniAppRuntimePort` 的 core adapter、MiniApp host/customization 纯 contract、MiniApp manager preflight tests、function-agent Git adapter 与 AI response parsing helper 必须存在,防止把 port contract 或 pure parser 误读成 storage IO、worker process、host dispatch、customization draft runtime、Git/AI service runtime 已完成迁移。 - miniapp runtime/storage/manager/host dispatch/exporter/builtin 与 function-agent 运行逻辑继续迁移前,需要先确认 agent/tool/provider port 和 Git/AI service 边界。 **验证:** @@ -1538,7 +1538,7 @@ cargo check --workspace - 未声明完成的 P2/后续剩余部分:remote-ssh runtime、remote-connect 等重 service 迁移、`ToolUseContext` 外移、tool exposure / manifest / `GetToolSpec` owner 化、concrete tool implementation 迁移、product registry / manifest / provider assembly、miniapp/function-agent 运行逻辑迁移。这些会触碰 `PathManager`、`ToolUseContext`、workspace service、snapshot wrapper、prompt-visible tool catalog、`AgentSubmissionPort` 或 AI service 边界,需要在继续前显式确认。 - 本次 rebase 后重新核对最新主干 Deep Review capacity/cost/queue、context profile、evidence ledger 与 session manifest 变更:当前 PR 已完成 Git feature group 的 owner crate 归属迁移,但未改动这些 Deep Review 行为路径;后续迁移必须补端口设计和等价测试后再推进。 - 本次 rebase 后重新核对最新主干 tool 变更:on-demand tool spec discovery 新增 collapsed/expanded manifest、`GetToolSpec`、context-aware schema/description 与 unlock state。这不要求回退当前 P2 已完成内容,但要求后续 tool/provider 迁移先补 manifest / catalog / unlock 等价保护,且不得和 PR5 product-domain runtime 收口混合。 -- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、import layout / fallback payload contract、lifecycle / revision helper、manager 纯状态转换 helper、host routing string / allowlist policy helper、customization metadata / permission diff、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / commit prompt preparation / AI response parsing policy / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper;core 只保留 AI client 调用、JSON 提取、错误映射、Git service adapter 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试。PathManager、Git/AI service、prompt template、builtin asset seeding、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 +- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、import layout / fallback payload contract、lifecycle / revision helper、manager 纯状态转换 helper、host routing string / allowlist policy helper、customization metadata / permission diff、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / commit prompt preparation / AI response parsing policy / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper;core 只保留 AI client 调用、JSON 提取、错误映射、Git service adapter 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试,并补齐 MiniApp manager import/sync/recompile/rollback/deps state 与 function-agent staged diff / AI response error mapping 的迁移前快照。PathManager、Git/AI service、prompt template、builtin asset seeding、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 - 本次 P2 后续复核结论:上述高耦合剩余项不是纯文件搬迁;若继续迁移会改变依赖方向或需要新增 port/provider 行为合约。因此当前 PR 将它们显式保留为 core-owned runtime,只完成低风险 owner container 化,并通过 boundary check 防止已拆 owner crate 回流依赖 core。 **后续风险重排(2026-05-13):** @@ -1681,7 +1681,7 @@ P2 后产品表面契约轨道(contract-only): - 已新增并扩展内置工具清单基线测试,后续迁移 `ToolUseContext`、concrete tool implementation、tool manifest/exposure 或 product registry / manifest assembly 必须先保持该清单、注册顺序、runtime collection 顺序、expanded/collapsed exposure、`GetToolSpec` unlock state、dynamic provider metadata 顺序和修改类工具 snapshot wrapper 等价,再评估 owner crate 边界。 - miniapp 与 function-agent runtime 外移必须先明确 Git/AI service、PathManager、process execution 和 permission policy 边界;如果需要行为合约变化,必须作为后续单独 PR 并先确认。 - 产品表面 contract 补强必须保持 observational:只记录 surface/thread/environment/permission/artifact facts,不改变 CLI、Desktop、Remote、ACP 的现有交互和运行时语义。 -- 当前 PR3 已补齐关键语义回归 baseline:MCP config failure 作为空配置基线但写入失败继续上抛、catalog replacement invalidation、沿用既有 list-changed helper baseline、dynamic manifest metadata/order snapshot、tool manifest / `GetToolSpec` snapshot、product-domains pure helper 与 core adapter 等价、remote workspace search fallback focused test。 +- 已合入的 PR3 已补齐关键语义回归 baseline:MCP config failure 作为空配置基线但写入失败继续上抛、catalog replacement invalidation、沿用既有 list-changed helper baseline、dynamic manifest metadata/order snapshot、tool manifest / `GetToolSpec` snapshot、product-domains pure helper 与 core adapter 等价、remote workspace search fallback focused test。 - 再之后才进入 owner-by-owner baseline:每个 runtime owner 迁移前先列出当前行为、输入输出、feature graph 和验证命令;迁移后先证明行为等价,再考虑删除 legacy path。 - `bitfun-core default = []`、per-product feature set、依赖版本收敛和构建收益优化仍是后续独立评估项,不与 runtime 外移或构建脚本调整混在同一批提交。 @@ -1733,7 +1733,7 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts 15. 已完成:agent tools + `tool-packs` owner 化低风险闭环;tool contract / DTO、runtime restriction、path resolution、generic registry / dynamic provider container 已归属 `bitfun-agent-tools`,core 保留产品工具列表、snapshot decorator、`ToolUseContext` 和 concrete tool implementation,后续外移需单独 port/provider 设计。 16. 已完成:关键语义回归 baseline,不移动 runtime owner。覆盖 MCP config failure / catalog invalidation / 既有 list-changed helper / dynamic manifest、tool manifest / `GetToolSpec`、product-domains adapter equivalence、remote workspace search fallback 的 focused tests 或 snapshots。 17. 已完成:remote-connect runtime 当前批次收口。已基于当前 port baseline 记录 remote command/response、remote model catalog、poll response、model catalog delta、session restore、active turn、cancel、image context、tracker event、queue/event fanout 的输入输出和验证命令;tracker state / registry lifecycle、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 已迁入 `bitfun-services-integrations`。dispatcher / product execution、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行显式保留在 core-owned runtime;后续只有在另起 port/provider 设计且 focused regression 继续通过时才允许继续移动这些 runtime owner,不能把 generic attachment guard 当作已接入多模态行为。 -18. 后续高风险单独审视:`product-domains` runtime + core facade finalization 的剩余 PathManager、process execution、Git/AI service、prompt template、host dispatch 执行与 worker/storage IO owner 迁移;不得与当前 PR2/PR3 混合。 +18. 后续高风险单独审视:`product-domains` runtime + core facade finalization 的剩余 PathManager、process execution、Git/AI service、prompt template、host dispatch 执行与 worker/storage IO owner 迁移;不得与既有 PR2/PR3 范围混合。 19. 后续独立评估:`bitfun-core default = []`、per-product feature set、依赖版本收敛或构建收益优化;任何收益声明都需要记录 `cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据。 冗余清理 PR 不进入上述主线序号。只有在满足 `0A.6` 的绝对等价要求时,才可以插入到相邻里程碑之间,并且不得与主线拆分 PR 混合。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index e5f57a3140..683dfb33c2 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -2040,6 +2040,45 @@ const requiredContentRules = [ regex: /\bstorage\.load_customization_metadata\b/, message: 'missing core-owned customization metadata storage IO', }, + { + regex: /\bruntime_preflight_preserves_recompile_sync_rollback_and_deps_state\b/, + message: 'missing MiniApp manager runtime preflight regression test', + }, + { + regex: /\bimport_from_path_preserves_fallback_files_recompile_and_runtime_state\b/, + message: 'missing MiniApp import runtime preflight regression test', + }, + ], + }, + { + path: 'src/crates/core/src/function_agents/git-func-agent/ai_service.rs', + reason: + 'core must continue owning Git function-agent prompt template, AI call, JSON extraction, and error mapping until AI runtime migration is reviewed', + patterns: [ + { + regex: /\bconst COMMIT_MESSAGE_PROMPT\b/, + message: 'missing core-owned Git function-agent prompt template', + }, + { + regex: /\bprepare_commit_prompt\b/, + message: 'missing product-domain prompt preparation helper use', + }, + { + regex: /\bai_client\s*\.\s*send_message\b/, + message: 'missing core-owned function-agent AI call', + }, + { + regex: /\bextract_json_from_ai_response\b/, + message: 'missing core-owned AI response JSON extraction', + }, + { + regex: /\bAgentError::analysis_error\b/, + message: 'missing core-owned AI response error mapping', + }, + { + regex: /\bparse_commit_response_preserves_core_json_extraction_and_error_mapping\b/, + message: 'missing Git function-agent AI response boundary regression test', + }, ], }, { @@ -2123,6 +2162,10 @@ const requiredContentRules = [ regex: /\bimpl FunctionAgentGitPort for CoreFunctionAgentGitAdapter\b/, message: 'missing function-agent Git port adapter owner', }, + { + regex: /\bgit_adapter_commit_snapshot_keeps_staged_diff_and_unstaged_count_separate\b/, + message: 'missing function-agent Git snapshot boundary regression test', + }, ], }, ]; @@ -2744,7 +2787,11 @@ function runManifestParserSelfTest() { }, { path: 'src/crates/core/src/function_agents/port_adapters.rs', - contracts: ['CoreFunctionAgentGitAdapter', 'FunctionAgentGitPort'], + contracts: [ + 'CoreFunctionAgentGitAdapter', + 'FunctionAgentGitPort', + 'git_adapter_commit_snapshot_keeps_staged_diff_and_unstaged_count_separate', + ], }, { path: 'src/crates/services-integrations/src/remote_ssh/paths.rs', @@ -2826,6 +2873,19 @@ function runManifestParserSelfTest() { 'MiniAppImportLayout', 'build_import_fallbacks', 'apply_import_runtime_state', + 'runtime_preflight_preserves_recompile_sync_rollback_and_deps_state', + 'import_from_path_preserves_fallback_files_recompile_and_runtime_state', + ], + }, + { + path: 'src/crates/core/src/function_agents/git-func-agent/ai_service.rs', + contracts: [ + 'COMMIT_MESSAGE_PROMPT', + 'prepare_commit_prompt', + 'send_message', + 'extract_json_from_ai_response', + 'AgentError::analysis_error', + 'parse_commit_response_preserves_core_json_extraction_and_error_mapping', ], }, { diff --git a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs index 3127fc20c8..3e801af117 100644 --- a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs +++ b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs @@ -106,3 +106,73 @@ impl AIAnalysisService { super::utils::parse_commit_analysis_value(&value).map_err(AgentError::analysis_error) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::function_agents::common::AgentErrorType; + use crate::util::types::AIConfig; + use bitfun_ai_adapters::types::ReasoningMode; + + fn test_service() -> AIAnalysisService { + AIAnalysisService { + ai_client: Arc::new(AIClient::new(AIConfig { + name: "test".to_string(), + base_url: "http://127.0.0.1".to_string(), + request_url: "http://127.0.0.1".to_string(), + api_key: "test".to_string(), + model: "test-model".to_string(), + format: "openai".to_string(), + context_window: 8192, + max_tokens: None, + temperature: None, + top_p: None, + reasoning_mode: ReasoningMode::Default, + inline_think_in_text: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: None, + thinking_budget_tokens: None, + custom_request_body: None, + custom_request_body_mode: None, + })), + } + } + + #[test] + fn parse_commit_response_preserves_core_json_extraction_and_error_mapping() { + let service = test_service(); + let parsed = service + .parse_commit_response( + r#"The answer is: +```json +{ + "type": "refactor", + "title": "refactor(product-domains): add runtime baseline", + "body": "Keep behavior stable.", + "confidence": 0.91 +} +``` +"#, + ) + .unwrap(); + + assert_eq!( + parsed.title, + "refactor(product-domains): add runtime baseline" + ); + assert_eq!(parsed.body.as_deref(), Some("Keep behavior stable.")); + assert_eq!(parsed.confidence, 0.91); + + let missing_json = service.parse_commit_response("no json here").unwrap_err(); + assert_eq!(missing_json.error_type, AgentErrorType::AnalysisError); + assert_eq!(missing_json.message, "Cannot extract JSON from response"); + + let missing_title = service + .parse_commit_response(r#"{"type":"refactor","body":"missing title"}"#) + .unwrap_err(); + assert_eq!(missing_title.error_type, AgentErrorType::AnalysisError); + assert_eq!(missing_title.message, "Missing title field"); + } +} diff --git a/src/crates/core/src/function_agents/port_adapters.rs b/src/crates/core/src/function_agents/port_adapters.rs index bb9ced2350..3c81536277 100644 --- a/src/crates/core/src/function_agents/port_adapters.rs +++ b/src/crates/core/src/function_agents/port_adapters.rs @@ -204,7 +204,11 @@ mod tests { async fn git_adapter_builds_commit_snapshot_from_existing_core_git_services() { let repo = TestTempDir::new("commit-snapshot"); init_git_repo(repo.path()); - fs::write(repo.path().join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap(); + fs::write( + repo.path().join("Cargo.toml"), + "[package]\nname = \"demo\"\n", + ) + .unwrap(); fs::create_dir_all(repo.path().join("src")).unwrap(); fs::write(repo.path().join("src/lib.rs"), "pub fn demo() {}\n").unwrap(); git(repo.path(), &["add", "Cargo.toml", "src/lib.rs"]); @@ -223,6 +227,32 @@ mod tests { assert_eq!(snapshot.project_context.project_type, "rust-application"); } + #[tokio::test] + async fn git_adapter_commit_snapshot_keeps_staged_diff_and_unstaged_count_separate() { + let repo = TestTempDir::new("commit-snapshot-boundary"); + init_git_repo(repo.path()); + fs::write(repo.path().join("tracked.txt"), "base\n").unwrap(); + git(repo.path(), &["add", "tracked.txt"]); + git(repo.path(), &["commit", "-m", "initial"]); + + fs::write(repo.path().join("tracked.txt"), "base\nunstaged only\n").unwrap(); + fs::write(repo.path().join("staged.txt"), "staged only\n").unwrap(); + git(repo.path(), &["add", "staged.txt"]); + + let adapter = CoreFunctionAgentGitAdapter::default(); + let snapshot = adapter + .git_commit_snapshot(repo.path().to_string_lossy().to_string()) + .await + .unwrap(); + + assert_eq!(snapshot.staged_paths, vec!["staged.txt".to_string()]); + assert_eq!(snapshot.staged_count, 1); + assert_eq!(snapshot.unstaged_count, 1); + assert!(snapshot.diff_content.contains("staged.txt")); + assert!(snapshot.diff_content.contains("staged only")); + assert!(!snapshot.diff_content.contains("unstaged only")); + } + #[tokio::test] async fn git_adapter_builds_startchat_snapshot_without_changing_git_semantics() { let repo = TestTempDir::new("startchat-snapshot"); diff --git a/src/crates/core/src/miniapp/manager.rs b/src/crates/core/src/miniapp/manager.rs index 6b1dddc221..ee60fedf00 100644 --- a/src/crates/core/src/miniapp/manager.rs +++ b/src/crates/core/src/miniapp/manager.rs @@ -8,14 +8,13 @@ use crate::miniapp::types::{ }; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_product_domains::miniapp::customization::{ - apply_draft_customization_metadata, decline_builtin_update_metadata, + MiniAppCustomizationBaseline, MiniAppCustomizationLocalSnapshot, MiniAppCustomizationMetadata, + MiniAppPermissionDiff, apply_draft_customization_metadata, decline_builtin_update_metadata, declined_builtin_update_needs_local_snapshot, diff_permissions, is_current_declined_builtin_update, mark_builtin_update_available_metadata, - MiniAppCustomizationBaseline, MiniAppCustomizationLocalSnapshot, MiniAppCustomizationMetadata, - MiniAppPermissionDiff, }; use bitfun_product_domains::miniapp::draft::{ - build_draft_manifest, build_draft_response, MiniAppDraft, MiniAppDraftManifest, + MiniAppDraft, MiniAppDraftManifest, build_draft_manifest, build_draft_response, }; use bitfun_product_domains::miniapp::lifecycle::{ apply_import_runtime_state, apply_recompile_result, apply_sync_from_fs_result, @@ -24,8 +23,8 @@ use bitfun_product_domains::miniapp::lifecycle::{ prepare_rollback_app, workspace_dir_string, }; use bitfun_product_domains::miniapp::storage::{ - build_import_fallbacks, MiniAppImportLayout, COMPILED_HTML, ESM_DEPS_JSON, META_JSON, - PACKAGE_JSON, REQUIRED_SOURCE_FILES, SOURCE_DIR, STORAGE_JSON, + COMPILED_HTML, ESM_DEPS_JSON, META_JSON, MiniAppImportLayout, PACKAGE_JSON, + REQUIRED_SOURCE_FILES, SOURCE_DIR, STORAGE_JSON, build_import_fallbacks, }; use chrono::Utc; use std::collections::HashMap; @@ -875,7 +874,13 @@ impl MiniAppManager { #[cfg(test)] mod tests { use super::*; - use crate::miniapp::types::{FsPermissions, MiniAppPermissions, MiniAppSource}; + use crate::miniapp::types::{ + FsPermissions, MiniAppMeta, MiniAppPermissions, MiniAppSource, NpmDep, + }; + use bitfun_product_domains::miniapp::storage::{ + COMPILED_HTML, ESM_DEPS_JSON, INDEX_HTML, PACKAGE_JSON, SOURCE_DIR, STORAGE_JSON, + STYLE_CSS, UI_JS, WORKER_JS, + }; fn test_manager() -> MiniAppManager { let root = std::env::temp_dir().join(format!( @@ -916,6 +921,155 @@ mod tests { .unwrap() } + async fn write_import_source(root: &std::path::Path) { + let source_dir = root.join(SOURCE_DIR); + tokio::fs::create_dir_all(&source_dir).await.unwrap(); + let meta = MiniAppMeta { + id: "template-id".to_string(), + name: "Imported".to_string(), + description: "Imported app".to_string(), + icon: "box".to_string(), + category: "utility".to_string(), + tags: vec!["imported".to_string()], + version: 7, + created_at: 11, + updated_at: 12, + permissions: MiniAppPermissions::default(), + ai_context: None, + runtime: Default::default(), + i18n: None, + }; + tokio::fs::write( + root.join(META_JSON), + serde_json::to_string_pretty(&meta).unwrap(), + ) + .await + .unwrap(); + tokio::fs::write( + source_dir.join(INDEX_HTML), + "
    ", + ) + .await + .unwrap(); + tokio::fs::write(source_dir.join(STYLE_CSS), "body { color: blue; }") + .await + .unwrap(); + tokio::fs::write( + source_dir.join(UI_JS), + "document.getElementById('app').textContent = 'imported';", + ) + .await + .unwrap(); + tokio::fs::write(source_dir.join(WORKER_JS), "") + .await + .unwrap(); + } + + #[tokio::test] + async fn runtime_preflight_preserves_recompile_sync_rollback_and_deps_state() { + let manager = test_manager(); + let mut app = create_sample_app(&manager).await; + app.source.npm_dependencies = vec![NpmDep { + name: "lodash".to_string(), + version: "^4.17.21".to_string(), + }]; + manager.storage.save(&app).await.unwrap(); + + let installed = manager.mark_deps_installed(&app.id).await.unwrap(); + assert!(!installed.runtime.deps_dirty); + assert!(installed.runtime.worker_restart_required); + let cleared = manager + .clear_worker_restart_required(&app.id) + .await + .unwrap(); + assert!(!cleared.runtime.worker_restart_required); + + let style_path = manager + .path_manager() + .miniapp_dir(&app.id) + .join(SOURCE_DIR) + .join(STYLE_CSS); + tokio::fs::write(&style_path, "body { color: red; }") + .await + .unwrap(); + let synced = manager.sync_from_fs(&app.id, "dark", None).await.unwrap(); + assert_eq!(synced.version, app.version + 1); + assert_eq!(synced.source.css, "body { color: red; }"); + assert!(synced.runtime.deps_dirty); + assert!(synced.runtime.worker_restart_required); + assert_eq!(manager.list_versions(&app.id).await.unwrap(), vec![1]); + + let recompiled = manager.recompile(&app.id, "dark", None).await.unwrap(); + assert_eq!(recompiled.version, synced.version); + assert_eq!(recompiled.source.css, synced.source.css); + assert!(recompiled.compiled_html.contains("body { color: red; }")); + assert!(!recompiled.runtime.ui_recompile_required); + + let rolled_back = manager.rollback(&app.id, app.version).await.unwrap(); + assert_eq!(rolled_back.version, recompiled.version + 1); + // sync_from_fs snapshots the source already loaded from disk; keep this + // boundary explicit before moving manager/runtime ownership. + assert_eq!(rolled_back.source.css, "body { color: red; }"); + assert!(rolled_back.runtime.deps_dirty); + assert!(rolled_back.runtime.worker_restart_required); + assert_eq!(manager.list_versions(&app.id).await.unwrap(), vec![1, 2]); + } + + #[tokio::test] + async fn import_from_path_preserves_fallback_files_recompile_and_runtime_state() { + let manager = test_manager(); + let import_root = std::env::temp_dir().join(format!( + "bitfun-miniapp-import-source-{}", + uuid::Uuid::new_v4() + )); + write_import_source(&import_root).await; + + let imported = manager + .import_from_path(import_root.clone(), None) + .await + .unwrap(); + let app_dir = manager.path_manager().miniapp_dir(&imported.id); + let source_dir = app_dir.join(SOURCE_DIR); + + assert_ne!(imported.id, "template-id"); + assert_eq!(imported.name, "Imported"); + assert_eq!(imported.version, 7); + assert_eq!(imported.source.css, "body { color: blue; }"); + assert!(imported.compiled_html.contains("textContent = 'imported'")); + assert!(!imported.runtime.deps_dirty); + assert!(imported.runtime.worker_restart_required); + assert!(!imported.runtime.ui_recompile_required); + + assert_eq!( + tokio::fs::read_to_string(source_dir.join(ESM_DEPS_JSON)) + .await + .unwrap(), + "[]" + ); + assert_eq!( + tokio::fs::read_to_string(app_dir.join(STORAGE_JSON)) + .await + .unwrap(), + "{}" + ); + let package_json: serde_json::Value = serde_json::from_str( + &tokio::fs::read_to_string(app_dir.join(PACKAGE_JSON)) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(package_json["name"], format!("miniapp-{}", imported.id)); + assert_eq!(package_json["dependencies"], serde_json::json!({})); + assert!( + tokio::fs::read_to_string(app_dir.join(COMPILED_HTML)) + .await + .unwrap() + .contains("textContent = 'imported'") + ); + + let _ = tokio::fs::remove_dir_all(import_root).await; + } + #[tokio::test] async fn draft_lifecycle_keeps_active_storage_and_source_isolated_until_apply() { let manager = test_manager(); diff --git a/src/crates/product-domains/tests/miniapp_contracts.rs b/src/crates/product-domains/tests/miniapp_contracts.rs index efb59e9e15..1f1b1031a0 100644 --- a/src/crates/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/product-domains/tests/miniapp_contracts.rs @@ -3,15 +3,15 @@ use bitfun_product_domains::miniapp::bridge_builder::{build_bridge_script, build_csp_content}; use bitfun_product_domains::miniapp::compiler::compile; use bitfun_product_domains::miniapp::customization::{ + MAX_DECLINED_BUILTIN_UPDATES, MiniAppCustomizationBaseline, MiniAppCustomizationLocalSnapshot, + MiniAppCustomizationMetadata, MiniAppCustomizationOrigin, MiniAppCustomizationOriginKind, apply_draft_customization_metadata, decline_builtin_update_metadata, declined_builtin_update_needs_local_snapshot, is_current_declined_builtin_update, - mark_builtin_update_available_metadata, MiniAppCustomizationBaseline, - MiniAppCustomizationLocalSnapshot, MiniAppCustomizationMetadata, MiniAppCustomizationOrigin, - MiniAppCustomizationOriginKind, MAX_DECLINED_BUILTIN_UPDATES, + mark_builtin_update_available_metadata, }; use bitfun_product_domains::miniapp::draft::{ - build_draft_manifest, build_draft_response, MINIAPP_DRAFT_STATUS_APPLIED, - MINIAPP_DRAFT_STATUS_DRAFT, + MINIAPP_DRAFT_STATUS_APPLIED, MINIAPP_DRAFT_STATUS_DRAFT, build_draft_manifest, + build_draft_response, }; use bitfun_product_domains::miniapp::exporter::{ExportCheckResult, ExportTarget}; use bitfun_product_domains::miniapp::host_routing::{ @@ -30,21 +30,21 @@ use bitfun_product_domains::miniapp::ports::{ MiniAppRuntimePort, }; use bitfun_product_domains::miniapp::runtime::{ - candidate_dirs, candidate_executable_path, runtime_lookup_order, version_manager_roots, - versioned_executable_candidate, RuntimeKind, + RuntimeKind, candidate_dirs, candidate_executable_path, runtime_lookup_order, + version_manager_roots, versioned_executable_candidate, }; use bitfun_product_domains::miniapp::storage::{ - build_import_fallbacks, build_package_json, parse_npm_dependencies, MiniAppImportLayout, - MiniAppStorageLayout, COMPILED_HTML, CUSTOMIZATION_JSON, DRAFTS_CLEANUP_MARKER, - DRAFTS_CLEANUP_PREFIX, DRAFTS_DIR, DRAFT_JSON, EMPTY_ESM_DEPENDENCIES_JSON, EMPTY_STORAGE_JSON, - ESM_DEPS_JSON, INDEX_HTML, META_JSON, PACKAGE_JSON, PLACEHOLDER_COMPILED_HTML, + COMPILED_HTML, CUSTOMIZATION_JSON, DRAFT_JSON, DRAFTS_CLEANUP_MARKER, DRAFTS_CLEANUP_PREFIX, + DRAFTS_DIR, EMPTY_ESM_DEPENDENCIES_JSON, EMPTY_STORAGE_JSON, ESM_DEPS_JSON, INDEX_HTML, + META_JSON, MiniAppImportLayout, MiniAppStorageLayout, PACKAGE_JSON, PLACEHOLDER_COMPILED_HTML, REQUIRED_SOURCE_FILES, SOURCE_DIR, STORAGE_JSON, STYLE_CSS, UI_JS, VERSIONS_DIR, WORKER_JS, + build_import_fallbacks, build_package_json, parse_npm_dependencies, }; use bitfun_product_domains::miniapp::types::{ FsPermissions, MiniApp, MiniAppPermissions, MiniAppRuntimeState, MiniAppSource, NetPermissions, NotificationPermissions, NpmDep, }; -use bitfun_product_domains::miniapp::worker::{install_command_for_runtime, InstallResult}; +use bitfun_product_domains::miniapp::worker::{InstallResult, install_command_for_runtime}; use std::path::{Path, PathBuf}; struct RuntimePortStub; @@ -759,10 +759,12 @@ fn miniapp_customization_decline_policy_updates_existing_and_trims_old_records() metadata.declined_builtin_updates.len(), MAX_DECLINED_BUILTIN_UPDATES ); - assert!(!metadata - .declined_builtin_updates - .iter() - .any(|record| record.source_hash == "hash-v5")); + assert!( + !metadata + .declined_builtin_updates + .iter() + .any(|record| record.source_hash == "hash-v5") + ); } fn sample_miniapp_for_lifecycle(source: MiniAppSource) -> MiniApp { From 894291ee5fe5cda8e092be64df9701b7b9592aa5 Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 18 May 2026 16:34:18 +0800 Subject: [PATCH 27/76] refactor(agent-tools): extract tool manifest contracts --- AGENTS.md | 10 +- docs/architecture/core-decomposition.md | 25 +- docs/plans/core-decomposition-plan.md | 31 +-- scripts/check-core-boundaries.mjs | 91 +++++-- src/crates/agent-tools/src/framework.rs | 143 +++++++++++ src/crates/agent-tools/src/lib.rs | 9 +- .../agent-tools/tests/tool_contracts.rs | 180 ++++++++++++- src/crates/core/AGENTS.md | 9 +- .../core/src/agentic/tools/framework.rs | 21 +- .../src/agentic/tools/manifest_resolver.rs | 242 ++++++++---------- src/crates/core/src/agentic/tools/registry.rs | 6 +- 11 files changed, 562 insertions(+), 205 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dad1120bc2..1fb07a749e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,11 +120,13 @@ and milestone verification gates. ### Tool ownership guardrails -- `src/crates/agent-tools` owns lightweight tool contracts and the generic - registry / dynamic-provider container. +- `src/crates/agent-tools` owns lightweight tool contracts, pure + manifest/exposure contracts, and the generic registry / dynamic-provider + container. - `src/crates/core/src/agentic/tools` owns product tool assembly, `dyn Tool` - adaptation, snapshot decoration, tool exposure / manifest resolution, and - on-demand tool spec discovery (`GetToolSpec`) for now. + adaptation, snapshot decoration, runtime manifest assembly / context + filtering, and on-demand tool spec discovery execution (`GetToolSpec`) for + now. - Keep `ToolUseContext` and concrete tool implementations in core until a reviewed port/provider design and equivalence tests exist. - Tool migrations must preserve expanded/collapsed exposure, prompt-visible diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 5f57272d79..e9b78529b8 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -66,7 +66,7 @@ Rust 编译和链接面。 | `bitfun-agent-stream` | Stream 聚合和 stream-focused 测试 | done:stream 聚合已独立 | | `bitfun-runtime-ports` | 面向 service/agent 边界的轻量跨层 DTO 和 trait | partial:DTO/trait-only 边界已建立,包含 agent submission/transcript/cancel、remote state、runtime event 与 remote image attachment 契约;不拥有 runtime 实现 | | `bitfun-agent-runtime` | Sessions、execution、coordination、agent system | target:crate 尚不存在,agent runtime 仍在 core | -| `bitfun-agent-tools` | 轻量 tool DTO / contract、runtime restriction、generic registry / provider container | partial:product manifest、`ToolUseContext`、`GetToolSpec` 和 concrete tools 仍在 core | +| `bitfun-agent-tools` | 轻量 tool DTO / contract、runtime restriction、pure manifest/exposure contract、generic registry / provider container | partial:runtime manifest assembly / context filtering、`ToolUseContext`、`GetToolSpec` 执行和 concrete tools 仍在 core | | `bitfun-tool-packs` | 由 feature group 隔离的具体工具实现 | target/scaffold:不得声明 concrete tools 已迁移 | | `bitfun-services-core` | Config、session、workspace、storage、filesystem、system services | partial:部分 pure helper 已迁出;config/workspace/filesystem runtime 多数仍在 core | | `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle 与 tracker event reduction,dispatcher/product execution 仍在 core | @@ -135,11 +135,12 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate - DeepResearch 现在包含 citation renumber post-turn hook。迁移 agent runtime 或 prompt/report 处理前,必须保留 `report.md` / `citations.md` / `display_map.json` 的 deterministic post-processing 行为; 在此之前该 hook 仍属于 `bitfun-core` agent runtime assembly。 -- 最新主干新增 on-demand tool spec discovery。`ToolExposure`、`GetToolSpec`、 - `manifest_resolver`、collapsed-tool catalog、context-aware tool schema/description - 和 `ToolUseContext.unlocked_collapsed_tools` 暂时属于 `bitfun-core` product tool runtime; - 迁移前必须证明 prompt-visible manifest、expanded/collapsed exposure、unlock state 与 - desktop/MCP/ACP tool catalog 等价。 +- 最新主干新增 on-demand tool spec discovery。`ToolExposure`、`GetToolSpec` 名称、 + collapsed stub 和 manifest ordering 等纯契约可由 `bitfun-agent-tools` 拥有;但 + `manifest_resolver`、产品 registry snapshot、collapsed-tool catalog、context-aware + tool schema/description、`GetToolSpec` 执行和 `ToolUseContext.unlocked_collapsed_tools` + 暂时仍属于 `bitfun-core` product tool runtime。继续迁移前必须证明 prompt-visible + manifest、expanded/collapsed exposure、unlock state 与 desktop/MCP/ACP tool catalog 等价。 - 最新主干的 remote workspace guard 和 search fallback/context 修复提高了 workspace/search 迁移门槛。后续迁移 workspace 或 search runtime 时,必须保留 remote workspace metadata、 startup runtime ensure、remote flashgrep fallback、preview mapping 和 local/remote fallback 语义。 @@ -183,7 +184,7 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate built-in asset seeding/source-hash lookup, host-dispatch execution, `PathManager` integration, function-agent Git/AI calls, prompt templates, JSON extraction, and error mapping. - - 高风险:`ToolUseContext`、product tool registry / manifest / exposure / `GetToolSpec` owner 化、 + - 高风险:`ToolUseContext`、product tool registry / runtime manifest assembly / `GetToolSpec` 执行 owner 化、 MCP concrete tool integration、remote-connect、remote SSH runtime、miniapp / function-agent runtime、 agent registry、`bitfun-core default = []` 或任何产品 crate feature set 调整。 @@ -193,7 +194,7 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate owner 主题:`services-integrations` runtime 收口、MCP runtime/dynamic tools、 remote-connect runtime、agent tools + `tool-packs` owner 化、`product-domains` runtime + core facade finalization。PR 2 的 MCP runtime/dynamic tools 已完成;后续不得把 - remote-connect、product tool manifest/exposure owner 化或 product-domain runtime 顺带混入已完成的 MCP PR。 + remote-connect、product tool runtime manifest / `GetToolSpec` 执行 owner 化或 product-domain runtime 顺带混入已完成的 MCP PR。 `bitfun-core default = []` 和 per-product feature matrix 仍是上述 runtime 队列之后的独立评估。 - 当前批次的 remote-connect runtime 收口以“tracker / wire / pure policy / registry lifecycle 归 `bitfun-services-integrations`,dispatcher / product execution 显式保留 core-owned”作为可合入闭环。 @@ -203,7 +204,7 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate transport lifecycle、resource/prompt adapter、catalog cache、list-changed / reconnect policy、 dynamic tool descriptor、dynamic tool provider 与 result rendering。`bitfun-core` 保留 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、旧路径 facade - 和全局 tool registry / manifest 组装;product tool manifest/exposure owner 化仍归后续 tool/provider PR。 + 和全局 tool registry / manifest 组装;product tool runtime manifest / `GetToolSpec` 执行 owner 化仍归后续 tool/provider PR。 - core MCP facade 当前允许保留窄 adapter 语义:data-dir injection、credential/config store adapter、 `BitFunError` 映射、legacy facade、product tool wrapper 和 global registry / manifest 接入。 如果继续收敛 MCP manager 行为,必须先补 config failure、catalog invalidation、list-changed @@ -211,6 +212,12 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate - 已合入的 semantic baseline 已补 config failure、catalog replacement invalidation、沿用既有 list-changed helper baseline、dynamic manifest order/metadata、tool manifest / `GetToolSpec`、MiniApp storage layout adapter 等价和 remote search fallback gate;这些都是 behavior-locking tests,不移动 runtime owner。 +- 当前 tool manifest contract closure PR 只把 `ToolExposure`、`GetToolSpec` 名称、纯 manifest + policy、collapsed prompt stub 与 prompt-visible ordering 归入 `bitfun-agent-tools`,并让 core + runtime manifest resolver 委托这些纯 helper;不迁移 `ToolUseContext`、registry snapshot、 + context-aware schema/description、`GetToolSpecTool` 执行、unlock state 或 concrete tool implementation。 + 该 PR 闭环后,后续不应再插入 baseline-only PR 才开始 runtime owner 迁移;下一组 PR 应直接以 + 单一 owner 为单位移动实际 runtime,并沿用本节等价测试和边界脚本。 - 已合入的 `Services/Product Runtime Owner Closure` 只收口已经有 port/contract 保护的低风险 owner: remote-SSH session identity / mirror path / unresolved-session layout 归属 `bitfun-services-integrations`,MiniApp storage/import file layout、fallback payload diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 51a9e81b66..caaa35275e 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -971,7 +971,7 @@ product-full = ["git", "mcp", "remote-ssh", "remote-connect", "announcement", "f - [ ] 再迁移 `remote-ssh`,保留 `ssh-remote` 语义。 - [x] 先迁移 `remote-ssh` 的纯 contract/type、workspace path/identity helper 与 unresolved-session-key helper,runtime manager / fs / terminal 仍保留在 core。 - [x] 迁移 `mcp` 的 PR2 runtime 与 dynamic provider:config service orchestration、server process / transport lifecycle、resource/prompt adapter、catalog cache、list-changed/reconnect policy、dynamic descriptor / provider / result rendering 均归属 `bitfun-services-integrations`。 -- [x] `bitfun-core` 保留 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、旧路径 facade 和全局 tool registry / manifest 组装;product tool manifest/exposure owner 化不混入本 PR。 +- [x] `bitfun-core` 保留 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、旧路径 facade 和全局 tool registry / manifest 组装;product tool runtime manifest / `GetToolSpec` 执行 owner 化不混入本 PR。 - [x] 先迁移 `announcement` 的纯 types contract,scheduler / state store / content loader / remote fetch 仍保留在 core。 - [x] 先完成 `remote-connect` contract slice:remote chat/image/tool/session wire DTO 与 relay/bot session/submission request builder 由 `bitfun-services-integrations` 拥有,relay/bot session 创建通过 `AgentSubmissionPort`。 - [x] 已补齐 remote runtime 迁移前的第一层 port baseline:`SessionTranscriptReader`、`AgentTurnCancellationPort`、`RemoteControlStatePort`、`RuntimeEventSink` 与 remote image attachment/request DTO;完整 `remote-connect` runtime 仍需后续单独迁移并补 queue/event/image 行为等价测试。 @@ -984,7 +984,7 @@ product-full = ["git", "mcp", "remote-ssh", "remote-connect", "announcement", "f **当前安全迁移状态(2026-05-15):** - 已迁移到 `bitfun-services-integrations`:`service::file_watch`,通过 `file-watch` / `product-full` feature 启用,并保持 `core::service::file_watch` 旧路径。 -- `git` 已完成 DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 迁移;`bitfun-core::service::git::*` 仅保留 legacy facade re-export。`remote-ssh` 已迁移纯 contract/type、workspace path/identity helper 与 unresolved-session-key helper;SSH runtime manager / fs / terminal、password vault 与 PathManager-backed session mirror assembly 仍保留在 core。`mcp` 已迁移 tool-name / tool-info / protocol types / config location / server type-status、server config、cursor-format、JSON-RPC request builder、JSON config format/validation helper、config merge / remote authorization helper、OAuth credential vault / authorization bootstrap contract、remote auth error classifier、legacy remote header fallback helper、transport Authorization 归一化 helper、remote client capability helper、rmcp 到 BitFun protocol 的纯映射 helper、resource/prompt adapter、catalog cache、list-changed/reconnect policy、config service save-load orchestration、server process / local-remote transport lifecycle、dynamic tool descriptor / provider / result rendering helper,并用 owner crate contract test 锁定 wire shape、transport default、validation message、Cursor 兼容格式、config precedence / dedup 语义、OAuth vault 存储路径注入、NeedsAuth 分类、旧 env Authorization fallback、remote client capabilities、remote result metadata / structured content 映射、config load/save/delete contract、unsupported remote transport contract、context resource selection 和 dynamic manifest;`bitfun-core` 继续负责 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和全局 tool registry / manifest 组装。`announcement` 仅迁移了纯 types contract,scheduler / state store / content loader / remote fetch 仍保留在 core;`remote-connect` 已完成 contract/request-builder slice,补齐 cancellation/state/event/image 第一层 port baseline,迁出 command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、tracker state / registry lifecycle / tracker event reduction / remote tool preview slimming helper、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy,并补齐 remote command/response、restore、active turn、cancel、image context、tracker fanout 与 queue policy 迁移前快照;但远程消息执行、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行仍保留在 core。它们涉及 SSH runtime、remote agent submission runtime、product tool manifest/exposure owner 化与 announcement config/path 边界,继续前需要单独确认端口方案与等价性测试。 +- `git` 已完成 DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 迁移;`bitfun-core::service::git::*` 仅保留 legacy facade re-export。`remote-ssh` 已迁移纯 contract/type、workspace path/identity helper 与 unresolved-session-key helper;SSH runtime manager / fs / terminal、password vault 与 PathManager-backed session mirror assembly 仍保留在 core。`mcp` 已迁移 tool-name / tool-info / protocol types / config location / server type-status、server config、cursor-format、JSON-RPC request builder、JSON config format/validation helper、config merge / remote authorization helper、OAuth credential vault / authorization bootstrap contract、remote auth error classifier、legacy remote header fallback helper、transport Authorization 归一化 helper、remote client capability helper、rmcp 到 BitFun protocol 的纯映射 helper、resource/prompt adapter、catalog cache、list-changed/reconnect policy、config service save-load orchestration、server process / local-remote transport lifecycle、dynamic tool descriptor / provider / result rendering helper,并用 owner crate contract test 锁定 wire shape、transport default、validation message、Cursor 兼容格式、config precedence / dedup 语义、OAuth vault 存储路径注入、NeedsAuth 分类、旧 env Authorization fallback、remote client capabilities、remote result metadata / structured content 映射、config load/save/delete contract、unsupported remote transport contract、context resource selection 和 dynamic manifest;`bitfun-core` 继续负责 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和全局 tool registry / manifest 组装。`announcement` 仅迁移了纯 types contract,scheduler / state store / content loader / remote fetch 仍保留在 core;`remote-connect` 已完成 contract/request-builder slice,补齐 cancellation/state/event/image 第一层 port baseline,迁出 command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、tracker state / registry lifecycle / tracker event reduction / remote tool preview slimming helper、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy,并补齐 remote command/response、restore、active turn、cancel、image context、tracker fanout 与 queue policy 迁移前快照;但远程消息执行、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行仍保留在 core。它们涉及 SSH runtime、remote agent submission runtime、product tool runtime manifest / `GetToolSpec` 执行 owner 化与 announcement config/path 边界,继续前需要单独确认端口方案与等价性测试。 - 最新主干的 Deep Review capacity / cost / queue、context profile、evidence ledger、session manifest、stream dedupe、search remote/fallback 与 session rollback persistence 仍属于 core runtime 或对应产品 runtime,不在本轮 `services-integrations` 迁移范围内;如果后续迁移 remote-connect / MCP / search / session,需要先定义运行状态 port 合约和等价测试。 **验证:** @@ -1018,6 +1018,7 @@ cargo check -p bitfun-cli **任务:** - [x] 抽出 tool result、validation、dynamic metadata、runtime restriction、path resolution DTO,以及 generic registry / dynamic provider container 到 `agent-tools`。 +- [x] 抽出纯 manifest/exposure 契约到 `agent-tools`:`ToolExposure`、`GetToolSpec` 名称、纯 manifest policy、collapsed prompt stub 与 prompt-visible ordering;core 继续拥有 runtime assembly。 - [ ] 抽出 `Tool` trait 与 `ToolUseContext` 前,先补可移植 tool context / service port 设计;当前不做无端口支撑的行为迁移。 - [x] `agent-tools` 不依赖任何 concrete service。 - [ ] 将工具实现迁移到 `tool-packs` crate,并按 feature group 分模块: @@ -1046,19 +1047,19 @@ pub fn create_tool_registry() -> ToolRegistry { ``` - [ ] 增加 registry / manifest 等价性测试:完整产品 registry、expanded/collapsed exposure 与 prompt-visible manifest 和拆分前一致。 -- [ ] 迁移 tool exposure / manifest / `GetToolSpec` 前,补 expanded/collapsed manifest、 +- [ ] 迁移 runtime manifest assembly / `GetToolSpec` 执行前,补 expanded/collapsed manifest、 prompt-visible stub、unlock state 和 desktop/MCP/ACP catalog 等价测试。 **当前安全迁移状态(2026-05-14):** - 已迁移到 `bitfun-agent-tools`:`ToolResult`、`ValidationResult`、`InputValidator`、dynamic tool metadata、tool render options、runtime restriction DTO、path resolution DTO,以及不依赖 core service 的 `ToolRegistry` / `ToolRegistryItem` generic registry container。dynamic tool provider / decorator contract 已通过 `agent-tools` 提供兼容 re-export,原 `runtime-ports` 路径保持可用;core 旧路径继续 re-export,并只保留 `BitFunError` 映射与路径 containment helper。 -- `bitfun-core::agentic::tools` 现在保留产品完整工具列表、snapshot decorator 组装、旧构造函数、`dyn Tool` 到 generic registry 的适配,以及最新主干新增的 tool exposure / manifest resolution / `GetToolSpec` 按需工具说明发现;dynamic metadata map、tool map、dynamic descriptor assembly 由 `bitfun-agent-tools` 拥有。 +- `bitfun-core::agentic::tools` 现在保留产品完整工具列表、snapshot decorator 组装、旧构造函数、`dyn Tool` 到 generic registry 的适配,以及最新主干新增的 runtime manifest assembly / context filtering / `GetToolSpec` 执行;dynamic metadata map、tool map、dynamic descriptor assembly 和纯 manifest/exposure 契约由 `bitfun-agent-tools` 拥有。 - 已新增 `bitfun-tool-packs` feature scaffold,默认 feature 为空,`product-full` 只聚合 feature,不注册或迁移任何工具实现。 -- 已通过 boundary check 锁定 `agent-tools` / `tool-packs` 暂不拥有 product tool manifest、`ToolExposure`、`GetToolSpec` 或 collapsed-tool unlock state;这些仍由 core product tool runtime 负责。 +- 已通过 boundary check 锁定 `agent-tools` / `tool-packs` 暂不拥有 product tool runtime assembly、`GetToolSpecTool` 执行或 collapsed-tool unlock state;`tool-packs` 也不得拥有 manifest/exposure 契约。`agent-tools` 只允许拥有纯 manifest/exposure helper,core product tool runtime 继续负责产品 registry snapshot、context-aware schema/description、unlock state 和执行路径。 - boundary check 也已补充 core owner anchor:要求产品工具注册、expanded/collapsed manifest、`GetToolSpec` duplicate-load guard、`ToolUseContext.unlocked_collapsed_tools`、执行管线 gating 与 execution unlock collector 仍保留在 core。后续若迁移这些 owner,必须先更新 port/provider 设计、等价测试与该脚本,而不能只删除 core 侧实现。 - `Tool` trait、`ToolUseContext` 和具体工具实现仍在 core;它们直接连接 workspace service、snapshot wrapper、computer-use host、cancellation token 与 Deep Review checkpoint hook,继续迁移前必须先确认可移植 tool context / provider port 方案,并补工具清单等价性测试。 - 最新主干新增的 Deep Review shared-context / evidence-ledger checkpoint hook 仍保留在 core 的 `ToolUseContext` 中;在设计独立 tool context / event port 前,不应把 `ToolUseContext` 或 concrete tool implementation 继续外移。 -- 最新主干新增 on-demand tool spec discovery:`ToolExposure`、`GetToolSpec`、`manifest_resolver`、collapsed-tool catalog、context-aware `description_with_context` / `input_schema_for_model_with_context`,以及 `ToolUseContext.unlocked_collapsed_tools` 均会影响模型可见工具集合。该变化不推翻 PR4 的低风险结论,但把后续 tool/provider 迁移提升为高风险项,不能在 PR5 product-domain 收尾中顺带执行。 +- 最新主干新增 on-demand tool spec discovery:`ToolExposure`、`GetToolSpec` 名称、collapsed prompt stub 与 manifest ordering 的纯契约已可由 `bitfun-agent-tools` 承载;`manifest_resolver`、collapsed-tool catalog、context-aware `description_with_context` / `input_schema_for_model_with_context`、`GetToolSpecTool` 执行以及 `ToolUseContext.unlocked_collapsed_tools` 仍会影响模型可见工具集合。该变化不推翻 PR4 的低风险结论,但把后续 tool/provider 迁移提升为高风险项,不能在 product-domain runtime 收尾中顺带执行。 **验证:** @@ -1534,8 +1535,8 @@ cargo check --workspace - 已完成中等粒度 owner crate 成型的安全部分:`bitfun-services-core`、`bitfun-services-integrations`、`bitfun-agent-tools`、`bitfun-tool-packs`、`bitfun-product-domains` 均已加入 workspace。 - 已迁移的模块均由 core facade re-export,未改变产品默认 feature、构建脚本或 release 脚本。 -- Git feature group 已闭环迁移到 `bitfun-services-integrations` 的 `git` feature:DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 均由 integrations owner crate 拥有,并通过 `bitfun-core::service::git::*` 保留旧路径兼容。`GitService` 所需的 Windows `libgit2` system-link 边界挂在该 crate 的 `git` feature 上;`bitfun-core` 仍因未迁移的 remote-connect runtime 保留其它 `git2` 使用。remote-ssh 本轮进一步外移 workspace path/identity 与 unresolved-session-key helper,并用 owner crate contract test 锁定 normalized path、mirror subpath、hostname sanitization、stable id 和 unresolved key 输出;PathManager-backed mirror root、global workspace registry、SSH manager/fs/terminal/runtime 仍留在 core。MCP PR2 已进一步外移 config service orchestration、server process / local-remote transport lifecycle、dynamic tool provider 与 context resource selection helper,core 旧路径继续做兼容 facade、core config store adapter、OAuth 数据目录注入与 `BitFunError` 映射。PR4 已将 generic tool registry / dynamic descriptor assembly 迁入 `bitfun-agent-tools`;core 继续负责产品工具列表、snapshot decorator、`dyn Tool` 适配、tool exposure / manifest resolution 与 `GetToolSpec` 按需工具说明发现。 -- 未声明完成的 P2/后续剩余部分:remote-ssh runtime、remote-connect 等重 service 迁移、`ToolUseContext` 外移、tool exposure / manifest / `GetToolSpec` owner 化、concrete tool implementation 迁移、product registry / manifest / provider assembly、miniapp/function-agent 运行逻辑迁移。这些会触碰 `PathManager`、`ToolUseContext`、workspace service、snapshot wrapper、prompt-visible tool catalog、`AgentSubmissionPort` 或 AI service 边界,需要在继续前显式确认。 +- Git feature group 已闭环迁移到 `bitfun-services-integrations` 的 `git` feature:DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 均由 integrations owner crate 拥有,并通过 `bitfun-core::service::git::*` 保留旧路径兼容。`GitService` 所需的 Windows `libgit2` system-link 边界挂在该 crate 的 `git` feature 上;`bitfun-core` 仍因未迁移的 remote-connect runtime 保留其它 `git2` 使用。remote-ssh 本轮进一步外移 workspace path/identity 与 unresolved-session-key helper,并用 owner crate contract test 锁定 normalized path、mirror subpath、hostname sanitization、stable id 和 unresolved key 输出;PathManager-backed mirror root、global workspace registry、SSH manager/fs/terminal/runtime 仍留在 core。MCP PR2 已进一步外移 config service orchestration、server process / local-remote transport lifecycle、dynamic tool provider 与 context resource selection helper,core 旧路径继续做兼容 facade、core config store adapter、OAuth 数据目录注入与 `BitFunError` 映射。PR4 已将 generic tool registry / dynamic descriptor assembly 迁入 `bitfun-agent-tools`;本轮进一步迁入纯 tool manifest/exposure 契约。core 继续负责产品工具列表、snapshot decorator、`dyn Tool` 适配、runtime manifest assembly / context filtering 与 `GetToolSpec` 执行。 +- 未声明完成的 P2/后续剩余部分:remote-ssh runtime、remote-connect 等重 service 迁移、`ToolUseContext` 外移、runtime manifest assembly / `GetToolSpec` 执行 owner 化、concrete tool implementation 迁移、product registry / provider assembly、miniapp/function-agent 运行逻辑迁移。这些会触碰 `PathManager`、`ToolUseContext`、workspace service、snapshot wrapper、prompt-visible tool catalog、`AgentSubmissionPort` 或 AI service 边界,需要在继续前显式确认。 - 本次 rebase 后重新核对最新主干 Deep Review capacity/cost/queue、context profile、evidence ledger 与 session manifest 变更:当前 PR 已完成 Git feature group 的 owner crate 归属迁移,但未改动这些 Deep Review 行为路径;后续迁移必须补端口设计和等价测试后再推进。 - 本次 rebase 后重新核对最新主干 tool 变更:on-demand tool spec discovery 新增 collapsed/expanded manifest、`GetToolSpec`、context-aware schema/description 与 unlock state。这不要求回退当前 P2 已完成内容,但要求后续 tool/provider 迁移先补 manifest / catalog / unlock 等价保护,且不得和 PR5 product-domain runtime 收口混合。 - PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、import layout / fallback payload contract、lifecycle / revision helper、manager 纯状态转换 helper、host routing string / allowlist policy helper、customization metadata / permission diff、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / commit prompt preparation / AI response parsing policy / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper;core 只保留 AI client 调用、JSON 提取、错误映射、Git service adapter 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试,并补齐 MiniApp manager import/sync/recompile/rollback/deps state 与 function-agent staged diff / AI response error mapping 的迁移前快照。PathManager、Git/AI service、prompt template、builtin asset seeding、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 @@ -1558,7 +1559,7 @@ cargo check --workspace - 已扩展 `scripts/check-core-boundaries.mjs`,增加 dependency profile / feature graph 静态保护:`core-types` default profile 禁止非 DTO 依赖,`runtime-ports` default profile 禁止 service implementation 依赖,`agent-tools` contract profile 禁止依赖 `bitfun-ai-adapters`,`product-domains` default profile 禁止无条件拉入 `dirs`,`services-integrations` default profile 禁止无条件拉入 feature-gated integration 依赖。 - 已将 `ToolImageAttachment` 提升到 `bitfun-core-types`,并由 `bitfun-ai-adapters`、`bitfun-agent-tools` 和 `bitfun-core::util::types` 保留旧路径兼容;`bitfun-agent-tools` 不再依赖 `bitfun-ai-adapters`。 - 已将 `product-domains` 的 `dirs` 依赖限制到 `miniapp` feature,默认 profile 保持轻量。 -- 已为 `product-domains` 增加 runtime-owner 静态保护,禁止在未确认 port/provider 迁移方案前引入进程启动、具体 Git/AI 服务、网络客户端或平台 API;也已锁定 `agent-tools` / `tool-packs` 暂不拥有 product tool manifest、`GetToolSpec` 或 collapsed-tool unlock state。 +- 已为 `product-domains` 增加 runtime-owner 静态保护,禁止在未确认 port/provider 迁移方案前引入进程启动、具体 Git/AI 服务、网络客户端或平台 API;也已锁定 `agent-tools` / `tool-packs` 暂不拥有 product tool runtime assembly、`GetToolSpecTool` 执行或 collapsed-tool unlock state。 - 已为 core 侧高风险 owner 增加 required-content anchor,覆盖 product tool registry / manifest / `GetToolSpec` / collapsed-tool unlock 流,以及 MiniApp storage/runtime adapter 与 function-agent Git adapter;该检查用于避免“轻量 crate 已抽出”被误解为 runtime owner 已迁移。 - 已补充 `ToolResult` image attachment、dynamic provider metadata、dynamic descriptor wire shape、runtime restrictions、path resolution contract、generic tool registry descriptor/stale metadata 测试,以及 core 内置 tool registry 清单快照测试;后续迁移 `ToolUseContext`、product registry / manifest assembly 或 concrete tool implementation 前必须保持这些基线。 - 已将 generic tool registry / dynamic provider descriptor assembly 迁入 `bitfun-agent-tools`;core tool runtime 保留产品完整工具列表、manifest/exposure、snapshot decorator 和 `dyn Tool` 适配,并通过 boundary check 禁止重新拥有 `IndexMap` 工具容器或 dynamic metadata map。 @@ -1590,7 +1591,7 @@ P2 后产品表面契约轨道(contract-only): 需要单独审视的高风险项: -- `ToolUseContext`、tool exposure / manifest / `GetToolSpec`、product tool provider assembly、concrete tool implementation 外移。 +- `ToolUseContext`、runtime manifest assembly / `GetToolSpec` 执行、product tool provider assembly、concrete tool implementation 外移。 - MCP concrete tool implementation / product registry / manifest assembly 外移。 - remote-connect、remote-SSH runtime、announcement runtime 外移。 - miniapp runtime/compiler/builtin 与 function-agent 运行逻辑外移。 @@ -1615,13 +1616,13 @@ P2 后产品表面契约轨道(contract-only): **剩余工作压缩为 5 个 PR(2026-05-13):** 1. `services-integrations` runtime 收口:迁移 remote-SSH 中不直接持有 SSH channel / SFTP / terminal handle 的 workspace registry、session mirror 与轻量 runtime helper;继续保留 SSH manager / remote FS / remote terminal 的 core-owned assembly,直到 port/provider 合约明确。`file-watch` 已由 `services-integrations` 拥有,只做 contract 复核;announcement 只迁移不依赖 config service / embedded content / remote fetch 的 state 或 eligibility helper。验收重点是 owner crate contract test、旧路径 facade、boundary check、workspace check/test。 -2. 已完成:MCP runtime 与 dynamic tools:MCP config service orchestration、server process / transport lifecycle、adapter、dynamic tool/resource/prompt provider 已归属 `bitfun-services-integrations`;未混入 remote-connect 或 product tool manifest/exposure owner 化。验收重点是 MCP wire shape、auth/config merge、dynamic manifest 快照和 core registry / manifest 集成等价。 +2. 已完成:MCP runtime 与 dynamic tools:MCP config service orchestration、server process / transport lifecycle、adapter、dynamic tool/resource/prompt provider 已归属 `bitfun-services-integrations`;未混入 remote-connect 或 product tool runtime manifest / `GetToolSpec` 执行 owner 化。验收重点是 MCP wire shape、auth/config merge、dynamic manifest 快照和 core registry / manifest 集成等价。 - 保留边界:`bitfun-core` 只保留 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和与全局 tool registry / manifest 的组装调用;配置写入、OAuth、SSE/session 与 registry / manifest 行为不得在本 PR 中改变。 - 后续切片:MCP concrete tool integration / product registry / manifest assembly 继续保留 dynamic provider metadata、工具清单顺序、expanded/collapsed exposure 和 snapshot wrapper 等价测试。 - 文档校正:P2 后补充文档中的 MCP runtime step 已由本 PR2 闭环;后续 MCP 相关工作只保留 concrete tool implementation 迁移或 product registry / manifest assembly,不再重复迁移 config/process/transport lifecycle。 3. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO、remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、`AgentTurnCancellationPort`、`RemoteControlStatePort`、`RuntimeEventSink`、`RemoteSessionStateTracker`、`RemoteSessionTrackerRegistry`、`TrackerEvent`、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 已具备 owner/port 契约;core 仍保留 tracker host adapter、`ImageContextData` adapter、file IO/path resolution、dispatcher/product execution。 - 本轮收口:remote-connect 在当前批次以 tracker / wire / pure policy / registry lifecycle 归 owner crate、dispatcher / product execution 显式保留 core-owned 闭环;若未来继续迁移完整 dialog submission、terminal pre-warm、file IO/path resolution 或 `ImageContextData` adapter,必须另起 port/provider 设计与行为等价评审,不得混入 tool/provider owner 化。 -4. 已完成本轮可提交闭环:agent tools + `tool-packs` owner 化低风险部分。纯 tool contract/provider metadata、runtime restriction DTO、path resolution DTO、generic tool registry / dynamic provider container 已迁入 `bitfun-agent-tools`,并为 dynamic provider contract 提供 `agent-tools` 兼容 re-export;core tool runtime 保留产品完整工具列表、snapshot decorator、`dyn Tool` 适配、tool exposure / manifest resolution 和 `GetToolSpec` 按需工具说明发现。`ToolUseContext`、tool manifest/exposure 与 concrete tool implementation 按 feature group 外移需要新的 port/provider 设计,必须保持 builtin/readonly/dynamic manifest、expanded/collapsed exposure、prompt stub、unlock state、snapshot wrapping、runtime restrictions、cancellation 与 Deep Review tool flow 等价,作为后续高风险迁移单独审视。 +4. 已完成本轮可提交闭环:agent tools + `tool-packs` owner 化低风险部分。纯 tool contract/provider metadata、runtime restriction DTO、path resolution DTO、generic tool registry / dynamic provider container,以及纯 manifest/exposure 契约已迁入 `bitfun-agent-tools`,并为 dynamic provider contract 提供 `agent-tools` 兼容 re-export;core tool runtime 保留产品完整工具列表、snapshot decorator、`dyn Tool` 适配、runtime manifest assembly / context filtering 和 `GetToolSpec` 执行。`ToolUseContext`、runtime manifest assembly / `GetToolSpec` 执行与 concrete tool implementation 按 feature group 外移需要新的 port/provider 设计,必须保持 builtin/readonly/dynamic manifest、expanded/collapsed exposure、prompt stub、unlock state、snapshot wrapping、runtime restrictions、cancellation 与 Deep Review tool flow 等价,作为后续高风险迁移单独审视。 5. `product-domains` runtime + core facade finalization:迁移 miniapp runtime/compiler/builtin 与 function-agent 运行逻辑,最后把 `bitfun-core` 收敛为 facade + product runtime assembly;不在本 PR 中修改 `bitfun-core default = []` 或 per-product feature matrix。 `bitfun-core default = []`、per-product feature set、构建矩阵和 release 能力调整仍作为重构完成后的独立评估,不计入上述 5 个 PR。 @@ -1652,11 +1653,11 @@ P2 后产品表面契约轨道(contract-only): **P3 进入条件与最新主干补充(2026-05-18):** -- P3 只能在 P2 剩余迁移闭环后启动:重 service 迁移、`ToolUseContext` / tool exposure / manifest / `GetToolSpec` / concrete tool implementation 迁移、product registry / manifest / provider assembly、miniapp/function-agent 运行逻辑迁移都必须先完成或显式保留为 core-owned runtime;generic registry/provider container 已在 PR4 中完成低风险外移。 +- P3 只能在 P2 剩余迁移闭环后启动:重 service 迁移、`ToolUseContext` / runtime manifest assembly / `GetToolSpec` 执行 / concrete tool implementation 迁移、product registry / provider assembly、miniapp/function-agent 运行逻辑迁移都必须先完成或显式保留为 core-owned runtime;generic registry/provider container 和纯 manifest/exposure 契约已在 agent-tools 低风险外移中完成。 - 最近 `origin/main` 的 Deep Review 变更增加了 context profile、evidence ledger、capacity/cost/queue 控制、`deep_review_run_manifest` / `deep_review_cache`、以及 review-team UI orchestration;最新主干还补充了 agent-stream tool-call dedupe、search remote/fallback、session rollback persistence、remote workspace compatibility guard、ACP startup timeout / operation diff fallback 和 companion typewriter。P3 facade 收敛前必须确认这些行为要么仍由 core product runtime assembly 或对应 product surface 拥有,要么已有对应 owner crate + port/provider 合约和等价测试。 - 最新主干的 mode-scoped subagent visibility 将 `agentic::agents` 重组为 definitions / registry / visibility 边界,并扩展了 desktop subagent API、CLI `/subagents` mode-aware list/config 与 Review Team 可见性测试;后续又加入 `Multitask` mode、内置 `GeneralPurpose` subagent 和后台 subagent result delivery。后续若迁移 agent registry / subagent definitions / scheduler,不能只做路径 re-export,必须保留 mode 可见性过滤、hidden/custom/review 分组语义、CLI availability override 路径、前后端 API contract、`Task.run_in_background` 的 parent metadata / workspace routing、running-turn injection 与 idle-session follow-up turn 语义。 - 最新主干的 DeepResearch citation renumber hook 是 deterministic post-turn runtime 行为,不是普通 prompt 文案;后续若迁移 agent runtime / report finalization,必须保留 `report.md`、`citations.md`、`display_map.json` 与 REJECTED citation 过滤语义。 -- 最新主干的 on-demand tool spec discovery 将 `ToolExposure`、`manifest_resolver`、`GetToolSpec`、collapsed-tool prompt stub 和 `ToolUseContext.unlocked_collapsed_tools` 接入 agent prompt / execution pipeline / desktop-MCP-ACP catalog。P3 facade 收敛前必须把这些显式保留在 core product tool runtime,或先完成等价快照与 port/provider 设计后再迁移。 +- 最新主干的 on-demand tool spec discovery 将 `manifest_resolver`、`GetToolSpecTool` 执行、collapsed-tool catalog 和 `ToolUseContext.unlocked_collapsed_tools` 接入 agent prompt / execution pipeline / desktop-MCP-ACP catalog。P3 facade 收敛前必须把这些显式保留在 core product tool runtime,或先完成等价快照与 port/provider 设计后再迁移;`ToolExposure`、`GetToolSpec` 名称、collapsed-tool prompt stub 和 manifest ordering 仅作为纯契约保留在 `bitfun-agent-tools`。 - 最新主干的 search result rendering / context handling 与 remote workspace compatibility guard 要求后续 `service::search`、`workspace` 或 remote runtime 迁移保留 startup restored workspace guard、remote runtime ensure、remote flashgrep FilesWithMatches fallback、preview split 和 local/remote fallback contract。 - ACP startup timeout 和 Web file-operation diff fallback 属于 product surface 行为:可以在后续 contract 中记录 operation/diff facts,但不能把 ACP timeout policy 或 Web diff rendering 迁入 core contract crate。 - 最新主干的 Web 启动性能优化新增 startup trace、deferred background scheduler、narrow tool initializer、Monaco warmup 与历史会话非阻塞 hydrate;这些属于 web app / Flow Chat product surface,不是 core service 迁移前置条件。后续只能通过 web product checks 验证,不得把 `startupTrace`、`backgroundTaskScheduler`、history hydration 或 tool warmup 下沉到 core-types / runtime-ports / agent-tools。 @@ -1723,7 +1724,7 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts 5. 已完成:抽取 `bitfun-agent-stream`,迁移 stream processor 测试。 6. 已完成:引入 runtime ports 初始边界;后续在 service 迁移中逐步打断 `service <-> agentic` concrete 循环。 7. 已完成:抽取 `bitfun-services-core`。 -8. 已完成:抽取 `bitfun-services-integrations` 的低风险 feature group 和纯 helper,闭环 `git`、remote-SSH contract/helper、MCP 纯 protocol/config/auth helper;MCP runtime / dynamic provider 已在 PR2 补齐,未把 remote-connect 或 product tool manifest/exposure owner 化顺带迁入。 +8. 已完成:抽取 `bitfun-services-integrations` 的低风险 feature group 和纯 helper,闭环 `git`、remote-SSH contract/helper、MCP 纯 protocol/config/auth helper;MCP runtime / dynamic provider 已在 PR2 补齐,未把 remote-connect 或 product tool runtime manifest / `GetToolSpec` 执行 owner 化顺带迁入。 9. 已完成:前移低风险保护项:dependency profile / feature graph 基线、轻量 contract crate 依赖瘦身、feature group 说明、boundary check 扩展、迁移前快照测试。 10. 已提交:PR 1 `services-integrations` runtime 收口,处理 remote-SSH workspace registry / session mirror helper 和已迁移 file-watch 的 contract 复核;announcement 仅迁移无 config/content/remote fetch 依赖的 helper。 11. 已提交:PR 2 `Services/Product Runtime Owner Closure`,收口 remote-SSH session identity / mirror path / unresolved-session layout 与 MiniApp storage file layout owner;core 保留 `PathManager` 注入、SSH manager、remote FS / terminal、MiniApp filesystem IO 和 worker runtime。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 683dfb33c2..07cc8a454f 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -1004,16 +1004,12 @@ const forbiddenContentUnderRules = [ { path: 'src/crates/agent-tools/src', reason: - 'agent-tools must not own product tool manifest/exposure or GetToolSpec runtime without an approved provider migration', + 'agent-tools may own pure tool manifest contracts, but not product manifest runtime or GetToolSpec execution without an approved provider migration', patterns: [ { regex: /\bGetToolSpecTool\b/, message: 'GetToolSpec implementation stays in core product tool runtime', }, - { - regex: /\bGET_TOOL_SPEC_TOOL_NAME\b/, - message: 'GetToolSpec manifest insertion stays in core product tool runtime', - }, { regex: /\bmanifest_resolver\b/, message: 'tool manifest resolution stays in core product tool runtime', @@ -1022,10 +1018,6 @@ const forbiddenContentUnderRules = [ regex: /\bunlocked_collapsed_tools\b/, message: 'collapsed-tool unlock state stays in core ToolUseContext/runtime', }, - { - regex: /\bToolExposure\b/, - message: 'expanded/collapsed exposure policy stays in core until provider migration', - }, { regex: /\bToolUseContext\b/, message: 'ToolUseContext stays in core until a portable context port is reviewed', @@ -1085,6 +1077,37 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/agent-tools/src/framework.rs', + reason: + 'agent-tools may own pure prompt-visible tool manifest contracts without owning product runtime execution', + patterns: [ + { + regex: /\bpub const GET_TOOL_SPEC_TOOL_NAME\b/, + message: 'missing shared GetToolSpec manifest name contract', + }, + { + regex: /\bpub enum ToolExposure\b/, + message: 'missing lightweight tool exposure contract', + }, + { + regex: /\bpub struct ToolManifestPolicyTool\b/, + message: 'missing pure tool manifest policy input contract', + }, + { + regex: /\bpub fn resolve_tool_manifest_policy\b/, + message: 'missing pure tool manifest policy resolver', + }, + { + regex: /\bpub fn build_collapsed_tool_stub_definition\b/, + message: 'missing collapsed-tool prompt stub contract', + }, + { + regex: /\bpub fn sort_tool_manifest_definitions\b/, + message: 'missing prompt-visible manifest ordering helper', + }, + ], + }, { path: 'src/crates/core/src/agentic/coordination/coordinator.rs', reason: @@ -1138,7 +1161,7 @@ const requiredContentRules = [ { path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', reason: - 'core must continue owning prompt-visible tool manifest assembly until an approved provider migration exists', + 'core must continue owning prompt-visible tool manifest assembly and runtime context filtering until an approved provider migration exists', patterns: [ { regex: /\bpub async fn resolve_tool_manifest\b/, @@ -1149,16 +1172,16 @@ const requiredContentRules = [ message: 'missing GetToolSpec manifest insertion anchor', }, { - regex: /\bToolExposure::Collapsed\b/, - message: 'missing collapsed exposure branch', + regex: /\bresolve_tool_manifest_policy\b/, + message: 'missing agent-tools manifest policy contract use', }, { regex: /\bcollapsed_tool_names\b/, message: 'missing collapsed-tool name tracking', }, { - regex: /First call `GetToolSpec`|Use GetToolSpec.*first/, - message: 'missing collapsed-tool prompt stub', + regex: /\bbuild_collapsed_tool_stub_definition\b/, + message: 'missing collapsed-tool prompt stub contract use', }, ], }, @@ -1184,11 +1207,11 @@ const requiredContentRules = [ { path: 'src/crates/core/src/agentic/tools/framework.rs', reason: - 'core must continue owning ToolUseContext and exposure policy until a portable context port is reviewed', + 'core must continue owning ToolUseContext while re-exporting pure exposure contracts until a portable context port is reviewed', patterns: [ { - regex: /\bpub enum ToolExposure\b/, - message: 'missing ToolExposure owner type', + regex: /\bToolExposure\b/, + message: 'missing ToolExposure compatibility re-export', }, { regex: /\bpub struct ToolUseContext\b/, @@ -2505,16 +2528,16 @@ function runManifestParserSelfTest() { if (!agentToolsManifestRule) { throw new Error('missing agent-tools manifest-owner boundary rule'); } - const toolManifestContracts = [ + const agentToolsRuntimeForbiddenContracts = [ 'GetToolSpecTool', - 'GET_TOOL_SPEC_TOOL_NAME', 'manifest_resolver', 'unlocked_collapsed_tools', + 'ToolUseContext', ]; const agentToolsManifestRuleText = agentToolsManifestRule.patterns .map((pattern) => pattern.regex.source) .join('\n'); - for (const contract of toolManifestContracts) { + for (const contract of agentToolsRuntimeForbiddenContracts) { if (!agentToolsManifestRuleText.includes(contract)) { throw new Error(`agent-tools manifest boundary rule must forbid: ${contract}`); } @@ -2528,7 +2551,14 @@ function runManifestParserSelfTest() { const toolPacksManifestRuleText = toolPacksManifestRule.patterns .map((pattern) => pattern.regex.source) .join('\n'); - for (const contract of toolManifestContracts) { + const toolPacksManifestContracts = [ + 'GetToolSpecTool', + 'GET_TOOL_SPEC_TOOL_NAME', + 'manifest_resolver', + 'unlocked_collapsed_tools', + 'ToolExposure', + ]; + for (const contract of toolPacksManifestContracts) { if (!toolPacksManifestRuleText.includes(contract)) { throw new Error(`tool-packs manifest boundary rule must forbid: ${contract}`); } @@ -2544,6 +2574,17 @@ function runManifestParserSelfTest() { 'remote_image', ], }, + { + path: 'src/crates/agent-tools/src/framework.rs', + contracts: [ + 'GET_TOOL_SPEC_TOOL_NAME', + 'ToolExposure', + 'ToolManifestPolicyTool', + 'resolve_tool_manifest_policy', + 'build_collapsed_tool_stub_definition', + 'sort_tool_manifest_definitions', + ], + }, { path: 'src/crates/core/src/agentic/coordination/coordinator.rs', contracts: [ @@ -2625,7 +2666,13 @@ function runManifestParserSelfTest() { }, { path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', - contracts: ['resolve_tool_manifest', 'GET_TOOL_SPEC_TOOL_NAME', 'ToolExposure', 'First call `GetToolSpec`'], + contracts: [ + 'resolve_tool_manifest', + 'GET_TOOL_SPEC_TOOL_NAME', + 'resolve_tool_manifest_policy', + 'build_collapsed_tool_stub_definition', + 'collapsed_tool_names', + ], }, { path: 'src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs', diff --git a/src/crates/agent-tools/src/framework.rs b/src/crates/agent-tools/src/framework.rs index 806ec10997..27d88400fb 100644 --- a/src/crates/agent-tools/src/framework.rs +++ b/src/crates/agent-tools/src/framework.rs @@ -31,6 +31,149 @@ pub struct DynamicToolInfo { pub mcp: Option, } +pub const GET_TOOL_SPEC_TOOL_NAME: &str = "GetToolSpec"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ToolExposure { + Expanded, + Collapsed, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolManifestDefinition { + pub name: String, + pub description: String, + pub parameters: Value, +} + +impl ToolManifestDefinition { + pub fn new(name: impl Into, description: impl Into, parameters: Value) -> Self { + Self { + name: name.into(), + description: description.into(), + parameters, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolManifestPolicyTool { + pub name: String, + pub default_exposure: ToolExposure, + pub available: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolManifestPolicyResolution { + pub allowed_tool_names: Vec, + pub expanded_tool_names: Vec, + pub collapsed_tool_names: Vec, +} + +pub fn resolve_tool_manifest_policy( + tool_snapshot: &[ToolManifestPolicyTool], + allowed_tools: &[String], + exposure_overrides: &IndexMap, + get_tool_spec_tool_name: &str, +) -> ToolManifestPolicyResolution { + let allowed_set = allowed_tools + .iter() + .map(String::as_str) + .collect::>(); + let mut allowed_tool_names = allowed_tools.to_vec(); + let mut expanded_tool_names = Vec::new(); + let mut collapsed_tool_names = Vec::new(); + + for tool in tool_snapshot { + if !tool.available || !allowed_set.contains(tool.name.as_str()) { + continue; + } + + let exposure = exposure_overrides + .get(&tool.name) + .copied() + .unwrap_or(tool.default_exposure); + match exposure { + ToolExposure::Expanded => expanded_tool_names.push(tool.name.clone()), + ToolExposure::Collapsed => collapsed_tool_names.push(tool.name.clone()), + } + } + + if !collapsed_tool_names.is_empty() { + if !allowed_tool_names + .iter() + .any(|name| name == get_tool_spec_tool_name) + { + allowed_tool_names.push(get_tool_spec_tool_name.to_string()); + } + if tool_snapshot + .iter() + .any(|tool| tool.name == get_tool_spec_tool_name) + { + expanded_tool_names.push(get_tool_spec_tool_name.to_string()); + } + } + + ToolManifestPolicyResolution { + allowed_tool_names, + expanded_tool_names, + collapsed_tool_names, + } +} + +pub fn build_collapsed_tool_stub_definition( + tool_name: &str, + short_description: &str, +) -> ToolManifestDefinition { + ToolManifestDefinition::new( + tool_name, + format!( + "{} [This tool is collapsed. Do not call `{}` directly yet. First call `GetToolSpec` with {{\"tool_name\":\"{}\"}} to load its full description and input schema, then retry `{}` using the returned schema.]", + short_description, tool_name, tool_name, tool_name + ), + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "tool_name": { + "type": "string", + "description": format!( + "Do not supply {} arguments here while the tool is collapsed. Use GetToolSpec with {{\"tool_name\":\"{}\"}} first.", + tool_name, + tool_name + ) + } + } + }), + ) +} + +pub fn tool_manifest_sort_rank(tool_name: &str) -> usize { + match tool_name { + "Task" => 1, + "Bash" => 2, + "TerminalControl" => 3, + "Glob" => 4, + "Grep" => 5, + "Read" => 6, + "Edit" => 7, + "Write" => 8, + "Delete" => 9, + "WebFetch" => 10, + "WebSearch" => 11, + "TodoWrite" => 12, + "Skill" => 13, + "Log" => 14, + GET_TOOL_SPEC_TOOL_NAME => 15, + "ControlHub" => 16, + _ => 100, + } +} + +pub fn sort_tool_manifest_definitions(tool_definitions: &mut [ToolManifestDefinition]) { + tool_definitions.sort_by_key(|tool| tool_manifest_sort_rank(&tool.name)); +} + #[async_trait] pub trait ToolRegistryItem: Send + Sync { fn name(&self) -> &str; diff --git a/src/crates/agent-tools/src/lib.rs b/src/crates/agent-tools/src/lib.rs index 4e2f358da0..da071a74a6 100644 --- a/src/crates/agent-tools/src/lib.rs +++ b/src/crates/agent-tools/src/lib.rs @@ -11,8 +11,11 @@ pub use bitfun_runtime_ports::{ DynamicToolDescriptor, DynamicToolProvider, PortError, PortErrorKind, PortResult, ToolDecorator, }; pub use framework::{ - DynamicMcpToolInfo, DynamicToolInfo, ToolPathBackend, ToolPathOperation, ToolPathPolicy, - ToolPathResolution, ToolRef, ToolRegistry, ToolRegistryItem, ToolRenderOptions, - ToolRestrictionError, ToolResult, ToolRuntimeRestrictions, ValidationResult, + DynamicMcpToolInfo, DynamicToolInfo, GET_TOOL_SPEC_TOOL_NAME, ToolExposure, + ToolManifestDefinition, ToolManifestPolicyResolution, ToolManifestPolicyTool, ToolPathBackend, + ToolPathOperation, ToolPathPolicy, ToolPathResolution, ToolRef, ToolRegistry, ToolRegistryItem, + ToolRenderOptions, ToolRestrictionError, ToolResult, ToolRuntimeRestrictions, ValidationResult, + build_collapsed_tool_stub_definition, resolve_tool_manifest_policy, + sort_tool_manifest_definitions, tool_manifest_sort_rank, }; pub use input_validator::InputValidator; diff --git a/src/crates/agent-tools/tests/tool_contracts.rs b/src/crates/agent-tools/tests/tool_contracts.rs index 1cb2de965b..a48b91cb89 100644 --- a/src/crates/agent-tools/tests/tool_contracts.rs +++ b/src/crates/agent-tools/tests/tool_contracts.rs @@ -1,6 +1,9 @@ use bitfun_agent_tools::{ - DynamicMcpToolInfo, DynamicToolInfo, InputValidator, ToolImageAttachment, ToolPathBackend, + DynamicMcpToolInfo, DynamicToolInfo, GET_TOOL_SPEC_TOOL_NAME, InputValidator, ToolExposure, + ToolImageAttachment, ToolManifestDefinition, ToolManifestPolicyTool, ToolPathBackend, ToolPathResolution, ToolRenderOptions, ToolResult, ToolRuntimeRestrictions, ValidationResult, + build_collapsed_tool_stub_definition, resolve_tool_manifest_policy, + sort_tool_manifest_definitions, }; use bitfun_agent_tools::{ DynamicToolDescriptor, DynamicToolProvider, PortResult, ToolDecorator, ToolRegistry, @@ -224,6 +227,181 @@ fn dynamic_tool_provider_contract_is_available_from_agent_tools_boundary() { assert_decorator_contract::(); } +#[test] +fn tool_exposure_contract_keeps_lightweight_wire_shape() { + let collapsed = ToolExposure::Collapsed; + let value = serde_json::to_value(collapsed).expect("serialize exposure"); + + assert_eq!(value, json!("Collapsed")); + assert_eq!( + serde_json::from_value::(value).expect("deserialize exposure"), + ToolExposure::Collapsed + ); +} + +#[test] +fn tool_manifest_definition_keeps_lightweight_wire_shape() { + let definition = ToolManifestDefinition::new( + "Read", + "Read a file", + json!({ + "type": "object", + "properties": { + "file_path": { "type": "string" } + }, + "required": ["file_path"] + }), + ); + + let value = serde_json::to_value(&definition).expect("serialize definition"); + + assert_eq!(value["name"], json!("Read")); + assert_eq!(value["description"], json!("Read a file")); + assert_eq!(value["parameters"]["required"], json!(["file_path"])); + assert_eq!( + serde_json::from_value::(value).expect("deserialize definition"), + definition + ); +} + +#[test] +fn tool_manifest_policy_keeps_get_tool_spec_insertion_and_registry_order() { + let tools = vec![ + ToolManifestPolicyTool { + name: "Read".to_string(), + default_exposure: ToolExposure::Expanded, + available: true, + }, + ToolManifestPolicyTool { + name: "WebSearch".to_string(), + default_exposure: ToolExposure::Collapsed, + available: true, + }, + ToolManifestPolicyTool { + name: "WebFetch".to_string(), + default_exposure: ToolExposure::Collapsed, + available: true, + }, + ToolManifestPolicyTool { + name: GET_TOOL_SPEC_TOOL_NAME.to_string(), + default_exposure: ToolExposure::Expanded, + available: true, + }, + ToolManifestPolicyTool { + name: "HiddenUnavailable".to_string(), + default_exposure: ToolExposure::Expanded, + available: false, + }, + ]; + let allowed_tools = vec![ + "WebFetch".to_string(), + "Read".to_string(), + "WebSearch".to_string(), + "HiddenUnavailable".to_string(), + ]; + let overrides = Default::default(); + + let policy = + resolve_tool_manifest_policy(&tools, &allowed_tools, &overrides, GET_TOOL_SPEC_TOOL_NAME); + + assert_eq!( + policy.allowed_tool_names, + vec![ + "WebFetch", + "Read", + "WebSearch", + "HiddenUnavailable", + GET_TOOL_SPEC_TOOL_NAME, + ] + ); + assert_eq!( + policy.expanded_tool_names, + vec!["Read", GET_TOOL_SPEC_TOOL_NAME] + ); + assert_eq!(policy.collapsed_tool_names, vec!["WebSearch", "WebFetch"]); +} + +#[test] +fn tool_manifest_policy_preserves_explicit_get_tool_spec_duplicate_runtime_contract() { + let tools = vec![ + ToolManifestPolicyTool { + name: GET_TOOL_SPEC_TOOL_NAME.to_string(), + default_exposure: ToolExposure::Expanded, + available: true, + }, + ToolManifestPolicyTool { + name: "WebFetch".to_string(), + default_exposure: ToolExposure::Collapsed, + available: true, + }, + ]; + let allowed_tools = vec![GET_TOOL_SPEC_TOOL_NAME.to_string(), "WebFetch".to_string()]; + let overrides = Default::default(); + + let policy = + resolve_tool_manifest_policy(&tools, &allowed_tools, &overrides, GET_TOOL_SPEC_TOOL_NAME); + + assert_eq!( + policy.allowed_tool_names, + vec![GET_TOOL_SPEC_TOOL_NAME, "WebFetch"] + ); + assert_eq!( + policy.expanded_tool_names, + vec![GET_TOOL_SPEC_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME], + "core currently appends the runtime GetToolSpec entry whenever collapsed tools exist" + ); + assert_eq!(policy.collapsed_tool_names, vec!["WebFetch"]); +} + +#[test] +fn collapsed_tool_stub_definition_preserves_prompt_visible_guardrail() { + let stub = build_collapsed_tool_stub_definition( + "WebFetch", + "Fetch a URL and return readable content.", + ); + + assert_eq!(stub.name, "WebFetch"); + assert!(stub.description.contains("Fetch a URL")); + assert!( + stub.description + .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}") + ); + assert_eq!( + stub.parameters, + json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "tool_name": { + "type": "string", + "description": "Do not supply WebFetch arguments here while the tool is collapsed. Use GetToolSpec with {\"tool_name\":\"WebFetch\"} first." + } + } + }) + ); +} + +#[test] +fn tool_manifest_sorting_preserves_prompt_visible_order() { + let mut definitions = vec![ + ToolManifestDefinition::new("ControlHub", "control", json!({ "type": "object" })), + ToolManifestDefinition::new("Read", "read", json!({ "type": "object" })), + ToolManifestDefinition::new("ExternalTool", "external", json!({ "type": "object" })), + ToolManifestDefinition::new("GetToolSpec", "spec", json!({ "type": "object" })), + ToolManifestDefinition::new("Task", "task", json!({ "type": "object" })), + ]; + + sort_tool_manifest_definitions(&mut definitions); + + assert_eq!( + definitions + .iter() + .map(|definition| definition.name.as_str()) + .collect::>(), + vec!["Task", "Read", "GetToolSpec", "ControlHub", "ExternalTool"] + ); +} + struct RegistryMarkerTool { name: String, provider_id: Option, diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index ceadaf8208..a3babbdcb6 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -31,10 +31,11 @@ SessionManager → Session → DialogTurn → ModelRound - During core decomposition, `bitfun-core` is a compatibility facade and full product runtime assembly point. New modules should prefer the extracted owner crate listed in `docs/architecture/core-decomposition.md`. -- For tools, keep lightweight contracts and generic registry/provider container - logic in `bitfun-agent-tools`. Core tool runtime should assemble product - tools, adapt `dyn Tool`, apply snapshot decoration, and own tool exposure / - manifest resolution plus on-demand spec discovery (`GetToolSpec`) for now. +- For tools, keep lightweight contracts, pure manifest/exposure contracts, and + generic registry/provider container logic in `bitfun-agent-tools`. Core tool + runtime should assemble product tools, adapt `dyn Tool`, apply snapshot + decoration, and own runtime manifest assembly / context filtering plus + on-demand spec discovery execution (`GetToolSpec`) for now. - Keep `ToolUseContext` and concrete tool implementations in core unless a reviewed port/provider plan and equivalence tests exist. - Any tool migration must preserve expanded/collapsed exposure, prompt-visible diff --git a/src/crates/core/src/agentic/tools/framework.rs b/src/crates/core/src/agentic/tools/framework.rs index fe36b36d13..3f45227d49 100644 --- a/src/crates/core/src/agentic/tools/framework.rs +++ b/src/crates/core/src/agentic/tools/framework.rs @@ -1,26 +1,26 @@ //! Tool framework - Tool interface definition and execution context +use crate::agentic::WorkspaceBinding; use crate::agentic::coordination::get_global_coordinator; use crate::agentic::session::EvidenceLedgerCheckpoint; use crate::agentic::tools::post_call_hooks; use crate::agentic::tools::restrictions::{ - is_local_path_within_root, is_remote_posix_path_within_root, ToolPathOperation, - ToolRuntimeRestrictions, + ToolPathOperation, ToolRuntimeRestrictions, is_local_path_within_root, + is_remote_posix_path_within_root, }; use crate::agentic::tools::workspace_paths::{ build_bitfun_runtime_uri, is_bitfun_runtime_uri, normalize_runtime_relative_path, parse_bitfun_runtime_uri, }; use crate::agentic::workspace::WorkspaceServices; -use crate::agentic::WorkspaceBinding; use crate::infrastructure::get_path_manager_arc; use crate::service::git::{GitDiffParams, GitService}; use crate::service::remote_ssh::workspace_state::remote_workspace_runtime_root; -use crate::service::{get_workspace_runtime_service_arc, WorkspaceRuntimeContext}; +use crate::service::{WorkspaceRuntimeContext, get_workspace_runtime_service_arc}; use crate::util::errors::BitFunResult; use async_trait::async_trait; pub use bitfun_agent_tools::{ - DynamicMcpToolInfo, DynamicToolInfo, ToolPathBackend, ToolPathResolution, ToolRenderOptions, - ToolResult, ValidationResult, + DynamicMcpToolInfo, DynamicToolInfo, ToolExposure, ToolPathBackend, ToolPathResolution, + ToolRenderOptions, ToolResult, ValidationResult, }; use log::warn; use serde_json::Value; @@ -29,11 +29,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio_util::sync::CancellationToken; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToolExposure { - Expanded, - Collapsed, -} /// Tool use context #[derive(Debug, Clone)] pub struct ToolUseContext { @@ -442,8 +437,8 @@ impl ToolUseContext { #[cfg(test)] mod path_resolution_tests { use super::ToolUseContext; - use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::WorkspaceBinding; + use crate::agentic::tools::ToolRuntimeRestrictions; use std::collections::HashMap; use std::path::PathBuf; @@ -715,7 +710,7 @@ mod shared_context_tests { use crate::agentic::tools::ToolRuntimeRestrictions; use crate::util::errors::BitFunResult; use async_trait::async_trait; - use serde_json::{json, Value}; + use serde_json::{Value, json}; use std::collections::HashMap; struct MeasurementReadTool; diff --git a/src/crates/core/src/agentic/tools/manifest_resolver.rs b/src/crates/core/src/agentic/tools/manifest_resolver.rs index 6c73752709..d006289658 100644 --- a/src/crates/core/src/agentic/tools/manifest_resolver.rs +++ b/src/crates/core/src/agentic/tools/manifest_resolver.rs @@ -1,9 +1,12 @@ use crate::agentic::agents::AgentToolPolicyOverrides; -use crate::agentic::tools::framework::{Tool, ToolExposure, ToolUseContext}; -use crate::agentic::tools::registry::{get_global_tool_registry, GET_TOOL_SPEC_TOOL_NAME}; +use crate::agentic::tools::framework::{Tool, ToolUseContext}; +use crate::agentic::tools::registry::{GET_TOOL_SPEC_TOOL_NAME, get_global_tool_registry}; use crate::util::types::ToolDefinition; -use serde_json::json; -use std::collections::{HashMap, HashSet}; +use bitfun_agent_tools::{ + ToolManifestDefinition, ToolManifestPolicyTool, build_collapsed_tool_stub_definition, + resolve_tool_manifest_policy, sort_tool_manifest_definitions, +}; +use std::collections::HashSet; use std::sync::Arc; type ToolRef = Arc; @@ -29,55 +32,54 @@ fn build_visible_tools( exposure_overrides: &AgentToolPolicyOverrides, available_tool_names: &HashSet, ) -> ResolvedVisibleTools { - let allowed_set: HashSet<&str> = allowed_tools.iter().map(String::as_str).collect(); - let mut allowed_tool_names = allowed_tools.to_vec(); - let mut expanded_tools = Vec::new(); - let mut collapsed_tool_names = Vec::new(); - let mut collapsed_tools = Vec::new(); - - for tool in tool_snapshot { - let tool_name = tool.name().to_string(); - if !available_tool_names.contains(&tool_name) || !allowed_set.contains(tool_name.as_str()) { - continue; - } - - let exposure = exposure_overrides - .get(&tool_name) - .copied() - .unwrap_or_else(|| tool.default_exposure()); - match exposure { - ToolExposure::Collapsed => { - collapsed_tool_names.push(tool_name); - collapsed_tools.push(tool.clone()); + let policy_tools = tool_snapshot + .iter() + .map(|tool| { + let name = tool.name().to_string(); + ToolManifestPolicyTool { + available: available_tool_names.contains(&name), + default_exposure: tool.default_exposure(), + name, } - ToolExposure::Expanded => expanded_tools.push(tool.clone()), - } - } - - if !collapsed_tool_names.is_empty() { - if !allowed_tool_names - .iter() - .any(|name| name == GET_TOOL_SPEC_TOOL_NAME) - { - allowed_tool_names.push(GET_TOOL_SPEC_TOOL_NAME.to_string()); - } - if let Some(tool) = tool_snapshot - .iter() - .find(|tool| tool.name() == GET_TOOL_SPEC_TOOL_NAME) - .cloned() - { - expanded_tools.push(tool); - } - } + }) + .collect::>(); + let policy = resolve_tool_manifest_policy( + &policy_tools, + allowed_tools, + exposure_overrides, + GET_TOOL_SPEC_TOOL_NAME, + ); + let expanded_tools = tools_by_name(tool_snapshot, &policy.expanded_tool_names); + let collapsed_tools = tools_by_name(tool_snapshot, &policy.collapsed_tool_names); ResolvedVisibleTools { - allowed_tool_names, + allowed_tool_names: policy.allowed_tool_names, expanded_tools, - collapsed_tool_names, + collapsed_tool_names: policy.collapsed_tool_names, collapsed_tools, } } +fn tools_by_name(tool_snapshot: &[ToolRef], tool_names: &[String]) -> Vec { + tool_names + .iter() + .filter_map(|name| { + tool_snapshot + .iter() + .find(|tool| tool.name() == name) + .cloned() + }) + .collect() +} + +fn to_core_tool_definition(definition: ToolManifestDefinition) -> ToolDefinition { + ToolDefinition { + name: definition.name, + description: definition.description, + parameters: definition.parameters, + } +} + pub async fn resolve_visible_tools( allowed_tools: &[String], exposure_overrides: &AgentToolPolicyOverrides, @@ -123,68 +125,28 @@ pub async fn resolve_tool_manifest( .input_schema_for_model_with_context(Some(context)) .await; - tool_definitions.push(ToolDefinition { - name: tool.name().to_string(), + tool_definitions.push(ToolManifestDefinition::new( + tool.name().to_string(), description, parameters, - }); + )); } for tool in &visible_tools.collapsed_tools { - let description = format!( - "{} [This tool is collapsed. Do not call `{}` directly yet. First call `GetToolSpec` with {{\"tool_name\":\"{}\"}} to load its full description and input schema, then retry `{}` using the returned schema.]", - tool.short_description(), - tool.name(), + tool_definitions.push(build_collapsed_tool_stub_definition( tool.name(), - tool.name() - ); - - tool_definitions.push(ToolDefinition { - name: tool.name().to_string(), - description, - parameters: json!({ - "type": "object", - "additionalProperties": false, - "properties": { - "tool_name": { - "type": "string", - "description": format!( - "Do not supply {} arguments here while the tool is collapsed. Use GetToolSpec with {{\"tool_name\":\"{}\"}} first.", - tool.name(), - tool.name() - ) - } - } - }), - }); + &tool.short_description(), + )); } - let tool_ordering: HashMap = [ - ("Task", 1), - ("Bash", 2), - ("TerminalControl", 3), - ("Glob", 4), - ("Grep", 5), - ("Read", 6), - ("Edit", 7), - ("Write", 8), - ("Delete", 9), - ("WebFetch", 10), - ("WebSearch", 11), - ("TodoWrite", 12), - ("Skill", 13), - ("Log", 14), - ("GetToolSpec", 15), - ("ControlHub", 16), - ] - .into_iter() - .map(|(k, v)| (k.to_string(), v)) - .collect(); - tool_definitions.sort_by_key(|tool| tool_ordering.get(&tool.name).unwrap_or(&100)); + sort_tool_manifest_definitions(&mut tool_definitions); ResolvedToolManifest { allowed_tool_names: visible_tools.allowed_tool_names, - tool_definitions, + tool_definitions: tool_definitions + .into_iter() + .map(to_core_tool_definition) + .collect(), collapsed_tool_names: visible_tools.collapsed_tool_names, } } @@ -193,9 +155,9 @@ pub async fn resolve_tool_manifest( mod tests { use super::resolve_tool_manifest; use crate::agentic::agents::AgentToolPolicyOverrides; + use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::tools::framework::{ToolExposure, ToolUseContext}; use crate::agentic::tools::registry::GET_TOOL_SPEC_TOOL_NAME; - use crate::agentic::tools::ToolRuntimeRestrictions; use serde_json::json; use std::collections::HashMap; @@ -228,10 +190,12 @@ mod tests { assert!(manifest.collapsed_tool_names.is_empty()); assert_eq!(manifest.allowed_tool_names, allowed_tools); - assert!(!manifest - .tool_definitions - .iter() - .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); + assert!( + !manifest + .tool_definitions + .iter() + .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME) + ); } #[tokio::test] @@ -246,21 +210,29 @@ mod tests { .await; assert_eq!(manifest.collapsed_tool_names, vec!["WebFetch".to_string()]); - assert!(manifest - .allowed_tool_names - .contains(&GET_TOOL_SPEC_TOOL_NAME.to_string())); - assert!(manifest - .tool_definitions - .iter() - .any(|tool| tool.name == "Read")); - assert!(manifest - .tool_definitions - .iter() - .any(|tool| tool.name == "WebFetch")); - assert!(manifest - .tool_definitions - .iter() - .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); + assert!( + manifest + .allowed_tool_names + .contains(&GET_TOOL_SPEC_TOOL_NAME.to_string()) + ); + assert!( + manifest + .tool_definitions + .iter() + .any(|tool| tool.name == "Read") + ); + assert!( + manifest + .tool_definitions + .iter() + .any(|tool| tool.name == "WebFetch") + ); + assert!( + manifest + .tool_definitions + .iter() + .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME) + ); let stub = manifest .tool_definitions .iter() @@ -269,10 +241,12 @@ mod tests { assert!(stub.description.contains("First call `GetToolSpec`")); assert_eq!(stub.parameters["type"], json!("object")); assert_eq!(stub.parameters["additionalProperties"], json!(false)); - assert!(stub.parameters["properties"]["tool_name"]["description"] - .as_str() - .unwrap() - .contains("{\"tool_name\":\"WebFetch\"}")); + assert!( + stub.parameters["properties"]["tool_name"]["description"] + .as_str() + .unwrap() + .contains("{\"tool_name\":\"WebFetch\"}") + ); } #[tokio::test] @@ -322,9 +296,11 @@ mod tests { .iter() .find(|tool| tool.name == "WebFetch") .expect("collapsed WebFetch stub"); - assert!(web_fetch - .description - .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}")); + assert!( + web_fetch + .description + .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}") + ); assert_eq!( web_fetch.parameters, json!({ @@ -349,13 +325,17 @@ mod tests { let manifest = resolve_tool_manifest(&allowed_tools, &overrides, &tool_context()).await; assert!(manifest.collapsed_tool_names.is_empty()); - assert!(manifest - .tool_definitions - .iter() - .any(|tool| tool.name == "WebFetch")); - assert!(!manifest - .tool_definitions - .iter() - .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); + assert!( + manifest + .tool_definitions + .iter() + .any(|tool| tool.name == "WebFetch") + ); + assert!( + !manifest + .tool_definitions + .iter() + .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME) + ); } } diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 2782986687..314252fb30 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -14,7 +14,7 @@ use std::sync::Arc; type ToolRef = Arc; type ToolDecoratorRef = Arc>; -pub const GET_TOOL_SPEC_TOOL_NAME: &str = "GetToolSpec"; +pub use bitfun_agent_tools::GET_TOOL_SPEC_TOOL_NAME; #[derive(Debug, Clone)] struct SnapshotToolDecorator; @@ -279,16 +279,16 @@ impl ToolRegistryItem for dyn Tool { #[cfg(test)] mod tests { - use super::create_tool_registry; use super::ToolRef; use super::ToolRegistry; + use super::create_tool_registry; use crate::agentic::tools::framework::{ DynamicMcpToolInfo, DynamicToolInfo, Tool, ToolResult, ToolUseContext, ValidationResult, }; use async_trait::async_trait; use bitfun_agent_tools::DynamicToolProvider; - use serde_json::json; use serde_json::Value; + use serde_json::json; use std::sync::Arc; struct DynamicMetadataTool { From b0870366bac2bd97a9625effb78031a34a4a6af2 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 18 May 2026 18:11:04 +0800 Subject: [PATCH 28/76] fix(ai-adapters): send flat tool schema for OpenAI Responses API --- .../src/providers/openai/codex_chatgpt.rs | 21 +------ .../src/providers/openai/common.rs | 21 ++++++- .../src/providers/openai/responses.rs | 63 ++++++++++++++++++- 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs b/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs index 5e8a87a3db..6d5e5119d4 100644 --- a/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs +++ b/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs @@ -36,25 +36,6 @@ pub(crate) fn is_codex_chatgpt_endpoint(request_url: &str) -> bool { request_url.contains("chatgpt.com/backend-api/codex") } -/// Convert a `ToolDefinition` list to the *flat* Responses tool schema that -/// Codex backend (and OpenAI's public Responses API) expects: -/// `{ "type": "function", "name": ..., "description": ..., "parameters": ..., "strict": false }`. -fn convert_tools_flat(tools: Option>) -> Option> { - tools.map(|defs| { - defs.into_iter() - .map(|tool| { - json!({ - "type": "function", - "name": tool.name, - "description": tool.description, - "parameters": tool.parameters, - "strict": false, - }) - }) - .collect() - }) -} - fn attach_tools(request_body: &mut Value, tools: Option>) { if let Some(tools) = tools { let names: Vec = tools @@ -173,7 +154,7 @@ pub(crate) async fn send_stream( let (instructions, response_input) = OpenAIMessageConverter::convert_messages_to_responses_input(messages); - let tools_flat = convert_tools_flat(tools); + let tools_flat = common::convert_tools_flat(tools); let request_body = build_request_body(client, instructions, response_input, tools_flat, extra_body); let idle_timeout = client.stream_options.idle_timeout; diff --git a/src/crates/ai-adapters/src/providers/openai/common.rs b/src/crates/ai-adapters/src/providers/openai/common.rs index 8b4674ca4e..6301bf57b7 100644 --- a/src/crates/ai-adapters/src/providers/openai/common.rs +++ b/src/crates/ai-adapters/src/providers/openai/common.rs @@ -2,7 +2,7 @@ use crate::client::quirks::apply_openai_compatible_reasoning_fields; use crate::client::utils::{dedupe_remote_models, normalize_base_url_for_discovery}; use crate::client::AIClient; use crate::providers::shared; -use crate::types::RemoteModelInfo; +use crate::types::{RemoteModelInfo, ToolDefinition}; use anyhow::Result; use log::warn; use reqwest::RequestBuilder; @@ -335,6 +335,7 @@ pub(crate) fn extract_tool_name(tool: &serde_json::Value) -> String { tool.get("function") .and_then(|function| function.get("name")) .and_then(|name| name.as_str()) + .or_else(|| tool.get("name").and_then(|name| name.as_str())) .unwrap_or("unknown") .to_string() } @@ -358,3 +359,21 @@ pub(crate) fn attach_tools( } } } + +pub(crate) fn convert_tools_flat( + tools: Option>, +) -> Option> { + tools.map(|defs| { + defs.into_iter() + .map(|tool| { + serde_json::json!({ + "type": "function", + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + "strict": false, + }) + }) + .collect() + }) +} diff --git a/src/crates/ai-adapters/src/providers/openai/responses.rs b/src/crates/ai-adapters/src/providers/openai/responses.rs index 0b39414341..56073b4ec5 100644 --- a/src/crates/ai-adapters/src/providers/openai/responses.rs +++ b/src/crates/ai-adapters/src/providers/openai/responses.rs @@ -111,7 +111,7 @@ pub(crate) async fn send_stream( let (instructions, response_input) = OpenAIMessageConverter::convert_messages_to_responses_input(messages); - let openai_tools = OpenAIMessageConverter::convert_tools(tools); + let openai_tools = common::convert_tools_flat(tools); let request_body = build_request_body( client, instructions, @@ -133,3 +133,64 @@ pub(crate) async fn send_stream( ) .await } + +#[cfg(test)] +mod tests { + use super::build_request_body; + use crate::types::{ReasoningMode, ToolDefinition}; + use crate::{client::AIClient, types::AIConfig}; + use serde_json::json; + + fn test_client() -> AIClient { + AIClient::new(AIConfig { + name: "test".to_string(), + base_url: "https://api.openai.com/v1".to_string(), + request_url: "https://api.openai.com/v1/responses".to_string(), + api_key: "test-key".to_string(), + model: "gpt-5.4".to_string(), + format: "responses".to_string(), + context_window: 128_000, + max_tokens: None, + temperature: None, + top_p: None, + reasoning_mode: ReasoningMode::Default, + inline_think_in_text: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: None, + thinking_budget_tokens: None, + custom_request_body: None, + custom_request_body_mode: None, + }) + } + + #[test] + fn attaches_flat_tool_schema_for_responses_api() { + let client = test_client(); + let request_body = build_request_body( + &client, + None, + vec![json!({ + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "hello" }] + })], + crate::providers::openai::common::convert_tools_flat(Some(vec![ToolDefinition { + name: "get_weather".to_string(), + description: "Get weather".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "city": { "type": "string" } + } + }), + }])), + None, + ); + + assert_eq!(request_body["tools"][0]["name"], json!("get_weather")); + assert_eq!(request_body["tools"][0]["type"], json!("function")); + assert!(request_body["tools"][0].get("function").is_none()); + } +} From ad907dd56721ec6f7490a333f6acbd3fe1069737 Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 18 May 2026 17:12:24 +0800 Subject: [PATCH 29/76] feat(miniapp): refine built-in PR review workflow --- .../builtin/assets/pr-review/style.css | 547 ++++- .../miniapp/builtin/assets/pr-review/ui.js | 1776 +++++++++++++++-- src/crates/core/src/miniapp/builtin/mod.rs | 213 +- 3 files changed, 2320 insertions(+), 216 deletions(-) diff --git a/src/crates/core/src/miniapp/builtin/assets/pr-review/style.css b/src/crates/core/src/miniapp/builtin/assets/pr-review/style.css index 32d66f3cb7..88111bbbf3 100644 --- a/src/crates/core/src/miniapp/builtin/assets/pr-review/style.css +++ b/src/crates/core/src/miniapp/builtin/assets/pr-review/style.css @@ -84,7 +84,11 @@ button { cursor: pointer; } -button:disabled { +button:disabled, +.pr-btn[aria-disabled="true"], +.pr-icon-btn[aria-disabled="true"], +.pr-text-btn[aria-disabled="true"], +.pr-sync-tile[aria-disabled="true"] { cursor: not-allowed; opacity: 0.55; } @@ -104,6 +108,15 @@ button:disabled { overflow: hidden; } +.pr-shell--with-status { + grid-template-rows: auto auto minmax(0, 1fr); + row-gap: 8px; +} + +.pr-shell--with-status .pr-main-layout { + grid-row: 3; +} + .pr-command-bar { display: grid; grid-template-columns: minmax(180px, 0.45fr) minmax(520px, 1.55fr) minmax(300px, 0.75fr); @@ -119,6 +132,22 @@ button:disabled { box-shadow: 0 10px 26px var(--pr-shadow); } +.pr-command-bar--simple { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: "brand actions"; + align-items: center; + padding: 10px 12px; +} + +.pr-command-actions { + grid-area: actions; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + .pr-brand { grid-area: brand; display: flex; @@ -129,6 +158,7 @@ button:disabled { } .pr-brand-mark { + flex: 0 0 30px; width: 30px; height: 30px; display: grid; @@ -217,6 +247,17 @@ button:disabled { 100% { background-position: 0% 50%; } } +@keyframes pr-pulse-dot { + 0%, 100% { + opacity: 1; + box-shadow: 0 0 0 5px color-mix(in srgb, var(--pr-info) 14%, transparent); + } + 50% { + opacity: 0.58; + box-shadow: 0 0 0 8px color-mix(in srgb, var(--pr-info) 6%, transparent); + } +} + .pr-repo-first::before { opacity: 0.15; } @@ -408,17 +449,59 @@ button:disabled { border-color: color-mix(in srgb, var(--pr-accent) 36%, var(--pr-line)); } +.pr-btn[aria-disabled="true"]:hover, +.pr-icon-btn[aria-disabled="true"]:hover { + background: var(--pr-surface-2); + border-color: var(--pr-line); +} + .pr-btn:active, .pr-icon-btn:active { transform: translateY(1px); } +.pr-btn[aria-disabled="true"]:active, +.pr-icon-btn[aria-disabled="true"]:active { + transform: none; +} + .pr-btn--primary { border-color: color-mix(in srgb, var(--pr-accent) 48%, var(--pr-line)); background: color-mix(in srgb, var(--pr-accent) 17%, var(--pr-surface-2)); color: var(--pr-text); } +.pr-btn--review { + border-color: color-mix(in srgb, var(--pr-accent) 62%, var(--pr-line)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--pr-accent) 42%, var(--pr-surface-2)), color-mix(in srgb, var(--pr-info) 18%, var(--pr-surface-2))); + color: var(--pr-text); + box-shadow: 0 8px 20px color-mix(in srgb, var(--pr-accent) 15%, transparent); +} + +.pr-btn--review:hover { + border-color: color-mix(in srgb, var(--pr-accent) 76%, var(--pr-line)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--pr-accent) 50%, var(--pr-surface-2)), color-mix(in srgb, var(--pr-info) 23%, var(--pr-surface-2))); +} + +.pr-btn--secondary { + color: var(--pr-text-2); + background: color-mix(in srgb, var(--pr-surface-2) 84%, transparent); +} + +.pr-btn--merge { + border-color: color-mix(in srgb, var(--pr-green) 38%, var(--pr-line)); + background: color-mix(in srgb, var(--pr-green) 13%, var(--pr-surface-2)); + color: color-mix(in srgb, var(--pr-green) 72%, var(--pr-text)); +} + +.pr-btn--danger { + border-color: color-mix(in srgb, var(--pr-red) 42%, var(--pr-line)); + background: color-mix(in srgb, var(--pr-red) 14%, var(--pr-surface-2)); + color: color-mix(in srgb, var(--pr-red) 78%, var(--pr-text)); +} + .pr-btn--compact { min-height: 30px; padding: 5px 9px; @@ -450,6 +533,12 @@ button:disabled { background: var(--pr-surface-2); } +.pr-text-btn[aria-disabled="true"]:hover { + color: var(--pr-muted); + border-color: transparent; + background: transparent; +} + .pr-token-badge, .pr-chip, .pr-count { @@ -497,6 +586,14 @@ button:disabled { min-height: 0; } +.pr-main-layout--no-composer { + grid-template-columns: minmax(260px, 0.5fr) minmax(0, 1.5fr); +} + +.pr-main-layout--reviewing { + grid-template-columns: minmax(300px, 0.78fr) minmax(460px, 1.35fr) minmax(330px, 0.92fr); +} + .pr-sidebar, .pr-review-workspace, .pr-composer { @@ -581,6 +678,10 @@ button:disabled { padding: 12px 12px 0; } +.pr-sync-panel--settings { + padding: 0; +} + .pr-sync-tile { display: grid; gap: 4px; @@ -609,6 +710,17 @@ button:disabled { background: linear-gradient(135deg, color-mix(in srgb, var(--pr-accent) 12%, transparent), color-mix(in srgb, var(--pr-info) 7%, transparent)); } +.pr-sync-tile[aria-disabled="true"]:hover { + border-color: var(--pr-line-soft); + background: color-mix(in srgb, var(--pr-surface) 88%, var(--pr-bg)); +} + +.pr-sync-tile.is-active[aria-disabled="true"], +.pr-sync-tile.is-active[aria-disabled="true"]:hover { + border-color: color-mix(in srgb, var(--pr-accent) 56%, var(--pr-line)); + background: linear-gradient(135deg, color-mix(in srgb, var(--pr-accent) 12%, transparent), color-mix(in srgb, var(--pr-info) 7%, transparent)); +} + .pr-queue-actions { align-items: center; justify-content: space-between; @@ -633,6 +745,11 @@ button:disabled { padding: 4px 7px; } +.pr-queue-actions--settings { + padding: 0; + justify-content: space-between; +} + .pr-list, .pr-source-list, .pr-review-list, @@ -657,6 +774,15 @@ button:disabled { padding: 11px; } +.pr-queue-item--compact { + gap: 6px; + padding: 10px; +} + +.pr-main-layout--reviewing .pr-queue-item--compact { + padding: 9px; +} + .pr-queue-item:hover, .pr-queue-item.is-active { border-color: color-mix(in srgb, var(--pr-accent) 56%, var(--pr-line)); @@ -673,6 +799,10 @@ button:disabled { overflow: hidden; } +.pr-queue-item--compact .pr-queue-title { + -webkit-line-clamp: 2; +} + .pr-queue-meta, .pr-queue-signals, .pr-meta-row { @@ -854,11 +984,32 @@ button:disabled { margin: 0; } +.pr-shell-status { + margin: 0; +} + +.pr-shell-status .pr-status { + display: flex; + align-items: center; + min-height: 34px; + font-size: 13px; + font-weight: 720; +} + .pr-status--error { color: var(--pr-red); border-color: color-mix(in srgb, var(--pr-red) 35%, var(--pr-line)); } +.pr-status--busy { + color: var(--pr-text); + border-color: color-mix(in srgb, var(--pr-accent) 42%, var(--pr-line)); + background: + linear-gradient(90deg, color-mix(in srgb, var(--pr-accent) 18%, var(--pr-surface)), color-mix(in srgb, var(--pr-info) 10%, var(--pr-bg))), + var(--pr-surface); + box-shadow: 0 8px 22px color-mix(in srgb, var(--pr-accent) 12%, transparent); +} + .pr-empty { display: grid; place-items: center; @@ -905,10 +1056,16 @@ button:disabled { .pr-pr-actions { align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; + flex: 0 0 auto; justify-content: flex-end; } +.pr-pr-actions .pr-btn { + min-height: 32px; + padding-inline: 9px; +} + .pr-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -946,6 +1103,52 @@ button:disabled { font-weight: 740; } +.pr-lifecycle-panel { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--pr-line-soft); + border-radius: 8px; + background: linear-gradient(135deg, color-mix(in srgb, var(--pr-surface) 92%, var(--pr-bg)), color-mix(in srgb, var(--pr-accent) 5%, var(--pr-bg))); +} + +.pr-lifecycle-panel .pr-section-head { + margin-bottom: 0; +} + +.pr-lifecycle-panel .pr-muted { + margin: 4px 0 0; +} + +.pr-lifecycle-tags { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.pr-lifecycle-tag { + min-height: 24px; + max-width: 100%; + border-color: color-mix(in srgb, var(--pr-accent) 22%, var(--pr-line-soft)); + background: color-mix(in srgb, var(--pr-bg) 74%, var(--pr-surface)); +} + +.pr-lifecycle-warnings { + display: flex; + flex-wrap: wrap; + gap: 6px; + color: var(--pr-amber); + font-size: 11px; + line-height: 1.4; +} + +.pr-lifecycle-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + .pr-section-head { display: flex; justify-content: space-between; @@ -1014,18 +1217,24 @@ button:disabled { .pr-diff-panel { min-width: 0; + max-height: min(560px, 56vh); + overflow: auto; + overscroll-behavior: contain; border: 1px solid var(--pr-line-soft); border-radius: 8px; - overflow: hidden; background: var(--pr-diff-bg); } .pr-diff-head { + position: sticky; + top: 0; + z-index: 1; display: flex; justify-content: space-between; gap: 10px; padding: 9px 10px; border-bottom: 1px solid var(--pr-line-soft); + background: var(--pr-diff-bg); } .pr-diff-head strong { @@ -1078,31 +1287,62 @@ button:disabled { } .pr-fold summary { + list-style: none; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; cursor: pointer; color: var(--pr-text-2); font-size: 13px; font-weight: 720; } -.pr-fold summary span { - margin-left: 8px; - color: var(--pr-muted); - font-size: 11px; - font-weight: 400; +.pr-fold summary::-webkit-details-marker { + display: none; } -.pr-overview-fold summary { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; +.pr-fold summary::before { + content: ""; + width: 0; + height: 0; + flex: 0 0 auto; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 5px solid var(--pr-muted); + transition: transform 140ms ease, border-left-color 140ms ease; +} + +.pr-fold[open] > summary::before { + transform: rotate(90deg); + border-left-color: var(--pr-accent); +} + +.pr-fold-title { + flex: 0 0 auto; + min-width: max-content; + color: var(--pr-text-2); + font-size: 13px; + font-weight: 720; + white-space: nowrap; } -.pr-overview-fold summary span { +.pr-fold-meta { + flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--pr-muted); + font-size: 11px; + font-weight: 400; +} + +.pr-overview-fold summary { + justify-content: flex-start; +} + +.pr-overview-fold .pr-fold-meta { margin-left: 0; } @@ -1110,6 +1350,19 @@ button:disabled { margin-top: 10px; } +.pr-files-fold summary { + justify-content: flex-start; +} + +.pr-files-fold .pr-fold-meta { + margin-left: auto; + text-align: right; +} + +.pr-files-fold .pr-files-layout { + margin-top: 10px; +} + .pr-review-row { display: grid; gap: 7px; @@ -1120,6 +1373,69 @@ button:disabled { font-size: 12px; } +.pr-ci-list { + display: grid; + gap: 7px; +} + +.pr-ci-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 9px; + align-items: center; + min-width: 0; + padding: 9px 10px; + border: 1px solid var(--pr-line-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--pr-bg) 82%, var(--pr-surface)); +} + +.pr-ci-row strong, +.pr-ci-row span { + display: block; +} + +.pr-ci-row strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--pr-text); + font-size: 12px; +} + +.pr-ci-row span { + color: var(--pr-muted); + font-size: 11px; +} + +.pr-ci-row .pr-text-btn { + white-space: nowrap; +} + +.pr-ci-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--pr-muted); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pr-muted) 10%, transparent); +} + +.pr-ci-dot.is-ok { + background: var(--pr-green); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pr-green) 12%, transparent); +} + +.pr-ci-dot.is-warn { + background: var(--pr-amber); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pr-amber) 14%, transparent); +} + +.pr-ci-dot.is-bad { + background: var(--pr-red); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pr-red) 13%, transparent); +} + .pr-review-row p { margin: 0; color: var(--pr-text-2); @@ -1128,15 +1444,33 @@ button:disabled { } .pr-manual-comment { - margin-top: 10px; display: grid; gap: 8px; - padding: 10px; + padding: 12px; border: 1px solid var(--pr-line-soft); border-radius: 8px; background: color-mix(in srgb, var(--pr-bg) 76%, var(--pr-surface)); } +.pr-manual-comment-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.pr-manual-comment .pr-textarea { + min-height: 56px; +} + +.pr-manual-comment.is-expanded .pr-textarea { + min-height: 260px; +} + +.pr-manual-comment .pr-btn { + justify-self: flex-start; +} + .pr-composer { padding-bottom: 12px; } @@ -1150,6 +1484,39 @@ button:disabled { margin: 0 12px 12px; } +.pr-language-control { + display: grid; + grid-template-columns: minmax(0, 1fr) 124px; + gap: 10px; + align-items: center; + margin: 0 12px 12px; + padding: 10px; + border: 1px solid var(--pr-line-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--pr-bg) 80%, var(--pr-surface)); +} + +.pr-language-control span { + min-width: 0; +} + +.pr-language-control strong, +.pr-language-control small { + display: block; +} + +.pr-language-control strong { + color: var(--pr-text); + font-size: 12px; +} + +.pr-language-control small { + margin-top: 3px; + color: var(--pr-muted); + font-size: 11px; + line-height: 1.35; +} + .pr-live-status { margin: 0 12px 12px; display: grid; @@ -1164,6 +1531,11 @@ button:disabled { box-shadow: 0 8px 22px color-mix(in srgb, var(--pr-accent) 12%, transparent); } +.pr-composer.is-reviewing .pr-live-status { + border-color: color-mix(in srgb, var(--pr-info) 48%, var(--pr-line)); + background: linear-gradient(135deg, color-mix(in srgb, var(--pr-info) 17%, var(--pr-surface)), color-mix(in srgb, var(--pr-accent) 11%, var(--pr-bg))); +} + .pr-live-status-dot { width: 9px; height: 9px; @@ -1172,6 +1544,10 @@ button:disabled { box-shadow: 0 0 0 5px color-mix(in srgb, var(--pr-accent) 15%, transparent); } +.pr-composer.is-reviewing .pr-live-status-dot { + animation: pr-pulse-dot 1.1s ease-in-out infinite; +} + .pr-live-status strong, .pr-live-status span { display: block; @@ -1189,6 +1565,37 @@ button:disabled { line-height: 1.45; } +.pr-reviewing-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 12px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--pr-info) 42%, var(--pr-line)); + border-left-width: 3px; + border-radius: 8px; + background: linear-gradient(135deg, color-mix(in srgb, var(--pr-info) 16%, var(--pr-surface)), color-mix(in srgb, var(--pr-accent) 9%, var(--pr-bg))); + box-shadow: 0 10px 26px color-mix(in srgb, var(--pr-info) 12%, transparent); +} + +.pr-reviewing-banner strong, +.pr-reviewing-banner span { + display: block; +} + +.pr-reviewing-banner strong { + color: var(--pr-text); + font-size: 13px; +} + +.pr-reviewing-banner span { + margin-top: 3px; + color: var(--pr-text-2); + font-size: 11px; + line-height: 1.45; +} + .pr-draft-list { padding: 0 12px; } @@ -1269,6 +1676,14 @@ button:disabled { min-width: 0; } +.pr-draft-path-row { + min-width: 0; +} + +.pr-draft-path-row .pr-file-link { + max-width: 100%; +} + .pr-file-link { display: inline-flex; align-items: center; @@ -1360,6 +1775,15 @@ button:disabled { box-shadow: 0 30px 80px rgba(0, 0, 0, 0.46); } +.pr-modal--lifecycle { + width: min(620px, calc(100vw - 40px)); +} + +.pr-settings-modal { + width: min(980px, calc(100vw - 40px)); + overflow: hidden; +} + .pr-modal-head, .pr-modal-body, .pr-modal-foot { @@ -1367,6 +1791,10 @@ button:disabled { } .pr-modal-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; border-bottom: 1px solid var(--pr-line-soft); } @@ -1375,11 +1803,79 @@ button:disabled { font-size: 16px; } +.pr-modal-head p { + margin: 4px 0 0; + color: var(--pr-muted); + font-size: 12px; + line-height: 1.45; +} + .pr-modal-body p { color: var(--pr-text-2); line-height: 1.55; } +.pr-settings-body { + max-height: calc(100vh - 170px); + overflow: auto; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +.pr-settings-body .pr-card--sources, +.pr-settings-section, +.pr-settings-body .pr-url-card, +.pr-settings-body .pr-access { + min-width: 0; + grid-area: auto; + border: 1px solid var(--pr-line-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--pr-bg) 80%, var(--pr-surface)); +} + +.pr-settings-section { + display: grid; + gap: 10px; + padding: 11px; +} + +.pr-confirm-summary { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin: 12px 0; +} + +.pr-confirm-summary div { + min-width: 0; + display: grid; + gap: 4px; + padding: 9px; + border: 1px solid var(--pr-line-soft); + border-radius: 7px; + background: color-mix(in srgb, var(--pr-bg) 74%, var(--pr-surface)); +} + +.pr-confirm-summary span { + color: var(--pr-muted); + font-size: 11px; + font-weight: 650; +} + +.pr-confirm-summary strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--pr-text); + font-size: 12px; + white-space: nowrap; +} + +.pr-lifecycle-warnings--modal { + margin-top: 12px; +} + .pr-modal-foot { display: flex; justify-content: flex-end; @@ -1420,6 +1916,12 @@ button:disabled { "access"; } + .pr-command-bar--simple { + grid-template-areas: + "brand" + "actions"; + } + .pr-sidebar, .pr-composer, .pr-review-workspace { @@ -1435,7 +1937,8 @@ button:disabled { .pr-open-strip, .pr-pr-header, - .pr-pr-actions { + .pr-pr-actions, + .pr-command-actions { align-items: stretch; flex-direction: column; } @@ -1446,10 +1949,18 @@ button:disabled { .pr-top-watch-form, .pr-access-row, .pr-source-row, - .pr-sync-panel { + .pr-sync-panel, + .pr-language-control, + .pr-settings-body, + .pr-confirm-summary { grid-template-columns: 1fr; } + .pr-reviewing-banner { + align-items: stretch; + flex-direction: column; + } + .pr-access-row .pr-btn, .pr-access-row .pr-token-badge, .pr-refresh-now { diff --git a/src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js b/src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js index eef7b9f005..5a8ec6c677 100644 --- a/src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js +++ b/src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js @@ -21,6 +21,11 @@ const I18N = { 'en-US': { title: 'PR Review Inbox', subtitle: 'Watch repositories, open PRs, review diffs, compose feedback, and publish with confirmation.', + manageSources: 'Manage sources', + sourceSettingsTitle: 'Sources and queue settings', + sourceSettingsSubtitle: 'Adjust watched repositories, queue mode, private access, or open a single PR.', + queueSettingsTitle: 'Queue settings', + queueSettingsHint: 'Choose which PRs appear in the review queue. The main workspace stays focused on review.', queueModeAll: 'Repository PRs', queueModeMine: 'Needs my review', queueModeAllHint: 'Sync open PRs from watched repositories.', @@ -70,7 +75,7 @@ const I18N = { manualToken: 'Manual token', repoAddedSyncing: 'Repository added. Syncing queue...', advancedProviders: 'Advanced provider settings', - errorRepoNotFound: 'Repository not found or not accessible. Check the repository path or authorize GitHub CLI.', + errorSourceUnavailable: 'Could not refresh {repo}. The repository or PR may be unavailable, the provider API may be incompatible, or your account may not have access. Check Manage sources or authorize the provider.', workspaceDiscovering: 'Detecting repositories from the current workspace...', workspaceDiscovered: 'Added {count} repository from the current workspace.', workspaceDiscoveredMany: 'Added {count} repositories from the current workspace.', @@ -81,6 +86,17 @@ const I18N = { repositoryFirstHint: 'Add the repo you care about, then sync its review queue.', singlePrFallback: 'Inspect one PR', singlePrFallbackHint: 'Use this when the repository is not watched yet.', + directOpenBusySync: 'Queue sync is running. Wait a moment before opening a single PR.', + directOpenBusyReview: 'AI review is running. Stop or finish it before opening another PR.', + directOpenBusyPublish: 'Review publishing is in progress. Finish it before opening another PR.', + directOpenBusyLifecycle: 'A PR lifecycle action is in progress. Finish it before opening another PR.', + directOpenBusyGeneric: 'Another PR action is in progress. Finish it before opening a single PR.', + busyActionSync: 'Queue sync is already running. Wait a moment before starting another action.', + busyActionReview: 'AI review is running. Stop or finish it before using this action.', + busyActionPublish: 'Review publishing is in progress. Finish it before using this action.', + busyActionLifecycle: 'A PR lifecycle action is in progress. Finish it before using this action.', + busyActionGeneric: 'Another PR action is in progress. Finish it before using this action.', + publishedItemLocked: 'Published review items cannot be deleted from BitFun.', owner: 'Owner', repo: 'Repository', providerName: 'Display name', @@ -104,6 +120,8 @@ const I18N = { ciDetails: 'CI details', existingReview: 'Existing discussion', ciFolded: 'CI is folded by default. Open it when status needs attention.', + ciFreshnessHint: 'CI is fetched when this PR is opened, refreshed, or auto-synced.', + ciOpenDetails: 'Open details', noCi: 'No CI status returned.', noBody: 'No description.', noFiles: 'No changed files returned by the provider.', @@ -111,14 +129,20 @@ const I18N = { manualComment: 'Manual comment', manualCommentPlaceholder: 'Write a PR-level comment or paste a finding here.', addManualComment: 'Add to review', + expandComment: 'Expand', + collapseComment: 'Collapse', composer: 'Review composer', composerHint: 'Draft, edit, select, then publish. Nothing is sent without confirmation.', modeFast: 'Fast scan', modeFocused: 'Focused', modeDeep: 'Deep', + reviewLanguage: 'Review language', + reviewLanguageEn: 'English', + reviewLanguageZh: 'Chinese', + reviewLanguageHint: 'Used for AI review comments.', summaryComment: 'Summary comment', inlineComment: 'Inline comment', - reviewDecision: 'Review decision', + reviewDecision: 'Overall review', decisionComment: 'Comment', decisionApprove: 'Approve', decisionRequestChanges: 'Request changes', @@ -126,15 +150,18 @@ const I18N = { publishSelected: 'Publish selected', publishConfirmTitle: 'Publish selected review items?', publishConfirmBody: 'These comments will be posted to the provider. This action cannot be undone from BitFun.', + overwriteDraftTitle: 'Replace current review draft?', + overwriteDraftBody: '{count} unpublished review items will be deleted before a new AI review starts. Published comments are kept as context for the next review.', + confirmStartReview: 'Start new AI review', publishStaleTitle: 'PR head changed', publishStaleBody: 'The draft was created for an older head. Refresh or confirm that you want to publish against the latest head.', staleConfirm: 'I understand the PR head changed', publishNow: 'Publish now', cancel: 'Cancel', - markReviewed: 'Mark current head reviewed', - audit: 'Publish audit', + audit: 'Action audit', statusReady: 'Ready', statusRefreshing: 'Syncing queue...', + statusPartialSync: 'Refreshed what BitFun could access. {count} source could not be refreshed.', statusAssignedNeedsToken: 'Assigned-review sync needs a session token for the selected provider.', statusNoSubscriptions: 'Add a watched repository or paste a PR URL first.', statusNoActiveSubscriptions: 'All watched repositories are paused. Re-enable one or paste a PR URL.', @@ -150,22 +177,106 @@ const I18N = { reviewDetailAi: 'AI is analyzing the diff and existing discussion.', reviewDetailAiWait: 'Still analyzing; large diffs can take a little while.', reviewDetailBuild: 'Building an editable review draft.', + reviewingBannerTitle: 'AI review is running', + reviewingBannerBody: 'BitFun is reading the diff and preparing editable findings. You can keep browsing the PR or stop this run.', cancelReview: 'Cancel review', reviewCancelled: 'Review cancelled', statusPublishing: 'Publishing review...', statusSaved: 'Saved', statusPublished: 'Review published', - statusReviewed: 'Current head marked reviewed', + statusPublishFailed: 'No review item was published. Check the action audit and try again.', errorParse: 'Could not identify a PR from this URL.', errorNetwork: 'Provider request failed', newPrTitle: 'New reviewable PR', newHeadTitle: 'New commits on reviewed PR', + newPrBatchTitle: 'Review queue updated', + newPrBatchBody: '{count} PRs need attention.', + newHeadBatchBody: '{count} reviewed PRs have new commits.', publicRead: 'Public read', privateAction: 'Private and write actions', draftStatus: 'Draft', readyStatus: 'Ready', + prDraftStatus: 'Draft PR', + readyForReviewStatus: 'Ready for review', + lifecyclePanel: 'PR lifecycle', + lifecyclePanelHint: 'Status is checked before actions.', + lifecycleUnsupported: 'This provider does not support this action from BitFun.', + refreshLifecycle: 'Refresh status', + refreshingLifecycle: 'Refreshing PR lifecycle status...', + lifecycleUpdating: 'Updating PR lifecycle...', + lifecycleState: 'PR state', + lifecyclePermission: 'Permission', + lifecycleFreshness: 'Head', + lifecycleChecks: 'Checks', + lifecycleReviews: 'Reviews', + lifecycleMergeability: 'Mergeability', + lifecycleTokenReady: 'Authorized', + lifecycleTokenMissing: 'Authorization needed', + lifecycleHeadReady: 'Head {sha}', + lifecycleHeadMissing: 'Head unavailable', + lifecycleOpen: 'Open', + lifecycleClosed: 'Closed', + lifecycleMerged: 'Merged', + lifecycleMergeable: 'Mergeable', + lifecycleBlocked: 'Blocked', + lifecycleUnknown: 'Unknown', + lifecycleChecking: 'Checking', + lifecycleChecksPassing: 'Passing', + lifecycleChecksPending: 'Pending', + lifecycleChecksFailing: 'Failing', + lifecycleChecksMissing: 'No checks', + lifecycleReviewApproved: 'Approved', + lifecycleReviewChanges: 'Changes requested', + lifecycleReviewCommented: 'Comments only', + lifecycleReviewMissing: 'No review signal', + lifecycleBlockDraft: 'Draft PRs cannot be merged.', + lifecycleBlockClosed: 'Only open PRs can be updated.', + lifecycleBlockMerged: 'This PR is already merged.', + lifecycleBlockHead: 'The provider did not return a head SHA.', + lifecycleBlockToken: 'Authorize this provider before lifecycle actions.', + lifecycleBlockMergeable: 'The provider does not report this PR as mergeable.', + lifecycleWarnChecks: 'Checks are not passing.', + lifecycleWarnReviews: 'Review state needs attention.', + lifecycleGuideToken: 'Use GitHub CLI authorization or paste a token before changing PR state.', + lifecycleGuideDraft: 'Mark the PR ready before merging.', + lifecycleGuideClosed: 'Open the provider page to reopen or inspect the closed PR.', + lifecycleGuideHead: 'Refresh the PR so BitFun can verify the latest head commit.', + lifecycleGuideMergeable: 'Open the provider page to resolve conflicts or branch-protection blocks.', + lifecycleGuideChecking: 'Refresh again after the provider finishes mergeability checks.', + lifecycleGuideChecks: 'Open CI details, fix failing checks, or wait for pending checks.', + lifecycleGuideReviews: 'Resolve requested changes or collect an approval before merging.', + lifecycleAutoAuthHint: 'BitFun will try to authorize with GitHub CLI before this action.', + lifecycleAuthFailed: 'Authorization is required. Use GitHub CLI authorization or paste a token in Manage sources.', + markPrReady: 'Mark ready', + convertPrDraft: 'Convert to draft', + mergePr: 'Merge PR', + mergeMethod: 'Merge method', + mergeMethodMerge: 'Merge commit', + mergeMethodSquash: 'Squash merge', + mergeMethodRebase: 'Rebase merge', + lifecycleConfirmReadyTitle: 'Mark this PR ready for review?', + lifecycleConfirmDraftTitle: 'Convert this PR to a draft?', + lifecycleConfirmMergeTitle: 'Merge this PR?', + lifecycleConfirmReadyBody: 'The provider-side PR state will become ready for review.', + lifecycleConfirmDraftBody: 'The provider-side PR state will become draft. Reviewers may stop seeing it as ready.', + lifecycleConfirmMergeBody: 'This will merge the selected PR on the provider. BitFun will use the expected head SHA shown below.', + lifecycleConfirmWarning: 'Warnings', + expectedHeadSha: 'Expected head', + confirmLifecycle: 'Confirm action', + lifecycleActionReady: 'Ready state', + lifecycleActionDraft: 'Draft state', + lifecycleActionMerge: 'Merge', + lifecycleReadyDone: 'PR marked ready for review', + lifecycleDraftDone: 'PR converted to draft', + lifecycleMergeDone: 'PR merged', + lifecycleHeadChanged: 'The PR head changed. Refresh the PR before taking this action.', + lifecycleMergeBlocked: 'Merge is blocked: {reason}', + lifecycleActionUnsupported: 'This lifecycle action is not supported for the selected provider.', overviewHint: 'Expand for full description.', noActionableFindings: 'No actionable findings were generated. Add a manual comment or edit this review decision before publishing.', + publishNoDraftItems: 'Generate or add a review item before publishing.', + publishSelectItemFirst: 'Select at least one draft review item before publishing.', + suggestedFix: 'Suggested fix', binary: 'binary', large: 'large', stale: 'stale', @@ -177,6 +288,11 @@ const I18N = { 'zh-CN': { title: 'PR 审核台', subtitle: '监听仓库、打开 PR、查看变更、组织意见,并在二次确认后发布 Review。', + manageSources: '管理来源', + sourceSettingsTitle: '来源与队列设置', + sourceSettingsSubtitle: '调整监听仓库、队列范围、私有授权,或单独打开某个 PR。', + queueSettingsTitle: '队列设置', + queueSettingsHint: '选择哪些 PR 进入审核队列。主界面只保留审核工作流。', queueModeAll: '仓库全部 PR', queueModeMine: '待我审核', queueModeAllHint: '从已监听仓库同步打开状态的 PR。', @@ -226,7 +342,7 @@ const I18N = { manualToken: '手动 Token', repoAddedSyncing: '已添加监听仓库,正在同步队列...', advancedProviders: '高级平台设置', - errorRepoNotFound: '仓库不存在或当前无权访问,请检查仓库路径或授权 GitHub CLI。', + errorSourceUnavailable: '无法刷新 {repo}。仓库或 PR 可能不可用、接口可能不兼容,或当前账号无权访问。请在“管理来源”中检查仓库地址;如为私有仓库,请先完成授权。', workspaceDiscovering: '正在从当前工作区识别仓库...', workspaceDiscovered: '已从当前工作区添加 {count} 个仓库。', workspaceDiscoveredMany: '已从当前工作区添加 {count} 个仓库。', @@ -237,6 +353,17 @@ const I18N = { repositoryFirstHint: '先添加要关注的仓库,再同步它的审核队列。', singlePrFallback: '单独检视一个 PR', singlePrFallbackHint: '当这个仓库暂时不需要监听时使用。', + directOpenBusySync: '正在同步队列,请稍后再单独打开 PR。', + directOpenBusyReview: 'AI 审核正在进行,请先完成或中止后再打开其他 PR。', + directOpenBusyPublish: 'Review 正在发布,请完成后再打开其他 PR。', + directOpenBusyLifecycle: 'PR 状态操作正在执行,请完成后再打开其他 PR。', + directOpenBusyGeneric: '当前有 PR 操作正在进行,请完成后再单独打开 PR。', + busyActionSync: '队列正在同步,请稍后再执行其他操作。', + busyActionReview: 'AI 审核正在进行,请先完成或中止后再执行此操作。', + busyActionPublish: 'Review 正在发布,请完成后再执行此操作。', + busyActionLifecycle: 'PR 状态操作正在执行,请完成后再执行此操作。', + busyActionGeneric: '当前有 PR 操作正在进行,请完成后再执行此操作。', + publishedItemLocked: '已发布的 Review 项不能在 BitFun 中删除。', owner: 'Owner', repo: '仓库', providerName: '显示名称', @@ -260,6 +387,8 @@ const I18N = { ciDetails: 'CI 详情', existingReview: '已有讨论', ciFolded: 'CI 默认折叠,只有需要定位状态时再展开。', + ciFreshnessHint: 'CI 会在打开 PR、刷新或自动同步时重新获取。', + ciOpenDetails: '打开详情', noCi: '代码平台没有返回 CI 状态。', noBody: '没有描述。', noFiles: '代码平台没有返回变更文件。', @@ -267,14 +396,20 @@ const I18N = { manualComment: '手写评论', manualCommentPlaceholder: '在这里写 PR 级评论,或粘贴你已经发现的问题。', addManualComment: '加入 Review', + expandComment: '展开', + collapseComment: '收起', composer: 'Review 编辑器', composerHint: '生成、编辑、选择,再发布。未经确认不会提交到代码平台。', modeFast: '快速扫读', modeFocused: '重点审核', modeDeep: '深度审核', + reviewLanguage: '审核语言', + reviewLanguageEn: '英文', + reviewLanguageZh: '中文', + reviewLanguageHint: '用于 AI 生成的审核意见。', summaryComment: '总结评论', inlineComment: '行内评论', - reviewDecision: 'Review 结论', + reviewDecision: '整体 Review', decisionComment: '评论', decisionApprove: '通过', decisionRequestChanges: '要求修改', @@ -282,15 +417,18 @@ const I18N = { publishSelected: '发布选中项', publishConfirmTitle: '确认发布选中的 Review 内容?', publishConfirmBody: '这些评论会提交到代码平台。BitFun 无法替你撤回这个操作。', + overwriteDraftTitle: '替换当前审核草稿?', + overwriteDraftBody: '新的 AI 审核开始前,会删除 {count} 条未发布审核意见。已发布的意见会作为下一轮审核上下文保留。', + confirmStartReview: '开始新的 AI 审核', publishStaleTitle: 'PR head 已变化', publishStaleBody: '草稿基于旧 head 生成。请刷新,或明确确认要基于最新 head 继续发布。', staleConfirm: '我确认 PR head 已变化', publishNow: '立即发布', cancel: '取消', - markReviewed: '标记当前 head 已审', - audit: '发布审计', + audit: '操作记录', statusReady: '就绪', statusRefreshing: '正在同步队列...', + statusPartialSync: '已刷新 BitFun 可访问的内容。{count} 个来源暂时无法刷新。', statusAssignedNeedsToken: '同步待我审核需要当前代码平台的会话 Token。', statusNoSubscriptions: '请先添加监听仓库,或粘贴一个 PR 链接。', statusNoActiveSubscriptions: '已添加的监听仓库都处于暂停状态,请重新开启一个仓库或粘贴 PR 链接。', @@ -306,22 +444,106 @@ const I18N = { reviewDetailAi: 'AI 正在分析 diff 和已有讨论。', reviewDetailAiWait: '仍在分析中,大型 diff 可能需要稍等。', reviewDetailBuild: '正在整理可编辑的审核草稿。', + reviewingBannerTitle: 'AI 正在审核', + reviewingBannerBody: 'BitFun 正在读取 diff 并整理可编辑问题。你可以继续浏览 PR,也可以中止本次审核。', cancelReview: '中止审核', reviewCancelled: '审核已中止', statusPublishing: '正在发布 Review...', statusSaved: '已保存', statusPublished: 'Review 已发布', - statusReviewed: '已标记当前 head 已审', + statusPublishFailed: '没有 Review 内容发布成功。请查看操作记录后重试。', errorParse: '无法从这个链接识别 PR。', errorNetwork: '代码平台请求失败', newPrTitle: '新的可审核 PR', newHeadTitle: '已审 PR 有新提交', + newPrBatchTitle: '审核队列已更新', + newPrBatchBody: '{count} 个 PR 需要关注。', + newHeadBatchBody: '{count} 个已审核 PR 有新提交。', publicRead: '公开读取', privateAction: '私有与写入操作', draftStatus: '草稿', readyStatus: '可审', + prDraftStatus: '草稿 PR', + readyForReviewStatus: '可审 PR', + lifecyclePanel: 'PR 生命周期', + lifecyclePanelHint: '操作前会先校验状态。', + lifecycleUnsupported: '当前代码平台不支持在 BitFun 中执行这个操作。', + refreshLifecycle: '刷新状态', + refreshingLifecycle: '正在刷新 PR 生命周期状态...', + lifecycleUpdating: '正在更新 PR 生命周期...', + lifecycleState: 'PR 状态', + lifecyclePermission: '权限', + lifecycleFreshness: 'Head', + lifecycleChecks: '检查', + lifecycleReviews: 'Review', + lifecycleMergeability: '可合并性', + lifecycleTokenReady: '已授权', + lifecycleTokenMissing: '需要授权', + lifecycleHeadReady: 'Head {sha}', + lifecycleHeadMissing: '未返回 Head', + lifecycleOpen: '打开', + lifecycleClosed: '已关闭', + lifecycleMerged: '已合并', + lifecycleMergeable: '可合并', + lifecycleBlocked: '受阻', + lifecycleUnknown: '未知', + lifecycleChecking: '检查中', + lifecycleChecksPassing: '通过', + lifecycleChecksPending: '等待中', + lifecycleChecksFailing: '失败', + lifecycleChecksMissing: '无检查', + lifecycleReviewApproved: '已批准', + lifecycleReviewChanges: '要求修改', + lifecycleReviewCommented: '仅评论', + lifecycleReviewMissing: '无 Review 信号', + lifecycleBlockDraft: '草稿 PR 不能合并。', + lifecycleBlockClosed: '只有打开状态的 PR 可以更新。', + lifecycleBlockMerged: '这个 PR 已经合并。', + lifecycleBlockHead: '代码平台未返回 head SHA。', + lifecycleBlockToken: '执行生命周期操作前需要先授权。', + lifecycleBlockMergeable: '代码平台未确认这个 PR 可合并。', + lifecycleWarnChecks: '检查尚未全部通过。', + lifecycleWarnReviews: 'Review 状态需要关注。', + lifecycleGuideToken: '先使用 GitHub CLI 授权,或粘贴 Token,再变更 PR 状态。', + lifecycleGuideDraft: '先将 PR 标记为可审,再执行合并。', + lifecycleGuideClosed: '打开代码平台页面,重新打开或检查这个已关闭 PR。', + lifecycleGuideHead: '刷新 PR,让 BitFun 确认最新 head 提交。', + lifecycleGuideMergeable: '打开代码平台页面处理冲突或分支保护阻塞。', + lifecycleGuideChecking: '等待代码平台完成可合并性检查后再刷新。', + lifecycleGuideChecks: '展开 CI 详情,修复失败检查或等待检查完成。', + lifecycleGuideReviews: '处理要求修改的意见,或先获得批准后再合并。', + lifecycleAutoAuthHint: 'BitFun 会先尝试通过 GitHub CLI 自动授权。', + lifecycleAuthFailed: '需要授权。请在管理来源中使用 GitHub CLI 授权,或粘贴 Token。', + markPrReady: '标记可审', + convertPrDraft: '转为草稿', + mergePr: '合并 PR', + mergeMethod: '合并方式', + mergeMethodMerge: '创建合并提交', + mergeMethodSquash: 'Squash 合并', + mergeMethodRebase: 'Rebase 合并', + lifecycleConfirmReadyTitle: '将这个 PR 标记为可审?', + lifecycleConfirmDraftTitle: '将这个 PR 转为草稿?', + lifecycleConfirmMergeTitle: '合并这个 PR?', + lifecycleConfirmReadyBody: '代码平台上的 PR 状态会变为可审。', + lifecycleConfirmDraftBody: '代码平台上的 PR 状态会变为草稿,审核者可能不再把它视为待审。', + lifecycleConfirmMergeBody: '这会在代码平台合并当前选中的 PR。BitFun 会使用下方显示的预期 head SHA。', + lifecycleConfirmWarning: '风险提示', + expectedHeadSha: '预期 Head', + confirmLifecycle: '确认执行', + lifecycleActionReady: '可审状态', + lifecycleActionDraft: '草稿状态', + lifecycleActionMerge: '合并', + lifecycleReadyDone: '已标记为可审 PR', + lifecycleDraftDone: '已转为草稿 PR', + lifecycleMergeDone: 'PR 已合并', + lifecycleHeadChanged: 'PR head 已变化,请刷新后再执行该操作。', + lifecycleMergeBlocked: '合并被阻止:{reason}', + lifecycleActionUnsupported: '当前代码平台不支持这个生命周期操作。', overviewHint: '展开查看完整描述。', noActionableFindings: '没有生成可操作问题。发布前可以添加手写评论,或编辑这条 Review 结论。', + publishNoDraftItems: '请先生成或添加一条 Review 内容,再发布。', + publishSelectItemFirst: '请至少选中一条 Review 草稿后再发布。', + suggestedFix: '建议处理', binary: '二进制', large: '过大', stale: '已过期', @@ -333,6 +555,11 @@ const I18N = { 'zh-TW': { title: 'PR 審核台', subtitle: '監聽倉庫、開啟 PR、檢視變更、組織意見,並在二次確認後發布 Review。', + manageSources: '管理來源', + sourceSettingsTitle: '來源與佇列設定', + sourceSettingsSubtitle: '調整監聽倉庫、佇列範圍、私有授權,或單獨開啟某個 PR。', + queueSettingsTitle: '佇列設定', + queueSettingsHint: '選擇哪些 PR 進入審核佇列。主介面只保留審核工作流。', queueModeAll: '倉庫全部 PR', queueModeMine: '待我審核', queueModeAllHint: '從已監聽倉庫同步開啟狀態的 PR。', @@ -382,7 +609,7 @@ const I18N = { manualToken: '手動 Token', repoAddedSyncing: '已新增監聽倉庫,正在同步佇列...', advancedProviders: '進階平台設定', - errorRepoNotFound: '倉庫不存在或目前無權存取,請檢查倉庫路徑或授權 GitHub CLI。', + errorSourceUnavailable: '無法重新整理 {repo}。倉庫或 PR 可能無法使用、介面可能不相容,或目前帳號無權存取。請在「管理來源」中檢查倉庫地址;如為私有倉庫,請先完成授權。', workspaceDiscovering: '正在從目前工作區識別倉庫...', workspaceDiscovered: '已從目前工作區新增 {count} 個倉庫。', workspaceDiscoveredMany: '已從目前工作區新增 {count} 個倉庫。', @@ -393,6 +620,17 @@ const I18N = { repositoryFirstHint: '先新增要關注的倉庫,再同步它的審核佇列。', singlePrFallback: '單獨檢視一個 PR', singlePrFallbackHint: '當這個倉庫暫時不需要監聽時使用。', + directOpenBusySync: '正在同步佇列,請稍後再單獨開啟 PR。', + directOpenBusyReview: 'AI 審核正在進行,請先完成或中止後再開啟其他 PR。', + directOpenBusyPublish: 'Review 正在發布,請完成後再開啟其他 PR。', + directOpenBusyLifecycle: 'PR 狀態操作正在執行,請完成後再開啟其他 PR。', + directOpenBusyGeneric: '目前有 PR 操作正在進行,請完成後再單獨開啟 PR。', + busyActionSync: '佇列正在同步,請稍後再執行其他操作。', + busyActionReview: 'AI 審核正在進行,請先完成或中止後再執行此操作。', + busyActionPublish: 'Review 正在發布,請完成後再執行此操作。', + busyActionLifecycle: 'PR 狀態操作正在執行,請完成後再執行此操作。', + busyActionGeneric: '目前有 PR 操作正在進行,請完成後再執行此操作。', + publishedItemLocked: '已發布的 Review 項不能在 BitFun 中刪除。', owner: 'Owner', repo: '倉庫', providerName: '顯示名稱', @@ -416,6 +654,8 @@ const I18N = { ciDetails: 'CI 詳情', existingReview: '既有討論', ciFolded: 'CI 預設摺疊,只有需要定位狀態時再展開。', + ciFreshnessHint: 'CI 會在開啟 PR、重新整理或自動同步時重新取得。', + ciOpenDetails: '開啟詳情', noCi: '程式碼平台沒有回傳 CI 狀態。', noBody: '沒有描述。', noFiles: '程式碼平台沒有回傳變更檔案。', @@ -423,14 +663,20 @@ const I18N = { manualComment: '手寫評論', manualCommentPlaceholder: '在這裡寫 PR 級評論,或貼上你已經發現的問題。', addManualComment: '加入 Review', + expandComment: '展開', + collapseComment: '收起', composer: 'Review 編輯器', composerHint: '產生、編輯、選擇,再發布。未經確認不會提交到程式碼平台。', modeFast: '快速掃讀', modeFocused: '重點審核', modeDeep: '深度審核', + reviewLanguage: '審核語言', + reviewLanguageEn: '英文', + reviewLanguageZh: '中文', + reviewLanguageHint: '用於 AI 產生的審核意見。', summaryComment: '總結評論', inlineComment: '行內評論', - reviewDecision: 'Review 結論', + reviewDecision: '整體 Review', decisionComment: '評論', decisionApprove: '通過', decisionRequestChanges: '要求修改', @@ -438,15 +684,18 @@ const I18N = { publishSelected: '發布選取項', publishConfirmTitle: '確認發布選取的 Review 內容?', publishConfirmBody: '這些評論會提交到程式碼平台。BitFun 無法替你撤回這個操作。', + overwriteDraftTitle: '取代目前審核草稿?', + overwriteDraftBody: '新的 AI 審核開始前,會刪除 {count} 條未發布審核意見。已發布的意見會作為下一輪審核上下文保留。', + confirmStartReview: '開始新的 AI 審核', publishStaleTitle: 'PR head 已變更', publishStaleBody: '草稿基於舊 head 產生。請重新整理,或明確確認要基於最新 head 繼續發布。', staleConfirm: '我確認 PR head 已變更', publishNow: '立即發布', cancel: '取消', - markReviewed: '標記目前 head 已審', - audit: '發布審計', + audit: '操作記錄', statusReady: '就緒', statusRefreshing: '正在同步佇列...', + statusPartialSync: '已重新整理 BitFun 可存取的內容。{count} 個來源暫時無法重新整理。', statusAssignedNeedsToken: '同步待我審核需要目前程式碼平台的工作階段 Token。', statusNoSubscriptions: '請先新增監聽倉庫,或貼上一個 PR 連結。', statusNoActiveSubscriptions: '已新增的監聽倉庫都處於暫停狀態,請重新開啟一個倉庫或貼上 PR 連結。', @@ -462,22 +711,106 @@ const I18N = { reviewDetailAi: 'AI 正在分析 diff 和既有討論。', reviewDetailAiWait: '仍在分析中,大型 diff 可能需要稍等。', reviewDetailBuild: '正在整理可編輯的審核草稿。', + reviewingBannerTitle: 'AI 正在審核', + reviewingBannerBody: 'BitFun 正在讀取 diff 並整理可編輯問題。你可以繼續瀏覽 PR,也可以中止本次審核。', cancelReview: '中止審核', reviewCancelled: '審核已中止', statusPublishing: '正在發布 Review...', statusSaved: '已儲存', statusPublished: 'Review 已發布', - statusReviewed: '已標記目前 head 已審', + statusPublishFailed: '沒有 Review 內容發布成功。請查看操作記錄後重試。', errorParse: '無法從這個連結識別 PR。', errorNetwork: '程式碼平台請求失敗', newPrTitle: '新的可審核 PR', newHeadTitle: '已審 PR 有新提交', + newPrBatchTitle: '審核佇列已更新', + newPrBatchBody: '{count} 個 PR 需要關注。', + newHeadBatchBody: '{count} 個已審核 PR 有新提交。', publicRead: '公開讀取', privateAction: '私有與寫入操作', draftStatus: '草稿', readyStatus: '可審', + prDraftStatus: '草稿 PR', + readyForReviewStatus: '可審 PR', + lifecyclePanel: 'PR 生命週期', + lifecyclePanelHint: '操作前會先校驗狀態。', + lifecycleUnsupported: '目前程式碼平台不支援在 BitFun 中執行此操作。', + refreshLifecycle: '重新整理狀態', + refreshingLifecycle: '正在重新整理 PR 生命週期狀態...', + lifecycleUpdating: '正在更新 PR 生命週期...', + lifecycleState: 'PR 狀態', + lifecyclePermission: '權限', + lifecycleFreshness: 'Head', + lifecycleChecks: '檢查', + lifecycleReviews: 'Review', + lifecycleMergeability: '可合併性', + lifecycleTokenReady: '已授權', + lifecycleTokenMissing: '需要授權', + lifecycleHeadReady: 'Head {sha}', + lifecycleHeadMissing: '未返回 Head', + lifecycleOpen: '開啟', + lifecycleClosed: '已關閉', + lifecycleMerged: '已合併', + lifecycleMergeable: '可合併', + lifecycleBlocked: '受阻', + lifecycleUnknown: '未知', + lifecycleChecking: '檢查中', + lifecycleChecksPassing: '通過', + lifecycleChecksPending: '等待中', + lifecycleChecksFailing: '失敗', + lifecycleChecksMissing: '無檢查', + lifecycleReviewApproved: '已核准', + lifecycleReviewChanges: '要求修改', + lifecycleReviewCommented: '僅評論', + lifecycleReviewMissing: '無 Review 訊號', + lifecycleBlockDraft: '草稿 PR 不能合併。', + lifecycleBlockClosed: '只有開啟狀態的 PR 可以更新。', + lifecycleBlockMerged: '這個 PR 已經合併。', + lifecycleBlockHead: '程式碼平台未返回 head SHA。', + lifecycleBlockToken: '執行生命週期操作前需要先授權。', + lifecycleBlockMergeable: '程式碼平台未確認這個 PR 可合併。', + lifecycleWarnChecks: '檢查尚未全部通過。', + lifecycleWarnReviews: 'Review 狀態需要關注。', + lifecycleGuideToken: '先使用 GitHub CLI 授權,或貼上 Token,再變更 PR 狀態。', + lifecycleGuideDraft: '先將 PR 標記為可審,再執行合併。', + lifecycleGuideClosed: '開啟程式碼平台頁面,重新開啟或檢查這個已關閉 PR。', + lifecycleGuideHead: '重新整理 PR,讓 BitFun 確認最新 head 提交。', + lifecycleGuideMergeable: '開啟程式碼平台頁面處理衝突或分支保護阻塞。', + lifecycleGuideChecking: '等待程式碼平台完成可合併性檢查後再重新整理。', + lifecycleGuideChecks: '展開 CI 詳情,修復失敗檢查或等待檢查完成。', + lifecycleGuideReviews: '處理要求修改的意見,或先取得核准後再合併。', + lifecycleAutoAuthHint: 'BitFun 會先嘗試透過 GitHub CLI 自動授權。', + lifecycleAuthFailed: '需要授權。請在管理來源中使用 GitHub CLI 授權,或貼上 Token。', + markPrReady: '標記可審', + convertPrDraft: '轉為草稿', + mergePr: '合併 PR', + mergeMethod: '合併方式', + mergeMethodMerge: '建立合併提交', + mergeMethodSquash: 'Squash 合併', + mergeMethodRebase: 'Rebase 合併', + lifecycleConfirmReadyTitle: '將這個 PR 標記為可審?', + lifecycleConfirmDraftTitle: '將這個 PR 轉為草稿?', + lifecycleConfirmMergeTitle: '合併這個 PR?', + lifecycleConfirmReadyBody: '程式碼平台上的 PR 狀態會變為可審。', + lifecycleConfirmDraftBody: '程式碼平台上的 PR 狀態會變為草稿,審核者可能不再把它視為待審。', + lifecycleConfirmMergeBody: '這會在程式碼平台合併目前選中的 PR。BitFun 會使用下方顯示的預期 head SHA。', + lifecycleConfirmWarning: '風險提示', + expectedHeadSha: '預期 Head', + confirmLifecycle: '確認執行', + lifecycleActionReady: '可審狀態', + lifecycleActionDraft: '草稿狀態', + lifecycleActionMerge: '合併', + lifecycleReadyDone: '已標記為可審 PR', + lifecycleDraftDone: '已轉為草稿 PR', + lifecycleMergeDone: 'PR 已合併', + lifecycleHeadChanged: 'PR head 已變化,請重新整理後再執行該操作。', + lifecycleMergeBlocked: '合併被阻止:{reason}', + lifecycleActionUnsupported: '目前程式碼平台不支援這個生命週期操作。', overviewHint: '展開查看完整描述。', noActionableFindings: '沒有生成可操作問題。發布前可以新增手寫評論,或編輯這條 Review 結論。', + publishNoDraftItems: '請先產生或新增一條 Review 內容,再發布。', + publishSelectItemFirst: '請至少選取一條 Review 草稿後再發布。', + suggestedFix: '建議處理', binary: '二進位', large: '過大', stale: '已過期', @@ -519,8 +852,10 @@ const state = { selectedFilePath: null, directUrl: '', mode: 'focused_review', + reviewLanguage: 'en', queueMode: 'all', drafts: {}, + publishedReviewContext: {}, audit: [], lastReviewedHeads: {}, notifiedKeys: [], @@ -533,10 +868,17 @@ const state = { busy: null, status: null, error: null, + startupSyncing: false, reviewProgress: null, cancelReviewRequested: false, + filesExpanded: false, + manualCommentExpanded: false, + manualCommentDraft: '', activeProviderId: 'github', confirm: null, + draftOverwriteConfirm: null, + lifecycleConfirm: null, + settingsOpen: false, }, volatile: { sessionTokens: {}, @@ -565,6 +907,30 @@ function restoreReviewWorkspaceScroll(position) { }); } +function readPaneScrolls() { + return ['.pr-sidebar', '.pr-review-workspace', '.pr-composer'].map((selector) => { + const node = document.querySelector(selector); + if (!(node instanceof HTMLElement)) return null; + return { + selector, + top: node.scrollTop, + left: node.scrollLeft, + }; + }).filter(Boolean); +} + +function restorePaneScrolls(positions) { + if (!positions?.length) return; + window.requestAnimationFrame(() => { + for (const position of positions) { + const node = document.querySelector(position.selector); + if (!(node instanceof HTMLElement)) continue; + node.scrollTop = position.top; + node.scrollLeft = position.left; + } + }); +} + function t(key, params = {}) { const table = I18N[state.locale] || I18N['en-US']; const fallback = I18N['en-US'][key] || key; @@ -607,6 +973,28 @@ function profileById(id) { return state.data.profiles.find((profile) => profile.id === id) || state.data.profiles[0]; } +function sourceLabel(source) { + const profileId = source?.providerId || source?.id || ''; + const profile = profileId ? profileById(profileId) : null; + const providerName = source?.displayName || profile?.displayName || source?.id || profile?.id || t('provider'); + if (!(source?.owner && source?.repo)) return providerName; + const repoName = `${source.owner}/${source.repo}`; + const number = Number.isFinite(Number(source?.number)) ? `#${source.number}` : ''; + return `${providerName} ${repoName}${number}`; +} + +function sourceAccessError(source, error) { + const friendly = new Error(t('errorSourceUnavailable', { repo: sourceLabel(source) })); + friendly.status = Number(error?.status || 0); + return friendly; +} + +function preferAccessError(firstError, secondError) { + return [401, 403, 404].includes(Number(secondError?.status || 0)) + ? secondError + : firstError; +} + function activeProfile() { return profileById(state.ui.activeProviderId); } @@ -622,7 +1010,7 @@ function setReviewProgress(stageKey, detail = '', progressPct = 8) { progressPct: Math.max(0, Math.min(100, Number(progressPct) || 0)), cancelled: state.ui.cancelReviewRequested, }; - render(); + render({ preservePaneScroll: true }); } function modeLabel(mode) { @@ -732,7 +1120,6 @@ async function netJson(url, options = {}) { : String(body || `${status}`); const error = new Error(`${t('errorNetwork')}: ${message}`); error.status = status; - error.body = body; throw error; } return body; @@ -773,12 +1160,14 @@ async function loadStorage() { subscriptions, items: Array.isArray(parsed.items) ? parsed.items : [], drafts: parsed.drafts && typeof parsed.drafts === 'object' ? parsed.drafts : {}, + publishedReviewContext: parsed.publishedReviewContext && typeof parsed.publishedReviewContext === 'object' ? parsed.publishedReviewContext : {}, audit: Array.isArray(parsed.audit) ? parsed.audit : [], lastReviewedHeads: parsed.lastReviewedHeads || {}, notifiedKeys: Array.isArray(parsed.notifiedKeys) ? parsed.notifiedKeys : [], dismissedWorkspaceRepos: Array.isArray(parsed.dismissedWorkspaceRepos) ? parsed.dismissedWorkspaceRepos : [], workspaceAutoListenDoneFor: parsed.workspaceAutoListenDoneFor || '', queueMode: parsed.queueMode || 'all', + reviewLanguage: ['en', 'zh'].includes(parsed.reviewLanguage) ? parsed.reviewLanguage : 'en', }; state.ui.activeProviderId = state.data.profiles[0]?.id || 'github'; } catch (error) { @@ -814,13 +1203,13 @@ function setBusy(key, statusKey) { render(); } -async function finish(statusKey) { +async function finish(statusKey, renderOptions = {}) { state.ui.busy = null; state.ui.status = statusKey ? t(statusKey) : null; state.ui.reviewProgress = null; state.ui.cancelReviewRequested = false; await saveStorage(); - render(); + render(renderOptions); } function setError(error) { @@ -1112,9 +1501,19 @@ async function applyWorkspaceDiscoveredRepositories({ force = false, sync = true } async function refreshQueueOnOpen() { - await applyWorkspaceDiscoveredRepositories({ sync: false }); - if (activeSubscriptions().length) { - void syncQueue('all'); + state.ui.startupSyncing = true; + state.ui.status = t('statusRefreshing'); + state.ui.error = null; + render({ preservePaneScroll: true }); + try { + await applyWorkspaceDiscoveredRepositories({ sync: false }); + if (activeSubscriptions().length) { + await syncQueue('all'); + } + } finally { + state.ui.startupSyncing = false; + if (state.ui.busy === 'refresh') state.ui.busy = null; + render({ preservePaneScroll: true }); } } @@ -1172,6 +1571,86 @@ function normalizeChecks(statusBody, checksBody) { return [...statusChecks, ...checkRuns]; } +function summarizeCheckState(checks = []) { + if (!checks.length) return 'missing'; + const states = checks.map((check) => String(check.conclusion || check.status || '').toLowerCase()); + if (states.some((stateValue) => ['failure', 'failed', 'error', 'timed_out', 'cancelled', 'action_required'].includes(stateValue))) { + return 'failing'; + } + if (states.some((stateValue) => ['pending', 'queued', 'in_progress', 'requested', 'waiting', 'expected'].includes(stateValue))) { + return 'pending'; + } + if (states.every((stateValue) => ['success', 'neutral', 'skipped', 'completed'].includes(stateValue))) { + return 'passing'; + } + return 'unknown'; +} + +function summarizeReviewState(reviewSummary = {}) { + if (reviewSummary.changesRequested > 0) return 'changes_requested'; + if (reviewSummary.approvals > 0) return 'approved'; + if (reviewSummary.comments > 0) return 'commented'; + return 'missing'; +} + +function shortSha(value) { + const sha = String(value || '').trim(); + return sha ? sha.slice(0, 12) : ''; +} + +function buildMergeReadiness({ pr, checks, reviewSummary, profile }) { + const stateName = String(pr.state || pr.status || '').toLowerCase(); + const merged = Boolean(pr.merged || pr.merged_at); + const isOpen = stateName === 'open' || stateName === 'opened'; + const isDraft = Boolean(pr.draft || pr.work_in_progress); + const headSha = pr.head?.sha || pr.head_sha || pr.sha || pr.diff_refs?.head_sha || ''; + const mergeableRaw = pr.mergeable; + const mergeableStateRaw = String(pr.mergeable_state || pr.detailed_merge_status || pr.merge_status || '').toLowerCase(); + const checkState = summarizeCheckState(checks); + const reviewState = summarizeReviewState(reviewSummary); + const blockers = []; + const warnings = []; + + if (merged) blockers.push('lifecycleBlockMerged'); + if (!merged && !isOpen) blockers.push('lifecycleBlockClosed'); + if (isDraft) blockers.push('lifecycleBlockDraft'); + if (!headSha) blockers.push('lifecycleBlockHead'); + if (!hasToken(profile)) blockers.push('lifecycleBlockToken'); + if (mergeableRaw === false || ['dirty', 'blocked', 'cannot_be_merged', 'conflicts'].includes(mergeableStateRaw)) { + blockers.push('lifecycleBlockMergeable'); + } + if ((mergeableRaw == null && !['clean', 'unstable', 'can_be_merged'].includes(mergeableStateRaw)) || mergeableStateRaw === 'checking' || mergeableStateRaw === 'unknown') { + blockers.push('lifecycleChecking'); + } + if (['failing', 'pending', 'unknown', 'missing'].includes(checkState)) warnings.push('lifecycleWarnChecks'); + if (['changes_requested', 'missing'].includes(reviewState)) warnings.push('lifecycleWarnReviews'); + + let mergeability = 'unknown'; + if (mergeableRaw === true || ['clean', 'unstable', 'can_be_merged'].includes(mergeableStateRaw)) { + mergeability = 'mergeable'; + } else if (mergeableRaw === false || ['dirty', 'blocked', 'cannot_be_merged', 'conflicts'].includes(mergeableStateRaw)) { + mergeability = 'blocked'; + } else if ((mergeableRaw == null && !['clean', 'unstable', 'can_be_merged'].includes(mergeableStateRaw)) || mergeableStateRaw === 'checking') { + mergeability = 'checking'; + } + + return { + state: merged ? 'merged' : isOpen ? 'open' : 'closed', + isDraft, + headSha, + expectedHeadSha: headSha, + mergeability, + mergeableState: mergeableStateRaw || String(mergeableRaw ?? 'unknown'), + checkState, + reviewState, + permissionState: hasToken(profile) ? 'ready' : 'missing', + blockers, + warnings, + canMerge: blockers.length === 0, + refreshedAt: new Date().toISOString(), + }; +} + async function fetchGithubSnapshot(identity) { const profile = profileById(identity.providerId); const base = normalizeBaseUrl(profile.apiBaseUrl); @@ -1198,6 +1677,8 @@ async function fetchGithubSnapshot(identity) { statusResult.status === 'fulfilled' ? statusResult.value : null, checksResult.status === 'fulfilled' ? checksResult.value : null, ); + const reviewSummary = summarizeReviews(reviews); + const mergeReadiness = buildMergeReadiness({ pr, checks, reviewSummary, profile }); return { identity: { providerId: profile.id, @@ -1206,11 +1687,16 @@ async function fetchGithubSnapshot(identity) { repo: identity.repo, number: identity.number, }, + queueOrigin: identity.queueOrigin || 'direct', + nodeId: pr.node_id || '', url: pr.html_url || identity.url || `${normalizeBaseUrl(profile.webBaseUrl)}/${identity.owner}/${identity.repo}/pull/${identity.number}`, title: pr.title || `#${identity.number}`, body: pr.body || '', author: pr.user?.login || '', state: pr.state || '', + merged: Boolean(pr.merged || pr.merged_at), + closedAt: pr.closed_at || '', + mergedAt: pr.merged_at || '', isDraft: Boolean(pr.draft), baseBranch: pr.base?.ref || '', headBranch: pr.head?.ref || '', @@ -1220,11 +1706,17 @@ async function fetchGithubSnapshot(identity) { files, checks, reviews, - reviewSummary: summarizeReviews(reviews), + reviewSummary, + mergeReadiness, providerCapabilities: { publishSummaryComment: true, publishInlineComment: true, publishReviewDecision: true, + refreshMergeReadiness: true, + transitionDraftState: true, + mergePullRequest: true, + closePullRequest: false, + reopenPullRequest: false, }, }; } @@ -1238,10 +1730,14 @@ async function fetchCompatibleSnapshot(identity) { pr = await requestWithAuthRetry(profile, () => netJson(`${base}/repos/${ownerRepo}/pulls/${identity.number}`, { headers: providerHeaders(profile), })); - } catch { - pr = await requestWithAuthRetry(profile, () => netJson(`${base}/projects/${encodeURIComponent(`${identity.owner}/${identity.repo}`)}/merge_requests/${identity.number}`, { - headers: providerHeaders(profile), - })); + } catch (firstError) { + try { + pr = await requestWithAuthRetry(profile, () => netJson(`${base}/projects/${encodeURIComponent(`${identity.owner}/${identity.repo}`)}/merge_requests/${identity.number}`, { + headers: providerHeaders(profile), + })); + } catch (secondError) { + throw preferAccessError(firstError, secondError); + } } const headers = providerHeaders(profile); @@ -1264,6 +1760,7 @@ async function fetchCompatibleSnapshot(identity) { const files = rawFiles.map(normalizeFile); const reviews = rawReviews.map((item) => normalizeReview(item)); const headSha = pr.head?.sha || pr.head_sha || pr.sha || pr.diff_refs?.head_sha || ''; + const reviewSummary = summarizeReviews(reviews); return { identity: { providerId: profile.id, @@ -1272,6 +1769,7 @@ async function fetchCompatibleSnapshot(identity) { repo: identity.repo, number: identity.number, }, + queueOrigin: identity.queueOrigin || 'direct', url: pr.html_url || pr.web_url || identity.url || `${normalizeBaseUrl(profile.webBaseUrl)}/${identity.owner}/${identity.repo}/pull/${identity.number}`, title: pr.title || `#${identity.number}`, body: pr.body || pr.description || '', @@ -1286,20 +1784,46 @@ async function fetchCompatibleSnapshot(identity) { files, checks: [], reviews, - reviewSummary: summarizeReviews(reviews), + reviewSummary, + mergeReadiness: { + state: pr.merged || pr.merged_at ? 'merged' : (String(pr.state || pr.status || '').toLowerCase() === 'closed' ? 'closed' : 'open'), + isDraft: Boolean(pr.draft || pr.work_in_progress), + headSha, + expectedHeadSha: headSha, + mergeability: 'unknown', + checkState: 'missing', + reviewState: summarizeReviewState(reviewSummary), + permissionState: hasToken(profile) ? 'ready' : 'missing', + blockers: ['lifecycleActionUnsupported'], + warnings: [], + canMerge: false, + refreshedAt: new Date().toISOString(), + }, providerCapabilities: { publishSummaryComment: true, publishInlineComment: true, publishReviewDecision: false, + refreshMergeReadiness: false, + transitionDraftState: false, + mergePullRequest: false, + closePullRequest: false, + reopenPullRequest: false, }, }; } async function fetchSnapshot(identity) { const profile = profileById(identity.providerId); - return profile.kind === 'github' - ? fetchGithubSnapshot(identity) - : fetchCompatibleSnapshot(identity); + try { + return profile.kind === 'github' + ? fetchGithubSnapshot(identity) + : fetchCompatibleSnapshot(identity); + } catch (error) { + if ([401, 403, 404].includes(Number(error?.status || 0))) { + throw sourceAccessError(identity, error); + } + throw error; + } } async function listRepositoryPullRequests(subscription) { @@ -1314,8 +1838,8 @@ async function listRepositoryPullRequests(subscription) { headers: providerHeaders(profile), })); } catch (error) { - if (Number(error?.status || 0) === 404) { - throw new Error(`${t('errorRepoNotFound')} (${subscription.owner}/${subscription.repo})`); + if ([401, 403, 404].includes(Number(error?.status || 0))) { + throw sourceAccessError(subscription, error); } throw error; } @@ -1326,16 +1850,21 @@ async function listRepositoryPullRequests(subscription) { repo: subscription.repo, number: Number(row.number || row.iid || row.id), url: row.html_url || row.web_url || '', + queueOrigin: 'watch', })).filter((row) => Number.isFinite(row.number)); } try { raw = await requestWithAuthRetry(profile, () => netJson(`${base}/repos/${ownerRepo}/pulls?state=open&per_page=20`, { headers: providerHeaders(profile), })); - } catch { - raw = await requestWithAuthRetry(profile, () => netJson(`${base}/projects/${encodeURIComponent(`${subscription.owner}/${subscription.repo}`)}/merge_requests?state=opened&per_page=20`, { - headers: providerHeaders(profile), - })); + } catch (firstError) { + try { + raw = await requestWithAuthRetry(profile, () => netJson(`${base}/projects/${encodeURIComponent(`${subscription.owner}/${subscription.repo}`)}/merge_requests?state=opened&per_page=20`, { + headers: providerHeaders(profile), + })); + } catch (secondError) { + throw sourceAccessError(subscription, preferAccessError(firstError, secondError)); + } } const rows = Array.isArray(raw) ? raw : raw?.values || raw?.items || []; return rows.slice(0, 20).map((row) => ({ @@ -1345,6 +1874,7 @@ async function listRepositoryPullRequests(subscription) { repo: subscription.repo, number: Number(row.number || row.iid || row.id), url: row.html_url || row.web_url || '', + queueOrigin: 'watch', })).filter((row) => Number.isFinite(row.number)); } @@ -1355,31 +1885,57 @@ async function listReviewRequested(profile) { const headers = providerHeaders(profile); const query = encodeURIComponent('is:pr is:open review-requested:@me archived:false'); const result = await netJson(`${base}/search/issues?q=${query}&per_page=20`, { headers }); - return (result.items || []).map((item) => parsePrUrl(item.html_url)).filter(Boolean); + return (result.items || []) + .map((item) => parsePrUrl(item.html_url)) + .filter(Boolean) + .map((item) => ({ ...item, queueOrigin: 'assigned' })); } try { const result = await netJson(`${normalizeBaseUrl(profile.apiBaseUrl)}/user/review_requests`, { headers: providerHeaders(profile), }); - return (Array.isArray(result) ? result : result?.items || []).map((item) => parsePrUrl(item.html_url || item.web_url || item.url)).filter(Boolean); + return (Array.isArray(result) ? result : result?.items || []) + .map((item) => parsePrUrl(item.html_url || item.web_url || item.url)) + .filter(Boolean) + .map((item) => ({ ...item, queueOrigin: 'assigned' })); } catch { return []; } } -function mergeItems(nextItems) { +function isOpenReviewItem(item) { + const stateName = String(item?.state || '').toLowerCase(); + return !item?.merged && (stateName === 'open' || stateName === 'opened' || !stateName); +} + +function mergeItems(nextItems, { dropMissing = false } = {}) { const previous = new Map(state.data.items.map((item) => [itemKey(item), item])); const merged = new Map(); for (const item of nextItems) { - merged.set(itemKey(item), { ...previous.get(itemKey(item)), ...item, stale: false }); + const existing = previous.get(itemKey(item)); + merged.set(itemKey(item), { + ...existing, + ...item, + queueOrigin: item.queueOrigin || existing?.queueOrigin || 'direct', + stale: false, + }); } for (const item of state.data.items) { - if (!merged.has(itemKey(item))) merged.set(itemKey(item), { ...item, stale: true }); + if (merged.has(itemKey(item))) continue; + if (!dropMissing || item.queueOrigin === 'direct') { + merged.set(itemKey(item), { ...item, stale: dropMissing }); + } } state.data.items = Array.from(merged.values()).sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))); + if (state.data.selectedKey && !state.data.items.some((item) => itemKey(item) === state.data.selectedKey)) { + state.data.selectedKey = null; + state.data.selectedFilePath = null; + resetSelectedPrTransientUi(); + } if (!state.data.selectedKey && state.data.items[0]) { state.data.selectedKey = itemKey(state.data.items[0]); state.data.selectedFilePath = state.data.items[0].files?.[0]?.path || null; + resetSelectedPrTransientUi(); } } @@ -1412,15 +1968,49 @@ async function notifyNewWork(previousItems, nextItems) { } } } - for (const notice of notifications.slice(0, 4)) { + if (!notifications.length) return; + for (const notice of notifications) { + notified.add(notice.key); + } + state.data.notifiedKeys = Array.from(notified).slice(-200); + if (shouldSuppressSystemNotification()) { + return; + } + const digest = buildNotificationDigest(notifications); + for (const notice of digest) { try { await app.notifications.system(notice.title, notice.body); - notified.add(notice.key); } catch { break; } } - state.data.notifiedKeys = Array.from(notified).slice(-200); +} + +function shouldSuppressSystemNotification() { + return document.visibilityState === 'visible' || document.hasFocus(); +} + +function buildNotificationDigest(notifications) { + const newItems = notifications.filter((notice) => notice.key.startsWith('new:')); + const newHeads = notifications.filter((notice) => notice.key.startsWith('head:')); + const digest = []; + if (newItems.length === 1) digest.push(newItems[0]); + if (newItems.length > 1) { + digest.push({ + key: 'new:digest', + title: t('newPrBatchTitle'), + body: t('newPrBatchBody', { count: newItems.length }), + }); + } + if (newHeads.length === 1) digest.push(newHeads[0]); + if (newHeads.length > 1) { + digest.push({ + key: 'head:digest', + title: t('newHeadTitle'), + body: t('newHeadBatchBody', { count: newHeads.length }), + }); + } + return digest.slice(0, 2); } async function syncQueue(mode = state.data.queueMode) { @@ -1444,20 +2034,24 @@ async function syncQueue(mode = state.data.queueMode) { try { const previousItems = [...state.data.items]; const identities = []; + const sourceErrors = []; + let refreshedSourceCount = 0; if (mode === 'all') { for (const subscription of subscriptions) { try { identities.push(...await listRepositoryPullRequests(subscription)); + refreshedSourceCount += 1; } catch (error) { - state.ui.error = String(error?.message || error); + sourceErrors.push(error); } } } else { for (const item of state.data.profiles.filter((profileItem) => profileItem.enabled && hasToken(profileItem))) { try { identities.push(...await listReviewRequested(item)); + refreshedSourceCount += 1; } catch (error) { - state.ui.error = String(error?.message || error); + sourceErrors.push(sourceAccessError(item, error)); } } } @@ -1465,14 +2059,31 @@ async function syncQueue(mode = state.data.queueMode) { const snapshots = []; for (const identity of Array.from(unique.values()).slice(0, 30)) { try { - snapshots.push(await fetchSnapshot(identity)); + const snapshot = await fetchSnapshot(identity); + if (isOpenReviewItem(snapshot)) snapshots.push(snapshot); } catch (error) { - state.ui.error = String(error?.message || error); + sourceErrors.push(error); } } - mergeItems(snapshots); + const hadSourceError = sourceErrors.length > 0; + mergeItems(snapshots, { dropMissing: !hadSourceError }); await notifyNewWork(previousItems, snapshots); - await finish('statusReady'); + if (hadSourceError) { + state.ui.busy = null; + if (refreshedSourceCount > 0) { + state.ui.error = null; + state.ui.status = t('statusPartialSync', { count: sourceErrors.length }); + } else { + state.ui.error = String(sourceErrors[0]?.message || sourceErrors[0]); + state.ui.status = null; + } + state.ui.reviewProgress = null; + state.ui.cancelReviewRequested = false; + await saveStorage(); + render({ preservePaneScroll: true }); + } else { + await finish('statusReady'); + } resetPollTimer(); } catch (error) { setError(error); @@ -1494,6 +2105,7 @@ async function openDirectUrl() { const byKey = new Map(state.data.items.map((item) => [itemKey(item), item])); byKey.set(itemKey(snapshot), snapshot); state.data.items = Array.from(byKey.values()); + if (state.data.selectedKey !== itemKey(snapshot)) resetSelectedPrTransientUi(); state.data.selectedKey = itemKey(snapshot); state.data.selectedFilePath = snapshot.files?.[0]?.path || null; state.ui.activeProviderId = identity.providerId; @@ -1512,6 +2124,50 @@ function selectedDraft() { return snapshot ? state.data.drafts[snapshotKey(snapshot)] || null : null; } +function unpublishedDraftOperations(draft) { + return (draft?.operations || []).filter((operation) => !operation.published); +} + +function publishedReviewContextFor(snapshot) { + const key = snapshotKey(snapshot); + const context = state.data.publishedReviewContext?.[key]; + return Array.isArray(context) ? context.slice(0, 20) : []; +} + +function recordPublishedReviewContext(snapshot, draft, results) { + const key = snapshotKey(snapshot); + const byOperationId = new Map((results || []).map((result) => [result.operationId, result])); + const publishedItems = (draft?.operations || []) + .filter((operation) => { + const result = byOperationId.get(operation.id); + return result?.status === 'success' && result.providerOperationId !== 'skipped'; + }) + .map((operation) => ({ + id: operation.id, + kind: operation.kind, + path: operation.path || '', + position: operation.position || null, + body: String(operation.body || '').slice(0, 1200), + decision: operation.decision || '', + headSha: draft.headSha || snapshot.headSha || '', + publishedAt: new Date().toISOString(), + })); + if (!publishedItems.length) return; + const previous = publishedReviewContextFor(snapshot); + state.data.publishedReviewContext = { + ...(state.data.publishedReviewContext || {}), + [key]: [...publishedItems, ...previous].slice(0, 30), + }; +} + +function resetSelectedPrTransientUi() { + state.ui.filesExpanded = false; + state.ui.focusedDiffPath = null; + state.ui.focusedDiffPosition = null; + state.ui.manualCommentDraft = ''; + state.ui.manualCommentExpanded = false; +} + function recommendMode(snapshot) { const lines = snapshot.files.reduce((sum, file) => sum + file.additions + file.deletions, 0); const security = snapshot.files.some((file) => /auth|permission|crypto|secret|token|security/i.test(file.path)); @@ -1521,9 +2177,26 @@ function recommendMode(snapshot) { return 'fast_check'; } +function reviewLanguageName() { + return state.data.reviewLanguage === 'zh' ? 'Simplified Chinese' : 'English'; +} + function localSummary(snapshot) { const lines = snapshot.files.reduce((sum, file) => sum + file.additions + file.deletions, 0); const topFiles = snapshot.files.slice(0, 8).map((file) => `- ${file.path} (+${file.additions}/-${file.deletions})`).join('\n'); + if (state.data.reviewLanguage === 'zh') { + return [ + `Review 草稿:"${snapshot.title}"。`, + '', + `变更文件:${snapshot.files.length}。变更行数:${lines}。`, + snapshot.checks.length ? `CI:${snapshot.checks.map((check) => `${check.name}:${check.conclusion || check.status}`).join(', ')}` : 'CI:代码平台未返回状态。', + '', + '建议关注:', + topFiles || '- 代码平台未返回可审核文件。', + '', + '发布前请编辑确认。', + ].join('\n'); + } return [ `Review draft for "${snapshot.title}".`, '', @@ -1545,12 +2218,23 @@ function reviewPrompt(snapshot, mode) { deletions: file.deletions, patch: (file.patch || '').slice(0, mode === 'deep_review' ? 12000 : 5000), })); + const previousPublishedFindings = publishedReviewContextFor(snapshot).map((item) => ({ + kind: item.kind, + path: item.path, + position: item.position, + body: item.body, + decision: item.decision, + headSha: item.headSha, + })); return [ 'You are reviewing a pull request. Return JSON only with actionable review items.', + `Write all review comments in ${reviewLanguageName()}.`, 'Do not create a general summary comment. The reviewed author does not need a recap unless there is a principle-level concern about the PR direction.', 'Use summaryComment only when the issue is a principle-level PR direction concern that cannot be tied to a specific file or diff line.', - `Depth: ${modeLabel(mode)}. Prefer concrete functionality direction, implementation risks, and missing tests.`, - 'Schema: {"findings":[{"path":"src/file.ts","position":12,"body":"specific issue"}],"summaryComment":"","decision":"comment","decisionBody":""}.', + `Depth: ${modeLabel(mode)}. Prefer concrete functionality direction, implementation risks, missing tests, and a practical suggested fix for each issue.`, + 'Previous published review comments are provided as context. Do not repeat previous published review comments as new findings.', + 'You may disagree with or refine those earlier comments when the current diff or discussion shows they were wrong, outdated, resolved, or need follow-up.', + 'Schema: {"findings":[{"path":"src/file.ts","position":12,"body":"specific issue","suggestion":"how to fix it"}],"summaryComment":"","decision":"comment","decisionBody":""}.', 'Use a 1-based diff position only when you can identify it from the patch. Omit findings that are not supported by the diff.', '', JSON.stringify({ @@ -1561,6 +2245,7 @@ function reviewPrompt(snapshot, mode) { head: snapshot.headBranch, ci: snapshot.checks, existingReviews: snapshot.reviews.slice(0, 12), + previousPublishedFindings, files, }, null, 2), ].join('\n'); @@ -1583,6 +2268,13 @@ function normalizeDecision(value) { return ['approve', 'request_changes', 'comment'].includes(value) ? value : 'comment'; } +function formatFindingBody(body, suggestion) { + const issue = String(body || '').trim(); + const fix = String(suggestion || '').trim(); + if (!fix) return issue; + return `${issue}\n\n${state.data.reviewLanguage === 'zh' ? '建议处理' : 'Suggested fix'}: ${fix}`; +} + function buildReviewOperations(snapshot, aiText, mode) { const parsed = extractJsonObject(aiText); const timestamp = snapshot.headSha || Date.now(); @@ -1606,6 +2298,7 @@ function buildReviewOperations(snapshot, aiText, mode) { for (const finding of findings.slice(0, 12)) { const path = String(finding?.path || '').trim(); const body = String(finding?.body || '').trim(); + const suggestion = String(finding?.suggestion || finding?.suggestedFix || '').trim(); const position = Number(finding?.position || 0); if (!path || !body || !validPaths.has(path) || !Number.isFinite(position) || position <= 0) { continue; @@ -1615,15 +2308,16 @@ function buildReviewOperations(snapshot, aiText, mode) { kind: 'inline_comment', path, position, - body, + body: formatFindingBody(body, suggestion), selected: true, stale: false, published: false, }); } + const hasInlineFindings = operations.some((operation) => operation.kind === 'inline_comment'); const decisionBody = String(parsed.decisionBody || '').trim(); - if (decisionBody) { + if (decisionBody && !hasInlineFindings) { operations.push({ id: `decision-${timestamp}`, kind: 'review_decision', @@ -1652,9 +2346,26 @@ function buildReviewOperations(snapshot, aiText, mode) { return operations; } +function requestGenerateDraft() { + const snapshot = selectedSnapshot(); + if (!snapshot) return; + const draft = selectedDraft(); + const unpublished = unpublishedDraftOperations(draft); + if (unpublished.length) { + state.ui.draftOverwriteConfirm = { + key: snapshotKey(snapshot), + count: unpublished.length, + }; + render(); + return; + } + void generateDraft(); +} + async function generateDraft() { const snapshot = selectedSnapshot(); if (!snapshot) return; + state.ui.draftOverwriteConfirm = null; state.ui.cancelReviewRequested = false; setBusy('draft', 'statusGenerating'); try { @@ -1696,7 +2407,7 @@ async function generateDraft() { operations: buildReviewOperations(snapshot, reviewText, mode), }; state.data.drafts[snapshotKey(snapshot)] = draft; - await finish('statusReady'); + await finish('statusReady', { preservePaneScroll: true }); } catch (error) { setError(error); } @@ -1725,6 +2436,7 @@ async function addManualComment() { published: false, }); state.data.drafts[key] = draft; + state.ui.manualCommentDraft = ''; input.value = ''; await finish('statusSaved'); } @@ -1879,17 +2591,274 @@ async function confirmPublish() { message: result.message || null, timestamp: new Date().toISOString(), })); + recordPublishedReviewContext(snapshot, draft, results); state.data.audit = [...auditEntries, ...state.data.audit].slice(0, 50); - state.data.lastReviewedHeads[snapshotKey(snapshot)] = snapshot.headSha; + const publishedCount = results.filter((result) => result.status === 'success').length; + if (publishedCount > 0) { + state.data.lastReviewedHeads[snapshotKey(snapshot)] = snapshot.headSha; + } state.ui.confirm = null; - await finish('statusPublished'); + if (publishedCount > 0) { + await finish('statusPublished'); + } else { + await saveStorage(); + setError(t('statusPublishFailed')); + } +} + +function upsertSnapshot(snapshot) { + if (!snapshot) return; + const byKey = new Map(state.data.items.map((item) => [itemKey(item), item])); + byKey.set(snapshotKey(snapshot), { + ...byKey.get(snapshotKey(snapshot)), + ...snapshot, + stale: false, + }); + state.data.items = Array.from(byKey.values()).sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))); + state.data.selectedKey = snapshotKey(snapshot); + state.data.selectedFilePath = state.data.selectedFilePath || snapshot.files?.[0]?.path || null; } -async function markReviewed() { +function githubGraphqlUrl(profile) { + const base = normalizeBaseUrl(profile.apiBaseUrl); + if (base === 'https://api.github.com') return `${base}/graphql`; + if (base.endsWith('/api/v3')) return `${base.slice(0, -'/api/v3'.length)}/api/graphql`; + return `${base}/graphql`; +} + +async function githubGraphql(profile, query, variables = {}) { + const result = await netJson(githubGraphqlUrl(profile), { + method: 'POST', + headers: providerHeaders(profile, true), + body: JSON.stringify({ query, variables }), + }); + if (Array.isArray(result?.errors) && result.errors.length) { + const message = result.errors.map((error) => error.message).filter(Boolean).join('; '); + throw new Error(`${t('errorNetwork')}: ${message || 'GraphQL error'}`); + } + return result?.data || {}; +} + +async function refreshSelectedLifecycleStatus() { const snapshot = selectedSnapshot(); if (!snapshot) return; - state.data.lastReviewedHeads[snapshotKey(snapshot)] = snapshot.headSha; - await finish('statusReviewed'); + setBusy('lifecycle-refresh', 'refreshingLifecycle'); + try { + const fresh = await fetchSnapshot(snapshot.identity); + upsertSnapshot(fresh); + await finish('statusReady'); + } catch (error) { + setError(error); + } +} + +function lifecycleActionBlockers(action, snapshot) { + const caps = snapshot?.providerCapabilities || {}; + const readiness = snapshot?.mergeReadiness || {}; + if (action === 'merge') { + if (!caps.mergePullRequest) return ['lifecycleActionUnsupported']; + const blockers = [...(readiness.blockers || ['lifecycleUnknown'])]; + if (!hasToken(profileById(snapshot?.identity?.providerId)) && !blockers.includes('lifecycleBlockToken')) { + blockers.push('lifecycleBlockToken'); + } + return blockers; + } + if (!caps.transitionDraftState) return ['lifecycleActionUnsupported']; + const blockers = []; + if (readiness.state === 'merged') blockers.push('lifecycleBlockMerged'); + if (readiness.state === 'closed') blockers.push('lifecycleBlockClosed'); + if (!readiness.headSha && !snapshot?.headSha) blockers.push('lifecycleBlockHead'); + if (!snapshot?.nodeId) blockers.push('lifecycleUnknown'); + if (!hasToken(profileById(snapshot.identity.providerId))) blockers.push('lifecycleBlockToken'); + return blockers; +} + +function hardLifecycleBlockers(blockers) { + return blockers.filter((key) => key !== 'lifecycleBlockToken'); +} + +function lifecycleButtonTitle(action, blockers, profile) { + const unique = Array.from(new Set(blockers || [])); + if (unique.includes('lifecycleBlockToken')) { + const authHint = profile?.kind === 'github' ? t('lifecycleAutoAuthHint') : t('lifecycleGuideToken'); + const other = unique.filter((key) => key !== 'lifecycleBlockToken').map((key) => t(key)); + return [authHint, ...other].filter(Boolean).join(' · '); + } + if (unique.length) return unique.map((key) => t(key)).join(' · '); + if (action === 'ready') return t('lifecycleConfirmReadyBody'); + if (action === 'draft') return t('lifecycleConfirmDraftBody'); + return t('lifecycleConfirmMergeBody'); +} + +function lifecycleActionLabel(action) { + if (action === 'ready') return t('lifecycleActionReady'); + if (action === 'draft') return t('lifecycleActionDraft'); + if (action === 'merge') return t('lifecycleActionMerge'); + return action; +} + +function lifecycleConfirmTitleKey(action) { + if (action === 'ready') return 'lifecycleConfirmReadyTitle'; + if (action === 'draft') return 'lifecycleConfirmDraftTitle'; + return 'lifecycleConfirmMergeTitle'; +} + +function lifecycleConfirmBodyKey(action) { + if (action === 'ready') return 'lifecycleConfirmReadyBody'; + if (action === 'draft') return 'lifecycleConfirmDraftBody'; + return 'lifecycleConfirmMergeBody'; +} + +async function requestLifecycleAction(action) { + const snapshot = selectedSnapshot(); + if (!snapshot) return; + const profile = profileById(snapshot.identity.providerId); + if (!profile || !await ensureProfileToken(profile)) { + state.ui.settingsOpen = true; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = t('lifecycleAuthFailed'); + render(); + return; + } + setBusy('lifecycle-check', 'refreshingLifecycle'); + try { + const fresh = await fetchSnapshot(snapshot.identity); + upsertSnapshot(fresh); + if (snapshot.headSha && fresh.headSha && snapshot.headSha !== fresh.headSha) { + state.ui.lifecycleConfirm = null; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = t('lifecycleHeadChanged'); + render(); + return; + } + const blockers = lifecycleActionBlockers(action, fresh); + if (blockers.length) { + state.ui.lifecycleConfirm = null; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = action === 'merge' + ? t('lifecycleMergeBlocked', { reason: blockers.map((key) => t(key)).join(', ') }) + : blockers.map((key) => t(key)).join(', '); + render(); + return; + } + state.ui.busy = null; + state.ui.status = null; + state.ui.error = null; + state.ui.lifecycleConfirm = { + action, + identity: fresh.identity, + title: fresh.title, + owner: fresh.identity.owner, + repo: fresh.identity.repo, + number: fresh.identity.number, + baseBranch: fresh.baseBranch, + headBranch: fresh.headBranch, + expectedHeadSha: fresh.headSha, + warnings: action === 'merge' ? (fresh.mergeReadiness?.warnings || []) : [], + defaultMergeMethod: 'merge', + }; + render(); + } catch (error) { + setError(error); + } +} + +async function transitionGithubDraftState(profile, snapshot, action) { + const mutation = action === 'ready' + ? `mutation MarkPullRequestReady($pullRequestId: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { + pullRequest { isDraft } + } + }` + : `mutation ConvertPullRequestToDraft($pullRequestId: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { + pullRequest { isDraft } + } + }`; + await githubGraphql(profile, mutation, { pullRequestId: snapshot.nodeId }); +} + +async function mergeGithubPullRequest(profile, snapshot, mergeMethod, expectedHeadSha) { + const base = normalizeBaseUrl(profile.apiBaseUrl); + const ownerRepo = `${encodeURIComponent(snapshot.identity.owner)}/${encodeURIComponent(snapshot.identity.repo)}`; + return netJson(`${base}/repos/${ownerRepo}/pulls/${snapshot.identity.number}/merge`, { + method: 'PUT', + headers: providerHeaders(profile, true), + body: JSON.stringify({ + sha: expectedHeadSha, + merge_method: mergeMethod, + }), + }); +} + +async function confirmLifecycleAction() { + const confirm = state.ui.lifecycleConfirm; + if (!confirm) return; + const profile = profileById(confirm.identity.providerId); + const mergeMethod = document.getElementById('merge-method')?.value || confirm.defaultMergeMethod || 'merge'; + setBusy('lifecycle', 'lifecycleUpdating'); + try { + const fresh = await fetchSnapshot(confirm.identity); + upsertSnapshot(fresh); + if (confirm.expectedHeadSha && fresh.headSha && confirm.expectedHeadSha !== fresh.headSha) { + state.ui.lifecycleConfirm = null; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = t('lifecycleHeadChanged'); + render(); + return; + } + const blockers = lifecycleActionBlockers(confirm.action, fresh); + if (blockers.length) { + state.ui.lifecycleConfirm = null; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = confirm.action === 'merge' + ? t('lifecycleMergeBlocked', { reason: blockers.map((key) => t(key)).join(', ') }) + : blockers.map((key) => t(key)).join(', '); + render(); + return; + } + if (confirm.action === 'merge') { + await mergeGithubPullRequest(profile, fresh, mergeMethod, confirm.expectedHeadSha); + } else { + await transitionGithubDraftState(profile, fresh, confirm.action); + } + const auditEntry = { + id: `${snapshotKey(fresh)}:lifecycle:${confirm.action}:${Date.now()}`, + providerId: profile.id, + owner: fresh.identity.owner, + repo: fresh.identity.repo, + number: fresh.identity.number, + actionType: `lifecycle:${confirm.action}`, + actionLabel: lifecycleActionLabel(confirm.action), + status: 'success', + headSha: confirm.expectedHeadSha, + timestamp: new Date().toISOString(), + }; + state.data.audit = [auditEntry, ...state.data.audit].slice(0, 50); + state.ui.lifecycleConfirm = null; + try { + upsertSnapshot(await fetchSnapshot(fresh.identity)); + } catch { + upsertSnapshot({ + ...fresh, + merged: confirm.action === 'merge' ? true : fresh.merged, + isDraft: confirm.action === 'draft' ? true : confirm.action === 'ready' ? false : fresh.isDraft, + }); + } + await finish(confirm.action === 'merge' + ? 'lifecycleMergeDone' + : confirm.action === 'draft' + ? 'lifecycleDraftDone' + : 'lifecycleReadyDone'); + } catch (error) { + state.ui.lifecycleConfirm = null; + setError(error); + } } function resetPollTimer() { @@ -1904,77 +2873,203 @@ function resetPollTimer() { function renderStatus() { if (state.ui.error) return `
    ${esc(state.ui.error)}
    `; - if (state.ui.status) return `
    ${esc(state.ui.status)}
    `; + if (state.ui.status) { + const busyClass = state.ui.startupSyncing || state.ui.busy === 'refresh' ? ' pr-status--busy' : ''; + return `
    ${esc(state.ui.status)}
    `; + } return ''; } +function shouldShowShellStatus() { + return Boolean(state.ui.status && (state.ui.startupSyncing || state.ui.busy === 'refresh')); +} + +function directOpenBusyReason() { + if (!state.ui.busy) return ''; + if (state.ui.busy === 'refresh') return t('directOpenBusySync'); + if (state.ui.busy === 'draft') return t('directOpenBusyReview'); + if (state.ui.busy === 'publish' || state.ui.busy === 'stale-check') return t('directOpenBusyPublish'); + if (String(state.ui.busy).startsWith('lifecycle')) return t('directOpenBusyLifecycle'); + return t('directOpenBusyGeneric'); +} + +function busyActionReason(action) { + if (!state.ui.busy) return ''; + if (action === 'open-direct') return directOpenBusyReason(); + if (state.ui.busy === 'refresh') return t('busyActionSync'); + if (state.ui.busy === 'draft') return t('busyActionReview'); + if (state.ui.busy === 'publish' || state.ui.busy === 'stale-check') return t('busyActionPublish'); + if (String(state.ui.busy).startsWith('lifecycle')) return t('busyActionLifecycle'); + return t('busyActionGeneric'); +} + +function actionAvailabilityAttrs(disabledReason = '', enabledTitle = '') { + const title = disabledReason || enabledTitle; + const attrs = [`aria-disabled="${disabledReason ? 'true' : 'false'}"`]; + if (title) attrs.push(`title="${esc(title)}"`); + if (disabledReason) attrs.push(`data-disabled-reason="${esc(disabledReason)}"`); + return attrs.join(' '); +} + +function disabledActionAttrs(disabledReason = '') { + return actionAvailabilityAttrs(disabledReason); +} + +function busyActionAttrs(action) { + return disabledActionAttrs(busyActionReason(action)); +} + +function renderShellStatus() { + if (!shouldShowShellStatus()) return ''; + return `
    ${renderStatus()}
    `; +} + function renderCommandBar() { - const profile = activeProfile(); + const activeRepoCount = activeSubscriptions().length; + const modeLabelText = state.data.queueMode === 'mine' ? t('syncMineTitle') : t('syncAllTitle'); return ` -
    +
    PR

    ${esc(t('title'))}

    -

    ${esc(t('subtitle'))}

    +

    ${esc(modeLabelText)} · ${esc(activeRepoCount ? t('autoSync') : t('queueEmpty'))}

    -
    -
    - ${esc(t('repositoryFirst'))} - ${esc(t('repositoryFirstHint'))} -
    -
    - - - -
    +
    + +
    -
    -
    - ${esc(t('singlePrFallback'))} - ${esc(t('singlePrFallbackHint'))} -
    -
    - - -
    +
    + `; +} + +function renderWatchRepositoryCard() { + return ` +
    +
    + ${esc(t('repositoryFirst'))} + ${esc(t('repositoryFirstHint'))} +
    +
    + + + +
    +
    + `; +} + +function renderDirectOpenCard() { + const disabledAttrs = busyActionAttrs('open-direct'); + return ` +
    +
    + ${esc(t('singlePrFallback'))} + ${esc(t('singlePrFallbackHint'))} +
    +
    + + +
    +
    + `; +} + +function renderAccessCard() { + const profile = activeProfile(); + return ` +
    +
    + ${esc(t('privateAction'))} + ${esc(t('authAutoHint'))}
    -
    -
    - ${esc(t('privateAction'))} - ${esc(t('authAutoHint'))} +
    + + ${profile?.kind === 'github' ? `` : ''} + ${esc(hasToken(profile) ? t('tokenReady') : t('tokenMissing'))} +
    +
    + ${esc(t('manualToken'))} + +
    +
    + `; +} + +function renderQueueSettingsCard() { + const mode = state.data.queueMode; + return ` +
    +
    + ${esc(t('queueSettingsTitle'))} + ${esc(t('queueSettingsHint'))} +
    +
    + + +
    +
    + + +
    +
    + `; +} + +function renderSettingsModal() { + if (!state.ui.settingsOpen) return ''; + return ` +
    +
    +
    + +
    + +
    `; } @@ -1983,8 +3078,7 @@ function renderQueuePanel() { const activeRepoCount = activeSubscriptions().length; return `
    - +
    ${state.data.subscriptions.length ? state.data.subscriptions.map(renderSubscriptionRow).join('') : `
    ${esc(t('noWatchedRepos'))}
    `} @@ -2134,28 +3210,14 @@ function renderProviderForm() { function renderInboxItem(item) { const key = itemKey(item); - const checks = item.checks || []; - const failed = checks.some((check) => ['failure', 'failed', 'error', 'timed_out'].includes(String(check.conclusion || check.status).toLowerCase())); - const ok = checks.length && !failed; - const lines = item.files.reduce((sum, file) => sum + file.additions + file.deletions, 0); - const providerName = profileById(item.identity.providerId)?.displayName || item.identity.providerId; - const excerpt = textSnippet(item.body || item.reviewSummary || '', 132); + const updatedAt = item.updatedAt || item.createdAt; return ` - @@ -2163,7 +3225,109 @@ function renderInboxItem(item) { } function renderDraftStateChip(item) { - return `${esc(t(item.isDraft ? 'draftStatus' : 'readyStatus'))}`; + return `${esc(t(item.isDraft ? 'prDraftStatus' : 'readyForReviewStatus'))}`; +} + +function lifecycleValueLabel(kind, value) { + if (kind === 'state') { + if (value === 'merged') return t('lifecycleMerged'); + if (value === 'closed') return t('lifecycleClosed'); + return t('lifecycleOpen'); + } + if (kind === 'permission') return value === 'ready' ? t('lifecycleTokenReady') : t('lifecycleTokenMissing'); + if (kind === 'checks') { + if (value === 'passing') return t('lifecycleChecksPassing'); + if (value === 'pending') return t('lifecycleChecksPending'); + if (value === 'failing') return t('lifecycleChecksFailing'); + if (value === 'missing') return t('lifecycleChecksMissing'); + return t('lifecycleUnknown'); + } + if (kind === 'reviews') { + if (value === 'approved') return t('lifecycleReviewApproved'); + if (value === 'changes_requested') return t('lifecycleReviewChanges'); + if (value === 'commented') return t('lifecycleReviewCommented'); + return t('lifecycleReviewMissing'); + } + if (kind === 'mergeability') { + if (value === 'mergeable') return t('lifecycleMergeable'); + if (value === 'blocked') return t('lifecycleBlocked'); + if (value === 'checking') return t('lifecycleChecking'); + return t('lifecycleUnknown'); + } + return value || t('lifecycleUnknown'); +} + +function lifecycleTone(kind, value) { + if (kind === 'state') return value === 'open' ? 'is-ok' : value === 'merged' ? 'is-ready' : 'is-bad'; + if (kind === 'permission') return value === 'ready' ? 'is-ok' : 'is-warn'; + if (kind === 'checks') return value === 'passing' ? 'is-ok' : value === 'failing' ? 'is-bad' : 'is-warn'; + if (kind === 'reviews') return value === 'approved' ? 'is-ok' : value === 'changes_requested' ? 'is-bad' : 'is-warn'; + if (kind === 'mergeability') return value === 'mergeable' ? 'is-ok' : value === 'blocked' ? 'is-bad' : 'is-warn'; + return ''; +} + +function renderLifecycleSignal(labelKey, kind, value) { + return ` + + ${esc(t(labelKey))}: ${esc(lifecycleValueLabel(kind, value))} + + `; +} + +function renderMergeReadinessPanel(snapshot) { + const caps = snapshot.providerCapabilities || {}; + const readiness = snapshot.mergeReadiness || {}; + const transitionAction = snapshot.isDraft ? 'ready' : 'draft'; + const transitionBlockers = lifecycleActionBlockers(transitionAction, snapshot); + const blockers = lifecycleActionBlockers('merge', snapshot); + const transitionHardBlockers = hardLifecycleBlockers(transitionBlockers); + const mergeHardBlockers = hardLifecycleBlockers(blockers); + const canTransition = Boolean(caps.transitionDraftState && !transitionHardBlockers.length && !state.ui.busy); + const canMerge = Boolean(caps.mergePullRequest && !mergeHardBlockers.length && !state.ui.busy); + const profile = profileById(snapshot.identity.providerId); + const busyReason = busyActionReason('request-lifecycle'); + const transitionTitle = lifecycleButtonTitle(transitionAction, transitionBlockers, profile); + const mergeTitle = lifecycleButtonTitle('merge', blockers, profile); + const transitionDisabledReason = canTransition ? '' : (busyReason || transitionTitle); + const mergeDisabledReason = canMerge ? '' : (busyReason || mergeTitle); + const headLabel = readiness.headSha || snapshot.headSha + ? t('lifecycleHeadReady', { sha: shortSha(readiness.headSha || snapshot.headSha) }) + : t('lifecycleHeadMissing'); + return ` +
    +
    +
    +

    ${esc(t('lifecyclePanel'))}

    +

    ${esc(headLabel)} · ${esc(t('lifecyclePanelHint'))}

    +
    + +
    +
    + ${renderLifecycleSignal('lifecycleState', 'state', readiness.state || 'open')} + ${renderLifecycleSignal('lifecycleChecks', 'checks', readiness.checkState || 'missing')} + ${renderLifecycleSignal('lifecycleReviews', 'reviews', readiness.reviewState || 'missing')} + ${renderLifecycleSignal('lifecycleMergeability', 'mergeability', readiness.mergeability || 'unknown')} +
    + ${caps.refreshMergeReadiness ? '' : `
    ${esc(t('lifecycleUnsupported'))}
    `} +
    + + +
    +
    + `; +} + +function renderFoldSummary(title, meta = '') { + return ` + + ${esc(title)} + ${meta ? `${esc(meta)}` : ''} + + `; } function renderOverviewSection(snapshot) { @@ -2171,12 +3335,26 @@ function renderOverviewSection(snapshot) { const summary = textSnippet(body, 160) || t('overviewHint'); return `
    - ${esc(t('overview'))}${esc(summary)} + ${renderFoldSummary(t('overview'), summary)}
    ${esc(body)}
    `; } +function renderReviewingBanner() { + if (state.ui.busy !== 'draft') return ''; + const progress = state.ui.reviewProgress; + return ` +
    +
    + ${esc(t('reviewingBannerTitle'))} + ${esc(progress?.detail || t('reviewingBannerBody'))} +
    + +
    + `; +} + function renderReviewWorkspace() { const snapshot = selectedSnapshot(); if (!snapshot) { @@ -2204,8 +3382,7 @@ function renderReviewWorkspace() {
    - - +
    @@ -2214,17 +3391,19 @@ function renderReviewWorkspace() {
    ${summary.comments}${esc(t('existingReview'))}
    ${snapshot.checks.length}${esc(t('ciDetails'))}
    + ${renderReviewingBanner()} + ${renderMergeReadinessPanel(snapshot)} ${renderOverviewSection(snapshot)} ${renderFilesExplorer(snapshot)}
    - ${esc(t('ciDetails'))}${esc(t('ciFolded'))} + ${renderFoldSummary(t('ciDetails'), t('ciFreshnessHint'))} ${renderChecks(snapshot.checks)}
    -
    -

    ${esc(t('existingReview'))}

    +
    + ${renderFoldSummary(t('existingReview'), String(summary.comments))} ${renderReviews(snapshot.reviews)} - ${renderManualComment()} -
    + + ${renderManualComment()} `; } @@ -2244,12 +3423,10 @@ function renderFilesExplorer(snapshot) { `; } + const filesOpen = state.ui.filesExpanded || Boolean(focusedPosition); return ` -
    -
    -

    ${esc(t('files'))}

    - ${files.length} -
    +
    + ${renderFoldSummary(t('files'), `${files.length} · ${activeFile.path}`)}
    -
    + `; } +function checkStateTone(check) { + const value = String(check?.conclusion || check?.status || '').toLowerCase(); + if (['success', 'completed', 'neutral', 'skipped'].includes(value)) return 'is-ok'; + if (['failure', 'failed', 'error', 'timed_out', 'cancelled', 'action_required'].includes(value)) return 'is-bad'; + if (['pending', 'queued', 'in_progress', 'requested', 'waiting', 'expected'].includes(value)) return 'is-warn'; + return ''; +} + +function checkStateLabel(check) { + return String(check?.conclusion || check?.status || 'unknown'); +} + function renderChecks(checks) { if (!checks.length) return `
    ${esc(t('noCi'))}
    `; - return `
    ${checks.map((check) => ` -
    - ${esc(check.name)} - ${esc(check.conclusion || check.status)} -
    - `).join('')}
    `; + return `
    ${checks.map((check) => { + const tone = checkStateTone(check); + const label = checkStateLabel(check); + return ` +
    + +
    + ${esc(check.name || 'check')} + ${esc(label)} +
    + ${check.url ? `${esc(t('ciOpenDetails'))}` : `${esc(label)}`} +
    + `; + }).join('')}
    `; } function renderReviews(reviews) { @@ -2301,21 +3498,41 @@ function renderReviews(reviews) { } function renderManualComment() { + const value = state.ui.manualCommentDraft || ''; return ` -
    - - - -
    +
    +
    + + +
    + + +
    `; } +function shouldShowComposer() { + if (state.ui.startupSyncing) return false; + return state.ui.busy === 'draft' || Boolean(selectedDraft()); +} + +function renderComposerPlaceholder() { + return ''; +} + function renderComposer() { const snapshot = selectedSnapshot(); const draft = selectedDraft(); const selected = selectedOperations(draft).length; + const reviewing = state.ui.busy === 'draft'; + const startReviewDisabledReason = snapshot ? busyActionReason('start-review') : t('noPr'); + const publishDisabledReason = !draft + ? t('publishNoDraftItems') + : !selected + ? t('publishSelectItemFirst') + : busyActionReason('request-publish'); return ` -