diff --git a/src/apps/desktop/src/api/btw_api.rs b/src/apps/desktop/src/api/btw_api.rs index 62b35c2a..c49652f5 100644 --- a/src/apps/desktop/src/api/btw_api.rs +++ b/src/apps/desktop/src/api/btw_api.rs @@ -1,7 +1,7 @@ //! BTW (side question) API //! //! Desktop adapter for the core side-question service: -//! - Reads current session context (no new dialog turn, no persistence writes) +//! - Reads current session context without mutating the parent session //! - Streams answer via `btw://...` events //! - Supports cancellation by request id @@ -14,8 +14,10 @@ use crate::api::app_state::AppState; use bitfun_core::agentic::coordination::ConversationCoordinator; use bitfun_core::agentic::side_question::{ - SideQuestionService, SideQuestionStreamEvent, SideQuestionStreamRequest, + SideQuestionPersistTarget, SideQuestionService, SideQuestionStreamEvent, + SideQuestionStreamRequest, }; +use std::path::PathBuf; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -44,6 +46,10 @@ pub struct BtwAskStreamRequest { pub model_id: Option, /// Limit how many context messages are included (from the end). pub max_context_messages: Option, + pub child_session_id: Option, + pub workspace_path: Option, + pub parent_dialog_turn_id: Option, + pub parent_turn_index: Option, } #[derive(Debug, Clone, Serialize)] @@ -135,6 +141,20 @@ pub async fn btw_ask_stream( question: request.question.clone(), model_id: request.model_id.clone(), max_context_messages: request.max_context_messages, + persist_target: match (&request.child_session_id, &request.workspace_path) { + (Some(child_session_id), Some(workspace_path)) + if !child_session_id.trim().is_empty() && !workspace_path.trim().is_empty() => + { + Some(SideQuestionPersistTarget { + child_session_id: child_session_id.clone(), + workspace_path: PathBuf::from(workspace_path), + parent_session_id: request.session_id.clone(), + parent_dialog_turn_id: request.parent_dialog_turn_id.clone(), + parent_turn_index: request.parent_turn_index, + }) + } + _ => None, + }, }) .await .map_err(|e| e.to_string())?; diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index d9ccd5c3..10c5b43c 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -1008,6 +1008,40 @@ pub async fn get_recent_workspaces( .collect()) } +#[tauri::command] +pub async fn cleanup_invalid_workspaces( + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + match state.workspace_service.cleanup_invalid_workspaces().await { + Ok(removed_count) => { + if let Some(workspace_info) = state.workspace_service.get_current_workspace().await { + apply_active_workspace_context(&state, &app, &workspace_info).await; + } else { + clear_active_workspace_context(&state, &app).await; + } + + if let Err(e) = state + .workspace_identity_watch_service + .sync_watched_workspaces() + .await + { + warn!( + "Failed to sync workspace identity watchers after workspace cleanup: {}", + e + ); + } + + info!("Invalid workspaces cleaned up: removed_count={}", removed_count); + Ok(removed_count) + } + Err(e) => { + error!("Failed to cleanup invalid workspaces: {}", e); + Err(format!("Failed to cleanup invalid workspaces: {}", e)) + } + } +} + #[tauri::command] pub async fn get_opened_workspaces( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index edc514a8..3c7702fd 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -550,6 +550,7 @@ pub async fn run() { subscribe_config_updates, get_model_configs, get_recent_workspaces, + cleanup_invalid_workspaces, get_opened_workspaces, open_workspace, create_assistant_workspace, diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 0c0983db..769d1323 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -1814,6 +1814,32 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &self.session_manager } + /// Persist a completed `/btw` side-question turn into an existing child session. + pub async fn persist_btw_turn( + &self, + workspace_path: &Path, + child_session_id: &str, + request_id: &str, + question: &str, + full_text: &str, + parent_session_id: &str, + parent_dialog_turn_id: Option<&str>, + parent_turn_index: Option, + ) -> BitFunResult<()> { + self.session_manager + .persist_btw_turn( + workspace_path, + child_session_id, + request_id, + question, + full_text, + parent_session_id, + parent_dialog_turn_id, + parent_turn_index, + ) + .await + } + /// Set global coordinator (called during initialization) /// /// Skips if global coordinator already exists diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 537b0f30..5c7fff36 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -25,6 +25,7 @@ const JSON_WRITE_MAX_RETRIES: usize = 5; const JSON_WRITE_RETRY_BASE_DELAY_MS: u64 = 30; static JSON_FILE_WRITE_LOCKS: OnceLock>>>> = OnceLock::new(); +static SESSION_INDEX_LOCKS: OnceLock>>>> = OnceLock::new(); #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredSessionMetadataFile { @@ -285,6 +286,16 @@ impl PersistenceManager { .clone() } + async fn get_session_index_lock(&self, workspace_path: &Path) -> Arc> { + let index_path = self.index_path(workspace_path); + let registry = SESSION_INDEX_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut registry_guard = registry.lock().await; + registry_guard + .entry(index_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!( @@ -468,7 +479,7 @@ impl PersistenceManager { } } - async fn rebuild_index(&self, workspace_path: &Path) -> BitFunResult> { + async fn rebuild_index_locked(&self, workspace_path: &Path) -> BitFunResult> { let sessions_root = self.ensure_project_sessions_dir(workspace_path).await?; let mut metadata_list = Vec::new(); let mut entries = fs::read_dir(&sessions_root) @@ -515,7 +526,7 @@ impl PersistenceManager { Ok(metadata_list) } - async fn upsert_index_entry( + async fn upsert_index_entry_locked( &self, workspace_path: &Path, metadata: &SessionMetadata, @@ -548,7 +559,7 @@ impl PersistenceManager { self.write_json_atomic(&index_path, &index).await } - async fn remove_index_entry( + async fn remove_index_entry_locked( &self, workspace_path: &Path, session_id: &str, @@ -568,6 +579,32 @@ impl PersistenceManager { self.write_json_atomic(&index_path, &index).await } + async fn rebuild_index(&self, workspace_path: &Path) -> BitFunResult> { + let lock = self.get_session_index_lock(workspace_path).await; + let _guard = lock.lock().await; + self.rebuild_index_locked(workspace_path).await + } + + async fn upsert_index_entry( + &self, + workspace_path: &Path, + metadata: &SessionMetadata, + ) -> BitFunResult<()> { + let lock = self.get_session_index_lock(workspace_path).await; + let _guard = lock.lock().await; + self.upsert_index_entry_locked(workspace_path, metadata).await + } + + async fn remove_index_entry( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { + let lock = self.get_session_index_lock(workspace_path).await; + let _guard = lock.lock().await; + self.remove_index_entry_locked(workspace_path, session_id).await + } + pub async fn list_session_metadata( &self, workspace_path: &Path, @@ -576,15 +613,28 @@ impl PersistenceManager { return Ok(Vec::new()); } + let lock = self.get_session_index_lock(workspace_path).await; + let _guard = lock.lock().await; let index_path = self.index_path(workspace_path); if let Some(index) = self .read_json_optional::(&index_path) .await? { + let has_stale_entry = index + .sessions + .iter() + .any(|metadata| !self.metadata_path(workspace_path, &metadata.session_id).exists()); + if has_stale_entry { + warn!( + "Session index contains stale entries, rebuilding: {}", + index_path.display() + ); + return self.rebuild_index_locked(workspace_path).await; + } return Ok(index.sessions); } - self.rebuild_index(workspace_path).await + self.rebuild_index_locked(workspace_path).await } pub async fn save_session_metadata( @@ -944,6 +994,16 @@ impl PersistenceManager { workspace_path: &Path, turn: &DialogTurnData, ) -> BitFunResult<()> { + let mut metadata = self + .load_session_metadata(workspace_path, &turn.session_id) + .await? + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Session metadata not found: {}", + turn.session_id + )) + })?; + self.ensure_turns_dir(workspace_path, &turn.session_id) .await?; @@ -957,18 +1017,6 @@ impl PersistenceManager { ) .await?; - let mut metadata = self - .load_session_metadata(workspace_path, &turn.session_id) - .await? - .unwrap_or_else(|| { - SessionMetadata::new( - turn.session_id.clone(), - "New Session".to_string(), - "agentic".to_string(), - "default".to_string(), - ) - }); - let turns = self .load_session_turns(workspace_path, &turn.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 c27f4bc6..e563df4d 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -17,6 +17,7 @@ use crate::service::snapshot::ensure_snapshot_manager_for_workspace; use crate::util::errors::{BitFunError, BitFunResult}; use dashmap::DashMap; use log::{debug, error, info, warn}; +use serde_json::json; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime}; @@ -914,6 +915,95 @@ impl SessionManager { Ok(()) } + /// Persist a completed `/btw` side-question turn into an existing child session. + pub async fn persist_btw_turn( + &self, + workspace_path: &Path, + child_session_id: &str, + request_id: &str, + question: &str, + full_text: &str, + parent_session_id: &str, + parent_dialog_turn_id: Option<&str>, + parent_turn_index: Option, + ) -> BitFunResult<()> { + let session = self + .sessions + .get(child_session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", child_session_id)))?; + + let turn_id = format!("btw-turn-{}", request_id); + let user_message_id = format!("btw-user-{}", request_id); + let round_id = format!("btw-round-{}", request_id); + let text_id = format!("btw-text-{}", request_id); + let now = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let mut turn = DialogTurnData::new( + turn_id.clone(), + 0, + child_session_id.to_string(), + UserMessageData { + id: user_message_id, + content: question.to_string(), + timestamp: now, + metadata: Some(json!({ + "kind": "btw", + "parentSessionId": parent_session_id, + "parentRequestId": request_id, + "parentDialogTurnId": parent_dialog_turn_id, + "parentTurnIndex": parent_turn_index, + })), + }, + ); + turn.timestamp = now; + turn.start_time = now; + turn.end_time = Some(now); + turn.duration_ms = Some(0); + turn.status = TurnStatus::Completed; + turn.model_rounds = vec![ModelRoundData { + id: round_id, + turn_id: turn_id.clone(), + round_index: 0, + timestamp: now, + text_items: vec![TextItemData { + id: text_id, + content: full_text.to_string(), + is_streaming: false, + timestamp: now, + is_markdown: true, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + status: Some("completed".to_string()), + }], + tool_items: vec![], + thinking_items: vec![], + start_time: now, + end_time: Some(now), + status: "completed".to_string(), + }]; + + drop(session); + + self.persistence_manager + .save_dialog_turn(workspace_path, &turn) + .await?; + + if let Some(mut session) = self.sessions.get_mut(child_session_id) { + if !session.dialog_turn_ids.iter().any(|existing| existing == &turn_id) { + session.dialog_turn_ids.push(turn_id); + } + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } + + Ok(()) + } + // ============ Helper Methods ============ /// Get session's message history (complete) diff --git a/src/crates/core/src/agentic/side_question.rs b/src/crates/core/src/agentic/side_question.rs index 6d87d373..68c2f733 100644 --- a/src/crates/core/src/agentic/side_question.rs +++ b/src/crates/core/src/agentic/side_question.rs @@ -14,6 +14,7 @@ use crate::util::types::message::Message as AIMessage; use futures::StreamExt; use log::{debug, warn}; use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; @@ -280,6 +281,9 @@ Rules:\n\ let (tx, rx) = mpsc::unbounded_channel(); let request_id = request.request_id.clone(); let session_id = request.session_id.clone(); + let question = request.question.clone(); + let persist_target = request.persist_target.clone(); + let coordinator = self.coordinator.clone(); let runtime = self.runtime.clone(); tokio::spawn(async move { @@ -346,6 +350,27 @@ Rules:\n\ ); } + if let Some(target) = persist_target { + if let Err(error) = coordinator + .persist_btw_turn( + &target.workspace_path, + &target.child_session_id, + &request_id, + &question, + full_text.trim(), + &target.parent_session_id, + target.parent_dialog_turn_id.as_deref(), + target.parent_turn_index, + ) + .await + { + warn!( + "Failed to persist side-question turn: child_session_id={}, request_id={}, error={}", + target.child_session_id, request_id, error + ); + } + } + let _ = tx.send(SideQuestionStreamEvent::Completed { request_id, session_id, @@ -365,6 +390,16 @@ pub struct SideQuestionStreamRequest { pub question: String, pub model_id: Option, pub max_context_messages: Option, + pub persist_target: Option, +} + +#[derive(Debug, Clone)] +pub struct SideQuestionPersistTarget { + pub child_session_id: String, + pub workspace_path: PathBuf, + pub parent_session_id: String, + pub parent_dialog_turn_id: Option, + pub parent_turn_index: Option, } #[derive(Debug, Clone)] diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index e73f0a61..c2b3f800 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -726,7 +726,11 @@ impl WorkspaceService { manager.cleanup_invalid_workspaces().await }; - if result.is_ok() {} + if result.is_ok() { + if let Err(e) = self.save_workspace_data().await { + warn!("Failed to save workspace data after cleanup: {}", e); + } + } result } diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index f3b38905..02264d5d 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -175,7 +175,13 @@ const MainNav: React.FC = ({ }, 150); }, []); - const openWorkspaceMenu = useCallback(() => { + const openWorkspaceMenu = useCallback(async () => { + try { + await workspaceManager.cleanupInvalidWorkspaces(); + } catch (error) { + log.warn('Failed to cleanup invalid workspaces before opening workspace menu', { error }); + } + const rect = workspaceMenuButtonRef.current?.getBoundingClientRect(); if (!rect) return; setWorkspaceMenuPos({ @@ -191,7 +197,7 @@ const MainNav: React.FC = ({ closeWorkspaceMenu(); return; } - openWorkspaceMenu(); + void openWorkspaceMenu(); }, [closeWorkspaceMenu, openWorkspaceMenu, workspaceMenuOpen]); const setSessionMode = useSessionModeStore(s => s.setMode); diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index d1ef46bb..9928d767 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -646,11 +646,18 @@ $_section-header-height: 24px; transition: color $motion-fast $easing-standard, background $motion-fast $easing-standard; - &:hover { + &:hover:not(:disabled):not(.is-disabled) { color: var(--color-text-primary); background: var(--element-bg-soft); } + &.is-disabled, + &:disabled { + opacity: 0.45; + cursor: not-allowed; + color: var(--color-text-muted); + } + svg { flex-shrink: 0; } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 74734e7a..6393e319 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -369,10 +369,12 @@ const WorkspaceItem: React.FC = ({ {t('nav.workspaces.actions.deleteAssistant')} )} - + {workspace.workspaceKind !== WorkspaceKind.Assistant && ( + + )} , document.body )} @@ -403,7 +405,6 @@ const WorkspaceItem: React.FC = ({ confirmText={t('nav.workspaces.actions.deleteAssistant')} cancelText={t('actions.cancel')} confirmDanger - preview={`${t('nav.workspaces.deleteAssistantDialog.pathLabel')}\n${workspace.rootPath}`} /> = ({ workspacePath }) => resetPersonaFiles, } = useAgentIdentityDocument(workspacePath); const [editingField, setEditingField] = useState< - 'name' | 'body' | 'emoji' | 'creature' | 'vibe' | null + 'name' | 'emoji' | 'creature' | 'vibe' | null >(null); const [editValue, setEditValue] = useState(''); const nameInputRef = useRef(null); const metaInputRef = useRef(null); - const bodyTextareaRef = useRef(null); const [models, setModels] = useState([]); const [funcAgentModels, setFuncAgentModels] = useState>({}); @@ -452,14 +451,12 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => [identityDocument.creature, identityDocument.emoji, identityDocument.vibe, t] ); - const startEdit = (field: 'name' | 'body' | 'emoji' | 'creature' | 'vibe') => { + const startEdit = (field: 'name' | 'emoji' | 'creature' | 'vibe') => { setEditingField(field); const nextValue = field === 'name' ? identityDocument.name - : field === 'body' - ? identityDocument.body - : identityDocument[field]; + : identityDocument[field]; setEditValue(nextValue); setTimeout(() => { @@ -468,11 +465,6 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => return; } - if (field === 'body') { - bodyTextareaRef.current?.focus(); - return; - } - metaInputRef.current?.focus(); }, 10); }; @@ -480,18 +472,24 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => if (!editingField) return; if (editingField === 'name') { updateIdentityField('name', editValue.trim()); - } else if (editingField === 'body') { - updateIdentityField('body', editValue.replace(/\r\n/g, '\n')); } else { updateIdentityField(editingField, editValue.trim()); } setEditingField(null); }, [editValue, editingField, updateIdentityField]); const onEditKey = (e: React.KeyboardEvent) => { - if (editingField !== 'body' && e.key === 'Enter') commitEdit(); + if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingField(null); }; + const bodyUpdateTimerRef = useRef | null>(null); + const handleBodyChange = useCallback((newBody: string) => { + if (bodyUpdateTimerRef.current) clearTimeout(bodyUpdateTimerRef.current); + bodyUpdateTimerRef.current = setTimeout(() => { + updateIdentityField('body', newBody); + }, 600); + }, [updateIdentityField]); + const handleConfirmResetIdentity = useCallback(async () => { setEditingField(null); setEditValue(''); @@ -1056,43 +1054,19 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => })} - {/* Description + Radar side by side */} + {/* Description — IR Markdown editor */}
-
!editingField && startEdit('body')}> - {editingField === 'body' ? ( -