Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ pub struct UpdateSessionModelRequest {
pub model_name: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSessionTitleRequest {
pub session_id: String,
pub title: String,
pub workspace_path: Option<String>,
#[serde(default)]
pub remote_connection_id: Option<String>,
#[serde(default)]
pub remote_ssh_host: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartDialogTurnRequest {
Expand Down Expand Up @@ -278,6 +290,51 @@ pub async fn update_session_model(
.map_err(|e| format!("Failed to update session model: {}", e))
}

#[tauri::command]
pub async fn update_session_title(
coordinator: State<'_, Arc<ConversationCoordinator>>,
app_state: State<'_, AppState>,
request: UpdateSessionTitleRequest,
) -> Result<String, String> {
let session_id = request.session_id.trim();
if session_id.is_empty() {
return Err("session_id is required".to_string());
}

if coordinator
.get_session_manager()
.get_session(session_id)
.is_none()
{
let workspace_path = request
.workspace_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
"workspace_path is required when the session is not loaded".to_string()
})?;

let effective = desktop_effective_session_storage_path(
&app_state,
workspace_path,
request.remote_connection_id.as_deref(),
request.remote_ssh_host.as_deref(),
)
.await;

coordinator
.restore_session(&effective, session_id)
.await
.map_err(|e| format!("Failed to restore session before renaming: {}", e))?;
}

coordinator
.update_session_title(session_id, &request.title)
.await
.map_err(|e| format!("Failed to update session title: {}", e))
}

/// Load the session into the coordinator process when it exists on disk but is not in memory.
/// Uses the same remote→local session path mapping as `restore_session`.
#[tauri::command]
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ pub async fn run() {
theme::show_main_window,
api::agentic_api::create_session,
api::agentic_api::update_session_model,
api::agentic_api::update_session_title,
api::agentic_api::ensure_coordinator_session,
api::agentic_api::start_dialog_turn,
api::agentic_api::compact_session,
Expand Down
127 changes: 78 additions & 49 deletions src/crates/core/src/agentic/coordination/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,21 +124,23 @@ impl ConversationCoordinator {
let workspace_path = config.workspace_path.as_ref()?;
let path_buf = PathBuf::from(workspace_path);

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 let Some(rid) = identity.remote_connection_id.as_deref() {
let connection_name = crate::service::remote_ssh::workspace_state::lookup_remote_connection_with_hint(
let identity =
crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity(
workspace_path,
Some(rid),
config.remote_connection_id.as_deref(),
config.remote_ssh_host.as_deref(),
)
.await
.map(|e| e.connection_name)
.unwrap_or_else(|| rid.to_string());
.await?;

if let Some(rid) = identity.remote_connection_id.as_deref() {
let connection_name =
crate::service::remote_ssh::workspace_state::lookup_remote_connection_with_hint(
workspace_path,
Some(rid),
)
.await
.map(|e| e.connection_name)
.unwrap_or_else(|| rid.to_string());

return Some(WorkspaceBinding::new_remote(
None,
Expand Down Expand Up @@ -577,7 +579,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
use crate::agentic::core::SessionConfig;
use crate::agentic::persistence::PersistenceManager;
use crate::infrastructure::PathManager;
use crate::service::session::{DialogTurnData, SessionMetadata, SessionStatus, UserMessageData};
use crate::service::session::{
DialogTurnData, SessionMetadata, SessionStatus, UserMessageData,
};

let path_manager = match PathManager::new() {
Ok(pm) => std::sync::Arc::new(pm),
Expand Down Expand Up @@ -1413,37 +1417,36 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
let eq = self.event_queue.clone();
let sid = session_id.clone();
let msg = original_user_input;
let expected_title = self
.session_manager
.get_session(&session_id)
.map(|session| session.session_name)
.unwrap_or_default();
tokio::spawn(async move {
let enabled = match crate::service::config::get_global_config_service().await {
Ok(svc) => svc
.get_config::<bool>(Some(
"app.ai_experience.enable_session_title_generation",
))
.await
.unwrap_or(true),
Err(_) => true,
};
if !enabled {
return;
}
match sm.generate_session_title(&msg, Some(20)).await {
Ok(title) => {
if let Err(e) = sm.update_session_title(&sid, &title).await {
debug!("Failed to persist auto-generated title: {e}");
}
let allow_ai = is_ai_session_title_generation_enabled().await;
let resolved = sm.resolve_session_title(&msg, Some(20), allow_ai).await;

match sm
.update_session_title_if_current(&sid, &expected_title, &resolved.title)
.await
{
Ok(true) => {
let _ = eq
.enqueue(
AgenticEvent::SessionTitleGenerated {
session_id: sid,
title,
method: "ai".to_string(),
title: resolved.title,
method: resolved.method.as_str().to_string(),
},
Some(EventPriority::Normal),
)
.await;
}
Err(e) => {
debug!("Auto session title generation failed: {e}");
Ok(false) => {
debug!("Skipped auto session title update because title changed");
}
Err(error) => {
debug!("Auto session title generation failed to apply: {error}");
}
}
});
Expand Down Expand Up @@ -2077,32 +2080,48 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
user_message: &str,
max_length: Option<usize>,
) -> BitFunResult<String> {
let title = self
let allow_ai = is_ai_session_title_generation_enabled().await;
let resolved = self
.session_manager
.generate_session_title(user_message, max_length)
.await?;
.resolve_session_title(user_message, max_length, allow_ai)
.await;

if let Err(e) = self
.session_manager
.update_session_title(session_id, &title)
.await
{
debug!("Failed to persist generated title: {e}");
}
self.session_manager
.update_session_title(session_id, &resolved.title)
.await?;

let event = AgenticEvent::SessionTitleGenerated {
session_id: session_id.to_string(),
title: title.clone(),
method: "ai".to_string(),
title: resolved.title.clone(),
method: resolved.method.as_str().to_string(),
};
self.emit_event(event).await;

debug!(
"Session title generation event sent: session_id={}, title={}",
session_id, title
session_id, resolved.title
);

Ok(title)
Ok(resolved.title)
}

pub async fn update_session_title(
&self,
session_id: &str,
title: &str,
) -> BitFunResult<String> {
let normalized = title.trim().to_string();
if normalized.is_empty() {
return Err(BitFunError::validation(
"Session title must not be empty".to_string(),
));
}

self.session_manager
.update_session_title(session_id, &normalized)
.await?;

Ok(normalized)
}

/// Emit event
Expand Down Expand Up @@ -2159,6 +2178,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
}
}

async fn is_ai_session_title_generation_enabled() -> bool {
match crate::service::config::get_global_config_service().await {
Ok(service) => service
.get_config::<bool>(Some("app.ai_experience.enable_session_title_generation"))
.await
.unwrap_or(true),
Err(_) => true,
}
}

// Global coordinator singleton
static GLOBAL_COORDINATOR: OnceLock<Arc<ConversationCoordinator>> = OnceLock::new();

Expand Down
Loading
Loading