diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 79627c38..8a6d9faa 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -90,6 +90,8 @@ pub struct StartDialogTurnRequest { #[serde(default)] pub trigger_source: Option, #[serde(default)] + pub user_message_metadata: Option, + #[serde(default)] pub persist_agent_type: Option, #[serde(default)] pub image_contexts: Option>, @@ -495,6 +497,7 @@ pub async fn start_dialog_turn( workspace_path, turn_id, trigger_source, + user_message_metadata, persist_agent_type, image_contexts, } = request; @@ -516,7 +519,7 @@ pub async fn start_dialog_turn( }; let outcome = scheduler - .submit( + .submit_with_metadata( session_id, user_input, original_user_input, @@ -527,6 +530,7 @@ pub async fn start_dialog_turn( policy, None, resolved_images, + user_message_metadata, ) .await .map_err(|e| format!("Failed to start dialog turn: {}", e))?; diff --git a/src/apps/desktop/src/api/product_app_runtime_api.rs b/src/apps/desktop/src/api/product_app_runtime_api.rs index 2140fcdc..f9344246 100644 --- a/src/apps/desktop/src/api/product_app_runtime_api.rs +++ b/src/apps/desktop/src/api/product_app_runtime_api.rs @@ -2302,7 +2302,10 @@ mod tests { name: "Sample Agent".to_string(), description: "Sample private agent".to_string(), package_source: ComponentPackageSource::AppPrivate, - owner_app: None, + owner_app: Some(ComponentOwnerApp { + app_id: "sample-app".to_string(), + app_version: "1.0.0".to_string(), + }), capabilities: vec![CapabilityRef { id: "agent.run".to_string(), title: "Run agent".to_string(), diff --git a/src/apps/desktop/src/api/skill_api.rs b/src/apps/desktop/src/api/skill_api.rs index 80bee7af..dd20195d 100644 --- a/src/apps/desktop/src/api/skill_api.rs +++ b/src/apps/desktop/src/api/skill_api.rs @@ -29,7 +29,7 @@ use sparo_core::agentic::tools::implementations::skills::{ is_skill_enabled_for_agent, }, AgentSkillInfo, SkillCatalog, SkillData, SkillInfo, SkillLocation, SkillRegistry, - SkillSuiteInfo, + SkillSuiteInfo, SkillSuiteManifest, }; use sparo_core::infrastructure::get_path_manager_arc; use sparo_core::infrastructure::APP_HIDDEN_DIR_NAME; @@ -47,14 +47,40 @@ const MARKET_DESC_MAX_LEN: usize = 220; static MARKET_DESCRIPTION_CACHE: OnceLock>> = OnceLock::new(); -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct SkillValidationResult { +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SkillPackageKind { + Skill, + Suite, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillPackageValidationResult { pub valid: bool, + pub kind: Option, pub name: Option, pub description: Option, + pub member_count: Option, pub error: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddSkillPackageRequest { + pub source_path: String, + pub level: SkillLocation, + pub workspace_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSkillPackageRequest { + pub kind: SkillPackageKind, + pub key: String, + pub workspace_path: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillMarketListRequest { @@ -179,21 +205,25 @@ async fn get_agent_skill_infos_for_workspace_input( .await .map_err(|e| format!("Failed to load user skill overrides: {}", e))?; - let workspace_root = workspace_root_from_input(workspace_path) - .ok_or_else(|| "Project-level skill overrides require an open workspace".to_string())?; - let project_config = load_project_agent_skills_document_local(&workspace_root) - .await - .map_err(|e| format!("Failed to load project agent skills: {}", e))?; - let disabled_project: HashSet = - get_disabled_agent_skills_from_document(&project_config, agent_id) - .into_iter() - .collect(); - let disabled_project_suites: HashSet = - get_disabled_agent_skill_suites_from_document(&project_config, agent_id) - .into_iter() - .collect(); + let workspace_root = workspace_root_from_input(workspace_path); + let (disabled_project, disabled_project_suites) = if let Some(workspace_root) = &workspace_root + { + let project_config = load_project_agent_skills_document_local(workspace_root) + .await + .map_err(|e| format!("Failed to load project agent skills: {}", e))?; + ( + get_disabled_agent_skills_from_document(&project_config, agent_id) + .into_iter() + .collect::>(), + get_disabled_agent_skill_suites_from_document(&project_config, agent_id) + .into_iter() + .collect::>(), + ) + } else { + (HashSet::new(), HashSet::new()) + }; let resolved_skills = registry - .get_resolved_skills_for_workspace(Some(&workspace_root), Some(agent_id)) + .get_resolved_skills_for_workspace(workspace_root.as_deref(), Some(agent_id)) .await; let resolved_keys: HashSet = @@ -628,92 +658,193 @@ pub async fn replace_agent_skill_selection( )) } +fn invalid_skill_package(error: impl Into) -> SkillPackageValidationResult { + SkillPackageValidationResult { + valid: false, + kind: None, + name: None, + description: None, + member_count: None, + error: Some(error.into()), + } +} + #[tauri::command] -pub async fn validate_skill_path(path: String) -> Result { - use std::path::Path; +pub async fn validate_skill_package_path( + path: String, +) -> Result { + let package_path = Path::new(&path); - let skill_path = Path::new(&path); + if !package_path.exists() { + return Ok(invalid_skill_package("Path does not exist")); + } + if !package_path.is_dir() { + return Ok(invalid_skill_package("Path is not a directory")); + } - if !skill_path.exists() { - return Ok(SkillValidationResult { - valid: false, - name: None, - description: None, - error: Some("Path does not exist".to_string()), - }); + let skill_md_path = package_path.join("SKILL.md"); + let suite_manifest_path = package_path.join("suite.json"); + let has_skill = skill_md_path.is_file(); + let has_suite = suite_manifest_path.is_file(); + + if has_skill == has_suite { + return Ok(invalid_skill_package( + "Package must contain exactly one of SKILL.md or suite.json", + )); } - if !skill_path.is_dir() { - return Ok(SkillValidationResult { - valid: false, - name: None, - description: None, - error: Some("Path is not a directory".to_string()), - }); + if has_skill { + let content = match tokio::fs::read_to_string(&skill_md_path).await { + Ok(content) => content, + Err(error) => { + return Ok(invalid_skill_package(format!( + "Failed to read SKILL.md: {}", + error + ))) + } + }; + return match SkillData::from_markdown(path, &content, SkillLocation::User, false) { + Ok(data) => Ok(SkillPackageValidationResult { + valid: true, + kind: Some(SkillPackageKind::Skill), + name: Some(data.name), + description: Some(data.description), + member_count: None, + error: None, + }), + Err(error) => Ok(invalid_skill_package(error.to_string())), + }; } - let skill_md_path = skill_path.join("SKILL.md"); - if !skill_md_path.exists() { - return Ok(SkillValidationResult { - valid: false, - name: None, - description: None, - error: Some("Directory is missing SKILL.md file".to_string()), - }); + let manifest_content = match tokio::fs::read_to_string(&suite_manifest_path).await { + Ok(content) => content, + Err(error) => { + return Ok(invalid_skill_package(format!( + "Failed to read suite.json: {}", + error + ))) + } + }; + let manifest = match serde_json::from_str::(&manifest_content) { + Ok(manifest) => manifest, + Err(error) => { + return Ok(invalid_skill_package(format!( + "Failed to parse suite.json: {}", + error + ))) + } + }; + let folder_name = package_path.file_name().and_then(|name| name.to_str()); + if folder_name != Some(manifest.id.as_str()) { + return Ok(invalid_skill_package(format!( + "Suite id '{}' must match its folder name", + manifest.id + ))); + } + if manifest.name.trim().is_empty() || manifest.description.trim().is_empty() { + return Ok(invalid_skill_package( + "Suite name and description must not be empty", + )); + } + if let Some(router_path) = manifest.router_path.as_deref() { + if !package_path.join(router_path).is_file() { + return Ok(invalid_skill_package(format!( + "Suite router file '{}' does not exist", + router_path + ))); + } } - match tokio::fs::read_to_string(&skill_md_path).await { - Ok(content) => { - match SkillData::from_markdown(path.clone(), &content, SkillLocation::User, false) { - Ok(data) => Ok(SkillValidationResult { - valid: true, - name: Some(data.name), - description: Some(data.description), - error: None, - }), - Err(e) => Ok(SkillValidationResult { - valid: false, - name: None, - description: None, - error: Some(e.to_string()), - }), + let skills_dir = package_path.join("skills"); + let mut seen_member_ids = HashSet::new(); + for member in &manifest.members { + if !seen_member_ids.insert(member.skill_id.as_str()) { + return Ok(invalid_skill_package(format!( + "Suite member '{}' is declared more than once", + member.skill_id + ))); + } + let member_path = skills_dir.join(&member.skill_id); + let member_skill_path = member_path.join("SKILL.md"); + if !member_skill_path.is_file() { + if member.required { + return Ok(invalid_skill_package(format!( + "Required suite member '{}' is missing SKILL.md", + member.skill_id + ))); + } + continue; + } + let content = match tokio::fs::read_to_string(&member_skill_path).await { + Ok(content) => content, + Err(error) => { + return Ok(invalid_skill_package(format!( + "Failed to read member '{}': {}", + member.skill_id, error + ))) } + }; + if let Err(error) = SkillData::from_markdown( + member_path.to_string_lossy().to_string(), + &content, + SkillLocation::User, + false, + ) { + return Ok(invalid_skill_package(format!( + "Invalid suite member '{}': {}", + member.skill_id, error + ))); } - Err(e) => Ok(SkillValidationResult { - valid: false, - name: None, - description: None, - error: Some(format!("Failed to read SKILL.md: {}", e)), - }), } + + Ok(SkillPackageValidationResult { + valid: true, + kind: Some(SkillPackageKind::Suite), + name: Some(manifest.name), + description: Some(manifest.description), + member_count: Some(manifest.members.len()), + error: None, + }) } #[tauri::command] -pub async fn add_skill( +pub async fn add_skill_package( _state: State<'_, AppState>, - source_path: String, - level: String, - workspace_path: Option, + request: AddSkillPackageRequest, ) -> Result { - let validation = validate_skill_path(source_path.clone()).await?; + let validation = validate_skill_package_path(request.source_path.clone()).await?; if !validation.valid { - return Err(validation.error.unwrap_or("Invalid skill path".to_string())); + return Err(validation + .error + .unwrap_or_else(|| "Invalid Skill package".to_string())); } - let skill_name = validation + let package_name = validation .name .as_ref() - .ok_or_else(|| "Skill name missing after validation".to_string())?; - let source = Path::new(&source_path); - - let target_dir = if level == "project" { - if let Some(workspace_root) = workspace_root_from_input(workspace_path.as_deref()) { - workspace_root.join(APP_HIDDEN_DIR_NAME).join("skills") - } else { - return Err("No workspace open, cannot add project-level Skill".to_string()); - } - } else { - get_path_manager_arc().user_skills_dir() + .ok_or_else(|| "Package name missing after validation".to_string())?; + let package_kind = validation + .kind + .ok_or_else(|| "Package kind missing after validation".to_string())?; + let source = Path::new(&request.source_path); + let workspace_root = workspace_root_from_input(request.workspace_path.as_deref()); + let path_manager = get_path_manager_arc(); + + let target_dir = match (request.level, package_kind) { + (SkillLocation::User, SkillPackageKind::Skill) => path_manager.user_skills_dir(), + (SkillLocation::User, SkillPackageKind::Suite) => path_manager.user_skill_suites_dir(), + (SkillLocation::Project, SkillPackageKind::Skill) => workspace_root + .as_ref() + .map(|root| root.join(APP_HIDDEN_DIR_NAME).join("skills")) + .ok_or_else(|| { + "No workspace open, cannot add a project-level Skill package".to_string() + })?, + (SkillLocation::Project, SkillPackageKind::Suite) => workspace_root + .as_ref() + .map(|root| path_manager.project_skill_suites_dir(root)) + .ok_or_else(|| { + "No workspace open, cannot add a project-level Skill suite".to_string() + })?, }; if let Err(e) = tokio::fs::create_dir_all(&target_dir).await { @@ -729,31 +860,31 @@ pub async fn add_skill( if target_path.exists() { return Err(format!( - "Skill '{}' already exists in {} level directory", + "Skill package '{}' already exists in the {} library", folder_name, - if level == "project" { - "project" - } else { - "user" - } + request.level.as_str() )); } if let Err(e) = copy_dir_all(source, &target_path).await { - return Err(format!("Failed to copy skill folder: {}", e)); + return Err(format!("Failed to copy Skill package: {}", e)); } SkillRegistry::global() - .refresh_for_workspace(workspace_root_from_input(workspace_path.as_deref()).as_deref()) + .refresh_for_workspace(workspace_root.as_deref()) .await; info!( - "Skill added: name={}, level={}, path={}", - skill_name, - level, + "Skill package added: name={}, kind={:?}, level={}, path={}", + package_name, + package_kind, + request.level.as_str(), target_path.display() ); - Ok(format!("Skill '{}' added successfully", skill_name)) + Ok(format!( + "Skill package '{}' added successfully", + package_name + )) } async fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { @@ -776,32 +907,59 @@ async fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io:: } #[tauri::command] -pub async fn delete_skill( - _state: State<'_, AppState>, - skill_key: String, - workspace_path: Option, +pub async fn delete_skill_package( + state: State<'_, AppState>, + request: DeleteSkillPackageRequest, ) -> Result { let registry = SkillRegistry::global(); + let workspace_root = workspace_root_from_input(request.workspace_path.as_deref()); - let workspace_root = workspace_root_from_input(workspace_path.as_deref()); - let skill_info = registry - .find_skill_by_key_for_workspace(&skill_key, workspace_root.as_deref()) - .await - .ok_or_else(|| format!("Skill '{}' not found", skill_key))?; - - if !skill_info.can_delete { - return Err(format!( - "Skill '{}' is managed by Sparo OS and cannot be deleted", - skill_info.name - )); - } - - let skill_path = std::path::PathBuf::from(&skill_info.path); - - if skill_path.exists() { - if let Err(e) = tokio::fs::remove_dir_all(&skill_path).await { - return Err(format!("Failed to delete skill folder: {}", e)); + let (package_name, package_path) = match request.kind { + SkillPackageKind::Skill => { + let skill = registry + .find_skill_by_key_for_workspace(&request.key, workspace_root.as_deref()) + .await + .ok_or_else(|| format!("Skill '{}' not found", request.key))?; + if let Some(suite_id) = skill.suite_key.as_deref() { + return Err(format!( + "Skill '{}' belongs to suite '{}'; delete the suite package instead", + skill.name, suite_id + )); + } + if !skill.can_delete { + return Err(format!( + "Skill '{}' is managed by Sparo OS and cannot be deleted", + skill.name + )); + } + (skill.name, PathBuf::from(skill.path)) + } + SkillPackageKind::Suite => { + let catalog = get_skill_catalog_for_workspace_input( + &state, + registry, + request.workspace_path.as_deref(), + ) + .await?; + let suite = catalog + .suites + .into_iter() + .find(|suite| suite.key == request.key) + .ok_or_else(|| format!("Skill suite '{}' not found", request.key))?; + if !suite.can_delete { + return Err(format!( + "Skill suite '{}' is managed by Sparo OS and cannot be deleted", + suite.name + )); + } + (suite.name, PathBuf::from(suite.path)) } + }; + + if package_path.exists() { + tokio::fs::remove_dir_all(&package_path) + .await + .map_err(|error| format!("Failed to delete Skill package: {}", error))?; } registry @@ -809,11 +967,15 @@ pub async fn delete_skill( .await; info!( - "Skill deleted: key={}, path={}", - skill_key, - skill_path.display() + "Skill package deleted: key={}, kind={:?}, path={}", + request.key, + request.kind, + package_path.display() ); - Ok(format!("Skill '{}' deleted successfully", skill_info.name)) + Ok(format!( + "Skill package '{}' deleted successfully", + package_name + )) } #[tauri::command] diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index d4e84917..2dbf8a5b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -485,9 +485,9 @@ pub fn run() { set_agent_skill_disabled, set_agent_skill_suite_disabled, replace_agent_skill_selection, - validate_skill_path, - add_skill, - delete_skill, + validate_skill_package_path, + add_skill_package, + delete_skill_package, compute_diff, apply_patch, save_merged_diff_content, diff --git a/src/crates/core/src/agentic_os/work/service.rs b/src/crates/core/src/agentic_os/work/service.rs index 1a0a6ac4..198e2f52 100644 --- a/src/crates/core/src/agentic_os/work/service.rs +++ b/src/crates/core/src/agentic_os/work/service.rs @@ -186,6 +186,8 @@ pub struct ResolveComponentWorkRequest { pub primary_surface_policy: PrimarySurfacePolicy, #[serde(default, skip_serializing_if = "Option::is_none")] pub assignment: Option, + #[serde(default)] + pub app_refs: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -535,7 +537,7 @@ impl WorkService { title: request.title, objective: request.objective, subject, - app_refs: Vec::new(), + app_refs: request.app_refs, scope: request.scope, visibility: request.visibility, primary_surface_policy: request.primary_surface_policy, @@ -4982,6 +4984,13 @@ mod tests { "1.0.0", "D:/workspace/project/.sparo_os/components/agent-1", ); + let builder_app = WorkAppRef::native_app( + "app-builder", + "app-builder", + "core-app-builder", + "core-default", + "1", + ); let first = service .resolve_component_work(ResolveComponentWorkRequest { component: component.clone(), @@ -4994,6 +5003,11 @@ mod tests { visibility: WorkVisibility::Secondary, primary_surface_policy: PrimarySurfacePolicy::WorkCenter, assignment: None, + app_refs: vec![WorkAppRelation { + app: builder_app.clone(), + role: WorkAppRelationRole::Executor, + surface_id: None, + }], }) .await .expect("resolve component work"); @@ -5013,7 +5027,9 @@ mod tests { } ); assert!(first.work.references_component(&component)); - assert!(first.work.app_refs.is_empty()); + assert_eq!(first.work.app_refs.len(), 1); + assert_eq!(first.work.app_refs[0].app, builder_app); + assert_eq!(first.work.app_refs[0].role, WorkAppRelationRole::Executor); let second = service .resolve_component_work(ResolveComponentWorkRequest { @@ -5027,6 +5043,7 @@ mod tests { visibility: WorkVisibility::Primary, primary_surface_policy: PrimarySurfacePolicy::WorkCenter, assignment: None, + app_refs: Vec::new(), }) .await .expect("reuse component work"); @@ -5046,6 +5063,7 @@ mod tests { visibility: WorkVisibility::Secondary, primary_surface_policy: PrimarySurfacePolicy::WorkCenter, assignment: None, + app_refs: Vec::new(), }) .await .expect("resolve review component work"); @@ -5422,6 +5440,7 @@ mod tests { visibility: WorkVisibility::Secondary, primary_surface_policy: PrimarySurfacePolicy::WorkCenter, assignment: None, + app_refs: Vec::new(), }) .await .expect("resolve component work"); @@ -5650,6 +5669,7 @@ mod tests { visibility: WorkVisibility::Secondary, primary_surface_policy: PrimarySurfacePolicy::WorkCenter, assignment: None, + app_refs: Vec::new(), }) .await .expect("resolve component work"); diff --git a/src/crates/core/src/app_platform/private_components.rs b/src/crates/core/src/app_platform/private_components.rs index 6667db82..01c91603 100644 --- a/src/crates/core/src/app_platform/private_components.rs +++ b/src/crates/core/src/app_platform/private_components.rs @@ -13,11 +13,22 @@ pub struct ProductAppPrivateComponentRegistration { pub private_agent_component_ids: Vec, } +fn uses_packaged_private_implementation(component: &ComponentDefinition) -> bool { + component.owner_app.is_some() + && component + .implementation_ref + .as_deref() + .is_none_or(|implementation_ref| implementation_ref.starts_with("app://")) +} + pub fn private_component_source_dir( app: &ResolvedProductApp, component: &ComponentDefinition, ) -> CoreResult> { - if component.owner_app.is_none() { + // ownerApp describes catalog ownership. Explicit agent:// and other + // delegated implementations intentionally have no source/ directory in + // the app. app:// and legacy declarations without a ref remain packaged. + if !uses_packaged_private_implementation(component) { return Ok(None); } let package_dir = app.package_dir.as_ref().ok_or_else(|| { @@ -118,3 +129,49 @@ pub async fn register_private_product_app_runtime_components( private_agent_component_ids, }) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn private_agent(implementation_ref: &str) -> ComponentDefinition { + serde_json::from_value(json!({ + "id": "sample-agent", + "kind": "agent", + "name": "Sample Agent", + "description": "Sample app-private agent", + "packageSource": "appPrivate", + "ownerApp": { + "appId": "sample-app", + "appVersion": "1.0.0" + }, + "visibility": "appDependency", + "implementationRef": implementation_ref + })) + .expect("private agent definition") + } + + #[test] + fn delegated_private_agent_does_not_require_packaged_source() { + let component = private_agent("agent://Runno"); + + assert!(!uses_packaged_private_implementation(&component)); + } + + #[test] + fn app_private_agent_requires_packaged_source() { + let component = private_agent("app://sample-app@1.0.0/agents/sample-agent"); + + assert!(uses_packaged_private_implementation(&component)); + } + + #[test] + fn legacy_private_agent_without_ref_requires_packaged_source() { + let mut component = private_agent("agent://Runno"); + component.implementation_ref = None; + + assert!(uses_packaged_private_implementation(&component)); + } +} diff --git a/src/crates/core/src/app_platform/system_apps.rs b/src/crates/core/src/app_platform/system_apps.rs index 17bb140d..041ccb07 100644 --- a/src/crates/core/src/app_platform/system_apps.rs +++ b/src/crates/core/src/app_platform/system_apps.rs @@ -5,6 +5,7 @@ //! enabled, untouched official selection to the bundled release, but never //! replaces a user fork or re-enables a disabled slot. +use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; @@ -60,7 +61,11 @@ async fn load_shared_components<'a>( Ok(cache.as_deref().unwrap_or_default()) } -async fn reusable_embedded_system_release( +// Startup is a release consumer, not a publisher. Once a valid system Release +// owns an app/version identity, preserve it even when a development filesystem +// bundle has drifted. Direct imports still reject changed bytes at the same +// version, so authors must publish the bundle under a new version. +async fn reusable_system_release( revision_store: &AppRevisionStore, existing_releases: &[ReleaseRecord], source_identity: &[String], @@ -156,22 +161,10 @@ pub async fn seed_system_app_releases( let mut releases_reused = 0; for source in collect_package_sources(&SYSTEM_PRODUCT_APP_BUNDLES, &filesystem_root, APP_JSON) { let source_identity = package_source_segments(&source, 2, "Product App")?; - let filesystem_source_present = - filesystem_package_source_exists(&filesystem_root.join(&source)).await?; - let release = if !filesystem_source_present { - if let Some(release) = reusable_embedded_system_release( - revision_store, - &existing_releases, - &source_identity, - ) - .await? - { - release - } else { - let components = - load_shared_components(&mut shared_components, path_manager).await?; - import_system_app_release(revision_store, &source, components).await? - } + let release = if let Some(release) = + reusable_system_release(revision_store, &existing_releases, &source_identity).await? + { + release } else { let components = load_shared_components(&mut shared_components, path_manager).await?; import_system_app_release(revision_store, &source, components).await? @@ -663,10 +656,11 @@ async fn digest_directory(root: &Path, domain: &[u8]) -> CoreResult { })?; let relative = relative.to_string_lossy().replace('\\', "/"); let bytes = fs::read(&file).await?; + let bytes = canonical_system_package_bytes(&bytes); hasher.update((relative.len() as u64).to_le_bytes()); hasher.update(relative.as_bytes()); hasher.update((bytes.len() as u64).to_le_bytes()); - hasher.update(bytes); + hasher.update(bytes.as_ref()); } Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) } @@ -718,7 +712,13 @@ async fn copy_filesystem_tree(source: &Path, destination: &Path) -> CoreResult<( fs::create_dir(&destination_path).await?; pending.push((source_path, destination_path)); } else if metadata.is_file() { - fs::copy(source_path, destination_path).await?; + let bytes = fs::read(&source_path).await?; + fs::write( + &destination_path, + canonical_system_package_bytes(&bytes).as_ref(), + ) + .await?; + fs::set_permissions(&destination_path, metadata.permissions()).await?; } else { let _ = fs::remove_dir_all(destination).await; return Err(CoreError::validation(format!( @@ -753,12 +753,26 @@ async fn copy_embedded_tree(source: &'static Dir<'static>, destination: &Path) - file.path().display() )) })?; - fs::write(destination_dir.join(name), file.contents()).await?; + fs::write( + destination_dir.join(name), + canonical_system_package_bytes(file.contents()).as_ref(), + ) + .await?; } } Ok(()) } +fn canonical_system_package_bytes(bytes: &[u8]) -> Cow<'_, [u8]> { + let Ok(text) = std::str::from_utf8(bytes) else { + return Cow::Borrowed(bytes); + }; + if !bytes.windows(2).any(|window| window == b"\r\n") { + return Cow::Borrowed(bytes); + } + Cow::Owned(text.replace("\r\n", "\n").into_bytes()) +} + fn collect_package_sources( embedded_root: &'static Dir<'static>, filesystem_root: &Path, @@ -845,6 +859,120 @@ mod tests { PathManager::with_user_root_for_tests(temp.path().join("app-root")) } + #[test] + fn canonical_system_package_bytes_normalizes_only_utf8_line_endings() { + assert_eq!( + canonical_system_package_bytes(b"line one\r\nline two\r\n").as_ref(), + b"line one\nline two\n" + ); + assert_eq!( + canonical_system_package_bytes(b"line one\nline two\n").as_ref(), + b"line one\nline two\n" + ); + + let binary = b"\xff\r\n\x00"; + assert_eq!(canonical_system_package_bytes(binary).as_ref(), binary); + } + + #[tokio::test] + async fn filesystem_line_endings_reuse_embedded_system_release() { + let temp = TempDir::new().expect("temp dir"); + let store = AppRevisionStore::open(temp.path().join("store")) + .await + .expect("revision store"); + let relative = Path::new("app-builder/1.0.0"); + let missing_filesystem_root = temp.path().join("missing-bundles"); + + let embedded_staging = temp.path().join("embedded-staging"); + materialize_source( + relative, + &missing_filesystem_root, + &SYSTEM_PRODUCT_APP_BUNDLES, + &embedded_staging, + ) + .await + .expect("embedded package"); + let embedded_release = normalize_and_import_system_app(&store, &embedded_staging, &[]) + .await + .expect("embedded release"); + + let filesystem_root = temp.path().join("filesystem-bundles"); + let filesystem_source = filesystem_root.join(relative); + fs::create_dir_all(filesystem_source.parent().expect("package parent")) + .await + .expect("filesystem package parent"); + materialize_source( + relative, + &missing_filesystem_root, + &SYSTEM_PRODUCT_APP_BUNDLES, + &filesystem_source, + ) + .await + .expect("filesystem source package"); + let compatibility_path = filesystem_source.join("compatibility.json"); + let compatibility = fs::read_to_string(&compatibility_path) + .await + .expect("compatibility manifest"); + fs::write(&compatibility_path, compatibility.replace('\n', "\r\n")) + .await + .expect("CRLF compatibility manifest"); + + let filesystem_staging = temp.path().join("filesystem-staging"); + materialize_source( + relative, + &filesystem_root, + &SYSTEM_PRODUCT_APP_BUNDLES, + &filesystem_staging, + ) + .await + .expect("materialized filesystem package"); + let filesystem_release = normalize_and_import_system_app(&store, &filesystem_staging, &[]) + .await + .expect("filesystem release"); + + assert_eq!(filesystem_release.release_id, embedded_release.release_id); + assert_eq!(store.list_releases(Some("app-builder")).await.len(), 1); + } + + #[tokio::test] + async fn system_seed_preserves_existing_same_version_release() { + let temp = TempDir::new().expect("temp dir"); + let path_manager = test_path_manager(&temp); + let store = AppRevisionStore::open(path_manager.app_root()) + .await + .expect("revision store"); + let staging = temp.path().join("legacy-app-builder"); + materialize_source( + Path::new("app-builder/1.0.0"), + &temp.path().join("missing-bundles"), + &SYSTEM_PRODUCT_APP_BUNDLES, + &staging, + ) + .await + .expect("app builder package"); + fs::write( + staging.join("legacy-build.txt"), + b"different immutable bytes", + ) + .await + .expect("legacy marker"); + let existing = normalize_and_import_system_app(&store, &staging, &[]) + .await + .expect("existing release"); + + let seeded = seed_system_app_releases(&path_manager, &store) + .await + .expect("system seed"); + let activation = store + .get_active(&AppActivationScope::System, "app-builder") + .await + .expect("app builder activation"); + + assert_eq!(activation.active_release_id, existing.release_id); + assert!(seeded.releases_reused >= 1); + assert_eq!(store.list_releases(Some("app-builder")).await.len(), 1); + } + #[tokio::test] async fn read_only_component_listing_does_not_create_storage() { let temp = TempDir::new().expect("temp dir"); @@ -989,7 +1117,7 @@ mod tests { .await .expect("official release") .release; - let fast_path_release = reusable_embedded_system_release( + let fast_path_release = reusable_system_release( &store, &store.list_releases(None).await, &[ diff --git a/src/web-ui/src/app/agentic-os/work/data/workApi.ts b/src/web-ui/src/app/agentic-os/work/data/workApi.ts index 7aecdf5a..91e7e59e 100644 --- a/src/web-ui/src/app/agentic-os/work/data/workApi.ts +++ b/src/web-ui/src/app/agentic-os/work/data/workApi.ts @@ -1066,6 +1066,7 @@ export class AgenticOsWorkApi { visibility: request.visibility ?? 'secondary', primary_surface_policy: request.primarySurfacePolicy ?? 'work_center', assignment: toRawAssignment(request.assignment), + app_refs: (request.appRefs ?? []).map(toRawAppRelation), }, }); return { work: fromRawWorkRecord(response.work), created: response.created }; diff --git a/src/web-ui/src/app/agentic-os/work/domain/productAppLaunchPolicy.test.ts b/src/web-ui/src/app/agentic-os/work/domain/productAppLaunchPolicy.test.ts index 7dc450ca..4a99460e 100644 --- a/src/web-ui/src/app/agentic-os/work/domain/productAppLaunchPolicy.test.ts +++ b/src/web-ui/src/app/agentic-os/work/domain/productAppLaunchPolicy.test.ts @@ -5,6 +5,7 @@ import type { ProductAppLaunch, } from '@/infrastructure/api/service-api/AppCatalogAPI'; import { + catalogAppLaunchRequiresWorkConfirmation, getCatalogAppLaunchBehavior, getProductAppLaunchBehavior, resolveProductAppWorkScope, @@ -75,6 +76,20 @@ describe('Product App launch behavior', () => { })).supportsMultipleWorks).toBe(true); }); + it('confirms new Work details unless the app is a global singleton', () => { + expect(catalogAppLaunchRequiresWorkConfirmation(app({ + workMultiplicity: 'multiple', + }))).toBe(true); + expect(catalogAppLaunchRequiresWorkConfirmation(app({ + launch: { scopeRequirement: 'workspaceRequired' }, + workMultiplicity: 'singleton', + }))).toBe(true); + expect(catalogAppLaunchRequiresWorkConfirmation(app({ + launch: { scopeRequirement: 'workspaceOptional' }, + workMultiplicity: 'singleton', + }))).toBe(false); + }); + it('keeps singleton system-allowed apps in system scope', () => { expect(resolveProductAppWorkScope(app({ primarySurfaceMode: 'immersivePrimary', diff --git a/src/web-ui/src/app/agentic-os/work/domain/productAppLaunchPolicy.ts b/src/web-ui/src/app/agentic-os/work/domain/productAppLaunchPolicy.ts index 2de2d4e2..7bee400f 100644 --- a/src/web-ui/src/app/agentic-os/work/domain/productAppLaunchPolicy.ts +++ b/src/web-ui/src/app/agentic-os/work/domain/productAppLaunchPolicy.ts @@ -124,6 +124,14 @@ export function getProductAppLaunchBehavior( return getCatalogAppLaunchBehavior(app); } +/** Only a global singleton can launch without confirming a new Work scope. */ +export function catalogAppLaunchRequiresWorkConfirmation( + app: CatalogAppLaunchBehaviorInput | null | undefined +): boolean { + const behavior = getCatalogAppLaunchBehavior(app); + return behavior.supportsMultipleWorks || behavior.requiresWorkspace; +} + export function getNativeAppLaunchBehavior( app: Pick | null | undefined ): ProductAppLaunchBehavior { diff --git a/src/web-ui/src/app/agentic-os/work/domain/workTypes.ts b/src/web-ui/src/app/agentic-os/work/domain/workTypes.ts index 7db7bda4..e2a29df9 100644 --- a/src/web-ui/src/app/agentic-os/work/domain/workTypes.ts +++ b/src/web-ui/src/app/agentic-os/work/domain/workTypes.ts @@ -519,6 +519,7 @@ export interface ResolveComponentWorkRequest { visibility?: WorkVisibility; primarySurfacePolicy?: PrimarySurfacePolicy; assignment?: WorkAssignmentRef | null; + appRefs?: WorkAppRelation[]; } export interface LinkSessionToWorkRequest { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index 417dc249..decddc20 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -6,15 +6,8 @@ min-height: min(336px, calc(100vh - 168px)); } -.sparo-remote-connect-overlay { - animation: modal-overlay-fade 0.18s ease; - - .ds-dialog { - width: min(900px, 100%); - max-width: min(900px, 100%); - min-height: min(540px, calc(100vh - 48px)); - animation: sparo-remote-connect-dialog-enter 0.22s cubic-bezier(0.22, 1, 0.36, 1); - } +.sparo-remote-connect-window { + min-height: min(540px, calc(100vh - 48px)); } .sparo-remote-connect-modal__content { @@ -54,11 +47,11 @@ margin-inline-end: 24px; } -.sparo-remote-connect-overlay .ds-dialog__title-group { +.sparo-remote-connect-window .ds-app-window__title-group { width: 100%; } -.sparo-remote-connect-overlay .ds-dialog__title-extra { +.sparo-remote-connect-window .ds-app-window__title-extra { margin-inline-start: auto; } @@ -1080,7 +1073,7 @@ } @media (max-width: 720px) { - .sparo-remote-connect-overlay .ds-dialog { + .sparo-remote-connect-window { min-height: 0; } @@ -1816,18 +1809,6 @@ } } -@keyframes sparo-remote-connect-dialog-enter { - from { - opacity: 0; - transform: translateY(12px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - @keyframes sparo-remote-connect-info-fade-in { from { opacity: 0; @@ -1839,10 +1820,6 @@ } @media (prefers-reduced-motion: reduce) { - .sparo-remote-connect-overlay .ds-dialog { - animation: none; - } - .sparo-remote-connect__info-meta-group--ready, .sparo-remote-connect__info-meta-skeleton-line { animation: none; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index b0c2cd48..b6094477 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -9,7 +9,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { CloudCog, Network, QrCode, Server, Wifi } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; import { useI18n } from '@/infrastructure/i18n'; -import { Dialog, Badge, Button, Input, SegmentedControl } from '@/design-system'; +import { AppWindow, Dialog, Badge, Button, Input, SegmentedControl } from '@/design-system'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; import { remoteConnectAPI, @@ -1040,7 +1040,7 @@ export const RemoteConnectDialog: React.FC = ({ return ( <> - { if (!nextOpen) { @@ -1072,8 +1072,8 @@ export const RemoteConnectDialog: React.FC = ({ )} showCloseButton - size="xlarge" - overlayClassName="sparo-remote-connect-overlay" + size="wide" + className="sparo-remote-connect-window" contentClassName="sparo-remote-connect-modal__content" > = ({ onOpenNetworkSetup={() => openNetworkSetup()} onOpenBotSetup={(tab) => openBotSetup(tab as BotTab)} /> - + = ({ data-testid="unified-top-bar-home" > diff --git a/src/web-ui/src/app/components/WorkDock/NewWorkDialog.scss b/src/web-ui/src/app/components/WorkDock/NewWorkDialog.scss index e55d650a..d80ebc8e 100644 --- a/src/web-ui/src/app/components/WorkDock/NewWorkDialog.scss +++ b/src/web-ui/src/app/components/WorkDock/NewWorkDialog.scss @@ -1,222 +1,77 @@ -.new-work-dialog-overlay { - .ds-dialog { - overflow: visible; - } - - .ds-dialog__header-shell--close-only { - min-height: 34px; - border-bottom: 1px solid var(--ds-color-border-subtle); - } - - .modal__content.new-work-dialog__modal-surface { - overflow: visible; - } -} - -.new-work-dialog__modal-surface { +.new-work-dialog-layer { + position: fixed; + inset: 0; display: flex; - flex-direction: column; - min-height: 0; - overflow: visible; - padding-top: var(--ds-space-3); - padding-bottom: var(--ds-space-2); + align-items: center; + justify-content: center; + padding: var(--ds-space-5); + pointer-events: none; + z-index: var(--ds-z-popover); } .new-work-dialog { display: flex; flex-direction: column; - width: 100%; -} - -.new-work-dialog__masthead { - display: flex; - flex-shrink: 0; - flex-direction: column; - align-items: flex-start; - gap: var(--ds-space-2); - padding-bottom: var(--ds-space-3); -} - -.new-work-dialog__lede { - margin: 0; - max-width: 46ch; - color: var(--ds-color-text-secondary); - font-size: var(--ds-font-size-xs); - line-height: 1.45; - animation: new-work-dialog-copy-in 160ms var(--ds-easing-standard); + width: min(520px, 100%); + overflow: visible; + pointer-events: auto; } -.new-work-dialog__intent-line { +.new-work-dialog__header { display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: var(--ds-space-2); + align-items: center; + justify-content: space-between; + gap: var(--ds-space-3); + margin-bottom: var(--ds-space-2); min-width: 0; } -.new-work-dialog__intent-prefix { - flex: 0 0 auto; +.new-work-dialog__title { + margin: 0; color: var(--ds-color-text-primary); - font-size: var(--ds-font-size-base); - font-weight: 500; - line-height: 1.2; -} - -.new-work-dialog__path-choice { - display: inline-flex; - flex: 0 0 auto; - align-items: baseline; - gap: var(--ds-space-1); - min-width: 0; -} - -.new-work-dialog__path-option { - position: relative; - display: inline-flex; - min-height: 24px; - min-width: 0; - align-items: baseline; - justify-content: center; - padding: 0 1px 3px; - border: 0; - border-radius: var(--ds-radius-xs); - background: transparent; - color: var(--ds-color-text-muted); - cursor: pointer; - font: inherit; - font-size: var(--ds-font-size-base); - font-weight: 500; - letter-spacing: 0; - line-height: 1.15; - text-align: center; - transition: - color var(--ds-motion-fast) var(--ds-easing-standard), - opacity var(--ds-motion-fast) var(--ds-easing-standard), - transform var(--ds-motion-fast) var(--ds-easing-standard); - - &::after { - position: absolute; - right: 1px; - bottom: 0; - left: 1px; - height: 1px; - border-radius: var(--ds-radius-full); - background: currentColor; - content: ''; - opacity: 0; - transform: scaleX(0.35); - transform-origin: center; - transition: - opacity 160ms var(--ds-easing-standard), - transform 180ms var(--ds-easing-standard); - } - - &:hover { - color: var(--ds-color-text-secondary); - } - - &:focus-visible { - outline: 1px solid var(--ds-color-border-strong); - outline-offset: 2px; - } - - &.is-selected { - color: var(--ds-color-danger); - font-weight: 650; - transform: translateY(-1px); - - &::after { - opacity: 1; - transform: scaleX(1); - } - } -} - -.new-work-dialog__path-separator { - flex: 0 0 auto; - color: var(--ds-color-text-muted); - font-size: var(--ds-font-size-sm); - line-height: 1; - opacity: 0.55; - transform: translateY(-1px); - user-select: none; -} - -@keyframes new-work-dialog-copy-in { - from { - opacity: 0; - transform: translateY(-2px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -.new-work-dialog__path-title { - max-width: 100%; - overflow: hidden; - color: inherit; - text-overflow: ellipsis; + font-size: var(--ds-font-size-lg); + font-weight: var(--ds-font-weight-semibold); + line-height: var(--ds-line-height-tight); + letter-spacing: -0.02em; white-space: nowrap; } -.new-work-dialog__card { - display: flex; - flex: 0 1 auto; - flex-direction: column; - gap: var(--ds-space-4); - padding: var(--ds-space-4); - border: 1px solid var(--ds-color-border-subtle); - border-radius: var(--ds-radius-md); - background: var(--ds-color-bg-elevated); +.new-work-dialog__mode { + flex: 0 0 auto; } -.new-work-dialog__section { +.new-work-dialog__mode-content { display: flex; + align-items: center; + height: 64px; min-width: 0; - flex-direction: column; + padding: var(--ds-space-2) 0; + box-sizing: border-box; } -.new-work-dialog__section-head { +.new-work-dialog__sentence { display: flex; - align-items: baseline; + align-items: center; + flex-wrap: wrap; gap: var(--ds-space-2); - margin-bottom: var(--ds-space-2); -} - -.new-work-dialog__index { - flex-shrink: 0; - color: var(--ds-color-text-muted); - font-size: 11px; - font-variant-numeric: tabular-nums; - font-weight: 600; + min-width: 0; + width: 100%; + animation: new-work-dialog-mode-in var(--ds-motion-fast) var(--ds-easing-standard); } -.new-work-dialog__section-title { - margin: 0; - color: var(--ds-color-text-primary); - font-size: var(--ds-font-size-base); - font-weight: 600; - line-height: 1.25; +.new-work-dialog__sentence-copy { + flex: 0 0 auto; + color: var(--ds-color-text-secondary); + font-size: var(--ds-font-size-sm); + font-weight: 500; + line-height: 1.3; + white-space: nowrap; } -.new-work-dialog__control { +.new-work-dialog__agent-select { + flex: 0 0 142px; min-width: 0; -} - -.new-work-dialog__objective.ds-textarea { - .ds-textarea__field { - min-height: 84px; - max-height: 180px; - resize: vertical; - line-height: 1.45; - } - - .ds-textarea__footer { - margin-top: var(--ds-space-1); - } + max-width: 142px; } .new-work-dialog__agent-option { @@ -271,12 +126,13 @@ line-height: 1.35; } -.new-work-dialog__workspace-row { +.new-work-dialog__workspace-field { display: flex; - min-width: 0; - flex-direction: row; align-items: center; - gap: var(--ds-space-2); + flex: 0 0 188px; + gap: var(--ds-space-1); + min-width: 0; + max-width: 188px; } .new-work-dialog__workspace-select { @@ -288,82 +144,61 @@ flex-shrink: 0; } -.new-work-dialog__scope-hint { - margin: var(--ds-space-2) 0 0; - color: var(--ds-color-text-muted); - font-size: var(--ds-font-size-xs); - line-height: 1.4; +.new-work-dialog__sentence--delegate { + flex-wrap: nowrap; } -.new-work-dialog__classify { - display: flex; - flex-wrap: wrap; - gap: var(--ds-space-2); +.new-work-dialog__objective { + flex: 1 1 240px; + min-width: 0; } -.new-work-dialog__classify-option { - display: inline-flex; - align-items: center; - min-height: 28px; - padding: 0 var(--ds-space-3); - border: 1px solid var(--ds-color-border-subtle); - border-radius: var(--ds-radius-sm); - background: transparent; - color: var(--ds-color-text-secondary); - font-size: var(--ds-font-size-xs); - line-height: 1.2; - cursor: pointer; - transition: - border-color var(--ds-motion-duration-fast) var(--ds-easing-standard), - background-color var(--ds-motion-duration-fast) var(--ds-easing-standard), - color var(--ds-motion-duration-fast) var(--ds-easing-standard); - - &:hover { - border-color: var(--ds-color-border-medium); - color: var(--ds-color-text-primary); - } - - &.is-selected { - border-color: color-mix(in srgb, var(--ds-color-accent-500) 45%, var(--ds-color-border-subtle)); - background: color-mix(in srgb, var(--ds-color-accent-500) 10%, transparent); - color: var(--ds-color-text-primary); +@keyframes new-work-dialog-mode-in { + from { + opacity: 0; + transform: translateY(3px); } - &:focus-visible { - outline: 2px solid var(--ds-focus-ring-subtle); - outline-offset: 2px; + to { + opacity: 1; + transform: translateY(0); } } -.new-work-dialog__divider { - flex-shrink: 0; - height: 1px; - margin: 0; - background: var(--ds-color-border-subtle); -} - .new-work-dialog__actions { display: flex; flex-shrink: 0; align-items: center; justify-content: flex-end; gap: var(--ds-space-1); - margin-top: var(--ds-space-3); + margin-top: var(--ds-space-2); } @media (max-width: 520px) { - .new-work-dialog__intent-line { + .new-work-dialog__header { align-items: flex-start; flex-direction: column; + } + + .new-work-dialog__mode-content { + height: 152px; + align-items: flex-start; + padding-block: var(--ds-space-3); + } + + .new-work-dialog__agent-select, + .new-work-dialog__workspace-field { + max-width: none; width: 100%; - gap: var(--ds-space-1); } - .new-work-dialog__path-choice { - max-width: 100%; + .new-work-dialog__sentence--delegate { + flex-wrap: wrap; } +} - .new-work-dialog__workspace-row { - align-items: center; +@media (prefers-reduced-motion: reduce) { + .new-work-dialog__sentence { + animation: none; } } diff --git a/src/web-ui/src/app/components/WorkDock/NewWorkDialog.tsx b/src/web-ui/src/app/components/WorkDock/NewWorkDialog.tsx index 3f706cd8..9c61c236 100644 --- a/src/web-ui/src/app/components/WorkDock/NewWorkDialog.tsx +++ b/src/web-ui/src/app/components/WorkDock/NewWorkDialog.tsx @@ -1,7 +1,16 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { FolderOpen } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; -import { Button, Dialog, IconButton, Select, Textarea, type SelectOption } from '@/design-system'; +import { + Button, + FloatingCard, + IconButton, + Input, + SegmentedControl, + Select, + type SelectOption, +} from '@/design-system'; import { useI18n } from '@/infrastructure/i18n'; import { getWorkspaceDisplayName, @@ -44,11 +53,7 @@ const INTELLIGENT_APP_CHOICE_PREFIX = 'app-slot:'; const NATIVE_AGENT_APP_IDS: Record = { OSAgent: 'os-agent', }; -const AGENT_TYPE_BY_SLOT: Record = { - runno: 'Runno', - 'app-builder': 'AppBuilder', -}; - +const BUILTIN_WORK_SLOT_IDS = ['runno', 'app-builder'] as const; type NewWorkStartMode = 'manual' | 'agentic-os'; export type NewWorkClassifyKind = Extract< @@ -229,15 +234,15 @@ export const NewWorkDialog: React.FC = ({ const [agentChoice, setAgentChoice] = useState(appSlotWorkChoice('runno')); const [startMode, setStartMode] = useState('manual'); - const [classifyKind, setClassifyKind] = useState('multi_step'); const [workspaceId, setWorkspaceId] = useState(null); const [browsedWorkspacePath, setBrowsedWorkspacePath] = useState(null); const [objective, setObjective] = useState(''); const [submitting, setSubmitting] = useState(false); const [appSlots, setAppSlots] = useState([]); + const cardRef = useRef(null); const knownBuiltinChoices = useMemo>( - () => new Set(['OSAgent']), + () => new Set(BUILTIN_WORK_SLOT_IDS.map(appSlotWorkChoice)), [] ); @@ -249,19 +254,13 @@ export const NewWorkDialog: React.FC = ({ }, [browsedWorkspacePath, openedWorkspacesList, workspaceId]); const intelligentExecutors = useMemo(() => appSlots.flatMap((slot) => { - const defaultAgentType = AGENT_TYPE_BY_SLOT[slot.slotId]; - const activeApp = defaultAgentType ? intelligentAppAPI.activeRef(slot) : null; - if (!defaultAgentType || !activeApp) return []; - const launch = activeApp.runtime.launch; - const agentType = launch?.kind === 'appBuilder' - ? 'AppBuilder' - : launch?.agentType || defaultAgentType; + const activeApp = intelligentAppAPI.activeRef(slot); + if (!activeApp?.runtime.launch) return []; const variant = slot.variants.find(({ app }) => app.appId === activeApp.appId); return [{ choice: appSlotWorkChoice(slot.slotId), slot, activeApp, - agentType, description: variant?.app.description ?? '', }]; }), [appSlots]); @@ -284,7 +283,6 @@ export const NewWorkDialog: React.FC = ({ setAgentChoice(initialAgentChoice ?? storedAgent ?? appSlotWorkChoice('runno')); setStartMode('manual'); - setClassifyKind('multi_step'); setBrowsedWorkspacePath(null); setObjective(''); setWorkspaceId( @@ -302,6 +300,28 @@ export const NewWorkDialog: React.FC = ({ resetDefaults(); }, [isOpen, resetDefaults]); + useEffect(() => { + if (!isOpen) return; + const previouslyFocused = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const focusFrame = window.requestAnimationFrame(() => cardRef.current?.focus()); + const handlePointerDown = (event: PointerEvent) => { + if (!cardRef.current?.contains(event.target as Node)) onClose(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + window.cancelAnimationFrame(focusFrame); + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + previouslyFocused?.focus(); + }; + }, [isOpen, onClose]); + useEffect(() => { if (!isOpen) return; let cancelled = false; @@ -309,19 +329,23 @@ export const NewWorkDialog: React.FC = ({ intelligentAppAPI.listCatalog().then((catalog) => { if (cancelled) return; setAppSlots(catalog.slots); - const available = new Set(['OSAgent']); + const available = new Set(); for (const slot of catalog.slots) { - if (AGENT_TYPE_BY_SLOT[slot.slotId] && intelligentAppAPI.activeRef(slot)) { + if (intelligentAppAPI.activeRef(slot)?.runtime.launch) { available.add(appSlotWorkChoice(slot.slotId)); } } - setAgentChoice((current) => normalizeChoiceForAvailableApps(current, available) - ?? (available.has(appSlotWorkChoice('runno')) ? appSlotWorkChoice('runno') : 'OSAgent')); + const fallbackChoice = BUILTIN_WORK_SLOT_IDS + .map(appSlotWorkChoice) + .find((choice) => available.has(choice)) + ?? (available.values().next().value as NewWorkAgentChoice | undefined) + ?? appSlotWorkChoice('runno'); + setAgentChoice((current) => normalizeChoiceForAvailableApps(current, available) ?? fallbackChoice); }).catch((error) => { if (cancelled) return; log.error('Failed to load active Intelligent App executors', { error }); setAppSlots([]); - setAgentChoice('OSAgent'); + setAgentChoice(appSlotWorkChoice('runno')); }); return () => { cancelled = true; @@ -361,74 +385,30 @@ export const NewWorkDialog: React.FC = ({ const agentOptions = useMemo( () => { - const systemOption = { - value: 'OSAgent', - label: 'OSAgent', - description: 'Coordinate Sparo OS work, sessions, and memory.', - group: t('nav.workDock.executor.systemGroup'), - }; - const appOptions = intelligentExecutors.map((executor) => ({ + const optionForExecutor = (executor: (typeof intelligentExecutors)[number], group: string) => ({ value: executor.choice, label: executor.slot.displayName, description: executor.description, - group: t('nav.workDock.executor.intelligentAppGroup'), - })); - return [systemOption, ...appOptions]; + group, + }); + const builtinOptions = BUILTIN_WORK_SLOT_IDS.flatMap((slotId) => { + const executor = intelligentExecutors.find((candidate) => candidate.slot.slotId === slotId); + return executor + ? [optionForExecutor(executor, t('nav.workDock.executor.systemGroup'))] + : []; + }); + const appOptions = intelligentExecutors + .filter((executor) => !knownBuiltinChoices.has(executor.choice)) + .map((executor) => optionForExecutor(executor, t('nav.workDock.executor.intelligentAppGroup'))); + return [...builtinOptions, ...appOptions]; }, - [intelligentExecutors, t] + [intelligentExecutors, knownBuiltinChoices, t] ); - const startModeOptions = useMemo>(() => [ - { - value: 'manual', - title: t('nav.workDock.modeManual'), - }, - { - value: 'agentic-os', - title: t('nav.workDock.modeAgenticOs'), - }, - ], [t]); - const classifyOptions = useMemo>(() => [ - { value: 'multi_step', label: t('newWork.classify.immediate') }, - { value: 'topic', label: t('newWork.classify.topic') }, - { value: 'tracking', label: t('newWork.classify.tracking') }, - { value: 'recurring', label: t('newWork.classify.recurring') }, + const startModeOptions = useMemo(() => [ + { value: 'manual', label: t('nav.workDock.modeManual') }, + { value: 'agentic-os', label: t('nav.workDock.modeAgenticOs') }, ], [t]); - const showClassifyControls = startMode === 'manual'; - const modeLede = startMode === 'manual' - ? t('nav.workDock.modeManualLede') - : t('nav.workDock.modeAgenticOsLede'); - - const handleStartModeKeyDown = useCallback(( - event: React.KeyboardEvent, - mode: NewWorkStartMode - ) => { - let nextMode: NewWorkStartMode | null = null; - if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { - nextMode = mode === 'manual' ? 'agentic-os' : 'manual'; - } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { - nextMode = mode === 'agentic-os' ? 'manual' : 'agentic-os'; - } else if (event.key === 'Home') { - nextMode = 'manual'; - } else if (event.key === 'End') { - nextMode = 'agentic-os'; - } - - if (!nextMode) return; - event.preventDefault(); - setStartMode(nextMode); - window.requestAnimationFrame(() => { - document - .querySelector(`[data-new-work-mode="${nextMode}"]`) - ?.focus(); - }); - }, []); const renderAgentOption = useCallback((option: SelectOption) => (
@@ -545,7 +525,6 @@ export const NewWorkDialog: React.FC = ({ agentChoice, workspace, rememberWorkspace, - classifyKind, }); } @@ -578,7 +557,6 @@ export const NewWorkDialog: React.FC = ({ agentChoice, browsedWorkspacePath, workAppScope, - classifyKind, objective, onClose, openWorkspace, @@ -590,198 +568,118 @@ export const NewWorkDialog: React.FC = ({ workspaceId, ]); - const selectedWorkspaceOption = workspaceOptions.find((option) => option.value === (workspaceId ?? SYSTEM_WORKSPACE_VALUE)); const canSubmit = (startMode === 'manual' || objective.trim().length > 0) - && (!parseAppSlotWorkChoice(agentChoice) || Boolean(selectedExecutor)) + && (startMode === 'agentic-os' || !parseAppSlotWorkChoice(agentChoice) || Boolean(selectedExecutor)) && !submitting; - return ( - { - if (!nextOpen) onClose(); - }} - size="medium" - contentInset - contentClassName="new-work-dialog__modal-surface" - overlayClassName="new-work-dialog-overlay" - showCloseButton - closeOnOverlayClick={false} - > -
-
-
- {t('nav.workDock.intentPrefix')} -
- {startModeOptions.map((option, index) => { - const selected = startMode === option.value; - return ( - - {index > 0 && ( - - / - - )} - - - ); - })} -
-
-

{modeLede}

+ if (!isOpen) return null; + + return createPortal( +
+ +
+

+ {t('nav.workDock.createTitle')} +

+ setStartMode(value as NewWorkStartMode)} + size="small" + variant="accent" + ariaLabel={t('nav.workDock.modeAriaLabel')} + />
-
- {startMode === 'agentic-os' ? ( -
-
- - 01 - -

- {t('nav.workDock.newWorkSectionObjective')} -

+
+ {startMode === 'manual' ? ( +
+ {t('nav.workDock.sentencePrefix')} +
+