diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 9bda1eda..c873d5bb 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -109,12 +109,26 @@ impl AppState { uptime_seconds: 0, })); + let initial_workspace_path = workspace_service + .get_current_workspace() + .await + .map(|workspace| workspace.root_path); + + if let Some(workspace_path) = initial_workspace_path.clone() { + miniapp_manager + .set_workspace_path(Some(workspace_path.clone())) + .await; + if let Err(e) = ai_rules_service.set_workspace(workspace_path).await { + log::warn!("Failed to restore AI rules workspace on startup: {}", e); + } + } + let app_state = Self { ai_client, ai_client_factory, tool_registry, workspace_service, - workspace_path: Arc::new(RwLock::new(None)), + workspace_path: Arc::new(RwLock::new(initial_workspace_path)), config_service, filesystem_service, ai_rules_service, diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index cb72810f..1dcf31c2 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -5,7 +5,7 @@ use crate::api::dto::WorkspaceInfoDto; use bitfun_core::infrastructure::{file_watcher, FileOperationOptions, SearchMatchType}; use log::{debug, error, info, warn}; use serde::Deserialize; -use tauri::State; +use tauri::{AppHandle, State}; #[derive(Debug, Deserialize)] pub struct OpenWorkspaceRequest { @@ -18,6 +18,18 @@ pub struct ScanWorkspaceInfoRequest { pub workspace_path: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloseWorkspaceRequest { + pub workspace_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetActiveWorkspaceRequest { + pub workspace_id: String, +} + #[derive(Debug, Deserialize)] pub struct TestAIConfigConnectionRequest { pub config: bitfun_core::service::config::types::AIModelConfig, @@ -128,6 +140,96 @@ pub struct RevealInExplorerRequest { pub path: String, } +async fn clear_active_workspace_context(state: &State<'_, AppState>, app: &AppHandle) { + #[cfg(not(target_os = "macos"))] + let _ = app; + + *state.workspace_path.write().await = None; + state.miniapp_manager.set_workspace_path(None).await; + + if let Some(ref pool) = state.js_worker_pool { + pool.stop_all().await; + } + + state.ai_rules_service.clear_workspace().await; + state.agent_registry.clear_custom_subagents(); + + #[cfg(target_os = "macos")] + { + let language = state + .config_service + .get_config::(Some("app.language")) + .await + .unwrap_or_else(|_| "zh-CN".to_string()); + let _ = crate::macos_menubar::set_macos_menubar_with_mode( + app, + &language, + crate::macos_menubar::MenubarMode::Startup, + ); + } +} + +async fn apply_active_workspace_context( + state: &State<'_, AppState>, + app: &AppHandle, + workspace_info: &bitfun_core::service::workspace::manager::WorkspaceInfo, +) { + #[cfg(not(target_os = "macos"))] + let _ = app; + + clear_active_workspace_context(state, app).await; + + *state.workspace_path.write().await = Some(workspace_info.root_path.clone()); + state + .miniapp_manager + .set_workspace_path(Some(workspace_info.root_path.clone())) + .await; + + if let Err(e) = bitfun_core::service::snapshot::initialize_global_snapshot_manager( + workspace_info.root_path.clone(), + None, + ) + .await + { + warn!( + "Failed to initialize snapshot system: path={}, error={}", + workspace_info.root_path.display(), + e + ); + } + + state + .agent_registry + .load_custom_subagents(&workspace_info.root_path) + .await; + + if let Err(e) = state + .ai_rules_service + .set_workspace(workspace_info.root_path.clone()) + .await + { + warn!( + "Failed to set AI rules workspace: path={}, error={}", + workspace_info.root_path.display(), + e + ); + } + + #[cfg(target_os = "macos")] + { + let language = state + .config_service + .get_config::(Some("app.language")) + .await + .unwrap_or_else(|_| "zh-CN".to_string()); + let _ = crate::macos_menubar::set_macos_menubar_with_mode( + app, + &language, + crate::macos_menubar::MenubarMode::Workspace, + ); + } +} + #[tauri::command] pub async fn initialize_global_state(_state: State<'_, AppState>) -> Result { Ok("Global state initialized successfully".to_string()) @@ -454,7 +556,7 @@ pub async fn update_app_status( #[tauri::command] pub async fn open_workspace( state: State<'_, AppState>, - _app: tauri::AppHandle, + app: tauri::AppHandle, request: OpenWorkspaceRequest, ) -> Result { match state @@ -463,55 +565,7 @@ pub async fn open_workspace( .await { Ok(workspace_info) => { - *state.workspace_path.write().await = Some(workspace_info.root_path.clone()); - state - .miniapp_manager - .set_workspace_path(Some(workspace_info.root_path.clone())) - .await; - - if let Err(e) = bitfun_core::service::snapshot::initialize_global_snapshot_manager( - workspace_info.root_path.clone(), - None, - ) - .await - { - warn!( - "Failed to initialize snapshot system: path={}, error={}", - workspace_info.root_path.display(), - e - ); - } - - state - .agent_registry - .load_custom_subagents(&workspace_info.root_path) - .await; - - if let Err(e) = state - .ai_rules_service - .set_workspace(workspace_info.root_path.clone()) - .await - { - warn!( - "Failed to set AI rules workspace: path={}, error={}", - workspace_info.root_path.display(), - e - ); - } - - #[cfg(target_os = "macos")] - { - let language = state - .config_service - .get_config::(Some("app.language")) - .await - .unwrap_or_else(|_| "zh-CN".to_string()); - let _ = crate::macos_menubar::set_macos_menubar_with_mode( - &_app, - &language, - crate::macos_menubar::MenubarMode::Workspace, - ); - } + apply_active_workspace_context(&state, &app, &workspace_info).await; info!( "Workspace opened: name={}, path={}", @@ -530,34 +584,22 @@ pub async fn open_workspace( #[tauri::command] pub async fn close_workspace( state: State<'_, AppState>, - _app: tauri::AppHandle, + app: tauri::AppHandle, + request: CloseWorkspaceRequest, ) -> Result<(), String> { - match state.workspace_service.close_workspace("default").await { + match state + .workspace_service + .close_workspace(&request.workspace_id) + .await + { Ok(_) => { - *state.workspace_path.write().await = None; - state.miniapp_manager.set_workspace_path(None).await; - if let Some(ref pool) = state.js_worker_pool { - pool.stop_all().await; - } - state.ai_rules_service.clear_workspace().await; - - state.agent_registry.clear_custom_subagents(); - - #[cfg(target_os = "macos")] - { - let language = state - .config_service - .get_config::(Some("app.language")) - .await - .unwrap_or_else(|_| "zh-CN".to_string()); - let _ = crate::macos_menubar::set_macos_menubar_with_mode( - &_app, - &language, - crate::macos_menubar::MenubarMode::Startup, - ); + 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; } - info!("Workspace closed"); + info!("Workspace closed: workspace_id={}", request.workspace_id); Ok(()) } Err(e) => { @@ -567,6 +609,41 @@ pub async fn close_workspace( } } +#[tauri::command] +pub async fn set_active_workspace( + state: State<'_, AppState>, + app: tauri::AppHandle, + request: SetActiveWorkspaceRequest, +) -> Result { + match state + .workspace_service + .set_active_workspace(&request.workspace_id) + .await + { + Ok(_) => { + let workspace_info = state + .workspace_service + .get_current_workspace() + .await + .ok_or_else(|| "Active workspace not found after switching".to_string())?; + + apply_active_workspace_context(&state, &app, &workspace_info).await; + + info!( + "Active workspace changed: workspace_id={}, path={}", + workspace_info.id, + workspace_info.root_path.display() + ); + + Ok(WorkspaceInfoDto::from_workspace_info(&workspace_info)) + } + Err(e) => { + error!("Failed to set active workspace: {}", e); + Err(format!("Failed to set active workspace: {}", e)) + } + } +} + #[tauri::command] pub async fn get_current_workspace( state: State<'_, AppState>, @@ -591,6 +668,19 @@ pub async fn get_recent_workspaces( .collect()) } +#[tauri::command] +pub async fn get_opened_workspaces( + state: State<'_, AppState>, +) -> Result, String> { + let workspace_service = &state.workspace_service; + Ok(workspace_service + .get_opened_workspaces() + .await + .into_iter() + .map(|info| WorkspaceInfoDto::from_workspace_info(&info)) + .collect()) +} + #[tauri::command] pub async fn scan_workspace_info( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 526d4cf5..bda8ff6d 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -540,8 +540,10 @@ pub async fn run() { subscribe_config_updates, get_model_configs, get_recent_workspaces, + get_opened_workspaces, open_workspace, close_workspace, + set_active_workspace, get_current_workspace, scan_workspace_info, api::prompt_template_api::get_prompt_template_config, diff --git a/src/crates/core/src/service/workspace/manager.rs b/src/crates/core/src/service/workspace/manager.rs index dd71e91e..d8f5ac01 100644 --- a/src/crates/core/src/service/workspace/manager.rs +++ b/src/crates/core/src/service/workspace/manager.rs @@ -403,6 +403,7 @@ pub struct WorkspaceSummary { /// Workspace manager. pub struct WorkspaceManager { workspaces: HashMap, + opened_workspace_ids: Vec, current_workspace_id: Option, recent_workspaces: Vec, max_recent_workspaces: usize, @@ -431,6 +432,7 @@ impl WorkspaceManager { pub fn new(config: WorkspaceManagerConfig) -> Self { Self { workspaces: HashMap::new(), + opened_workspace_ids: Vec::new(), current_workspace_id: None, recent_workspaces: Vec::new(), max_recent_workspaces: config.max_recent_workspaces, @@ -460,6 +462,7 @@ impl WorkspaceManager { .map(|w| w.id.clone()); if let Some(workspace_id) = existing_workspace_id { + self.ensure_workspace_open(&workspace_id); self.set_current_workspace(workspace_id.clone())?; return self.workspaces.get(&workspace_id).cloned().ok_or_else(|| { BitFunError::service(format!( @@ -474,6 +477,7 @@ impl WorkspaceManager { self.workspaces .insert(workspace_id.clone(), workspace.clone()); + self.ensure_workspace_open(&workspace_id); self.set_current_workspace(workspace_id.clone())?; Ok(workspace) @@ -481,27 +485,51 @@ impl WorkspaceManager { /// Closes the current workspace. pub fn close_current_workspace(&mut self) -> BitFunResult<()> { - if let Some(workspace_id) = &self.current_workspace_id { - if let Some(workspace) = self.workspaces.get_mut(workspace_id) { - workspace.status = WorkspaceStatus::Inactive; - } - self.current_workspace_id = None; + let current_workspace_id = self.current_workspace_id.clone(); + match current_workspace_id { + Some(workspace_id) => self.close_workspace(&workspace_id), + None => Ok(()), } - Ok(()) } /// Closes the specified workspace. pub fn close_workspace(&mut self, workspace_id: &str) -> BitFunResult<()> { + if !self.workspaces.contains_key(workspace_id) { + return Err(BitFunError::service(format!( + "Workspace not found: {}", + workspace_id + ))); + } + + self.opened_workspace_ids.retain(|id| id != workspace_id); + if let Some(workspace) = self.workspaces.get_mut(workspace_id) { workspace.status = WorkspaceStatus::Inactive; + } - if self.current_workspace_id.as_ref() == Some(&workspace_id.to_string()) { - self.current_workspace_id = None; + if self.current_workspace_id.as_deref() == Some(workspace_id) { + self.current_workspace_id = None; + + if let Some(next_workspace_id) = self.opened_workspace_ids.first().cloned() { + self.set_current_workspace(next_workspace_id)?; } } + Ok(()) } + /// Sets the active workspace among already opened workspaces. + pub fn set_active_workspace(&mut self, workspace_id: &str) -> BitFunResult<()> { + if !self.opened_workspace_ids.iter().any(|id| id == workspace_id) { + return Err(BitFunError::service(format!( + "Workspace is not opened: {}", + workspace_id + ))); + } + + self.set_current_workspace(workspace_id.to_string()) + } + /// Sets the current workspace. pub fn set_current_workspace(&mut self, workspace_id: String) -> BitFunResult<()> { if !self.workspaces.contains_key(&workspace_id) { @@ -511,6 +539,16 @@ impl WorkspaceManager { ))); } + self.ensure_workspace_open(&workspace_id); + + if let Some(previous_workspace_id) = &self.current_workspace_id { + if previous_workspace_id != &workspace_id { + if let Some(previous_workspace) = self.workspaces.get_mut(previous_workspace_id) { + previous_workspace.status = WorkspaceStatus::Inactive; + } + } + } + if let Some(workspace) = self.workspaces.get_mut(&workspace_id) { workspace.status = WorkspaceStatus::Active; workspace.touch(); @@ -537,6 +575,14 @@ impl WorkspaceManager { self.workspaces.get(workspace_id) } + /// Gets all opened workspaces. + pub fn get_opened_workspace_infos(&self) -> Vec<&WorkspaceInfo> { + self.opened_workspace_ids + .iter() + .filter_map(|id| self.workspaces.get(id)) + .collect() + } + /// Lists all workspaces. pub fn list_workspaces(&self) -> Vec { self.workspaces.values().map(|w| w.get_summary()).collect() @@ -623,6 +669,11 @@ impl WorkspaceManager { } } + fn ensure_workspace_open(&mut self, workspace_id: &str) { + self.opened_workspace_ids.retain(|id| id != workspace_id); + self.opened_workspace_ids.insert(0, workspace_id.to_string()); + } + /// Returns manager statistics. pub fn get_statistics(&self) -> WorkspaceManagerStatistics { let mut stats = WorkspaceManagerStatistics::default(); @@ -666,6 +717,19 @@ impl WorkspaceManager { &mut self.workspaces } + /// Returns the opened workspace ids. + pub fn get_opened_workspace_ids(&self) -> &Vec { + &self.opened_workspace_ids + } + + /// Sets the opened workspace ids. + pub fn set_opened_workspace_ids(&mut self, opened_workspace_ids: Vec) { + self.opened_workspace_ids = opened_workspace_ids + .into_iter() + .filter(|id| self.workspaces.contains_key(id)) + .collect(); + } + /// Returns a reference to the recent-workspaces list. pub fn get_recent_workspaces(&self) -> &Vec { &self.recent_workspaces diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index be5d45f5..b668e786 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -183,26 +183,37 @@ impl WorkspaceService { }; if result.is_ok() { + if let Err(e) = self.save_workspace_data().await { + warn!("Failed to save workspace data after closing: {}", e); + } self.sync_global_workspace_path().await; } result } - /// Switches to the specified workspace. - pub async fn switch_to_workspace(&self, workspace_id: &str) -> BitFunResult<()> { + /// Sets the active workspace from the opened workspace list. + pub async fn set_active_workspace(&self, workspace_id: &str) -> BitFunResult<()> { let result = { let mut manager = self.manager.write().await; - manager.set_current_workspace(workspace_id.to_string()) + manager.set_active_workspace(workspace_id) }; if result.is_ok() { + if let Err(e) = self.save_workspace_data().await { + warn!("Failed to save workspace data after switching active workspace: {}", e); + } self.sync_global_workspace_path().await; } result } + /// Switches to the specified workspace. + pub async fn switch_to_workspace(&self, workspace_id: &str) -> BitFunResult<()> { + self.set_active_workspace(workspace_id).await + } + /// Returns the current workspace. pub async fn get_current_workspace(&self) -> Option { let manager = self.manager.read().await; @@ -215,6 +226,16 @@ impl WorkspaceService { manager.get_workspace(workspace_id).cloned() } + /// Returns all currently opened workspaces. + pub async fn get_opened_workspaces(&self) -> Vec { + let manager = self.manager.read().await; + manager + .get_opened_workspace_infos() + .into_iter() + .cloned() + .collect() + } + /// Lists all workspaces. pub async fn list_workspaces(&self) -> Vec { let manager = self.manager.read().await; @@ -593,6 +614,7 @@ impl WorkspaceService { let workspace_data = WorkspacePersistenceData { workspaces: manager.get_workspaces().clone(), + opened_workspace_ids: manager.get_opened_workspace_ids().clone(), current_workspace_id: manager.get_current_workspace().map(|w| w.id.clone()), recent_workspaces: manager.get_recent_workspaces().clone(), saved_at: chrono::Utc::now(), @@ -627,6 +649,7 @@ impl WorkspaceService { let mut manager = self.manager.write().await; *manager.get_workspaces_mut() = data.workspaces; + manager.set_opened_workspace_ids(data.opened_workspace_ids); manager.set_recent_workspaces(data.recent_workspaces); if let Some(current_id) = data.current_workspace_id { @@ -664,7 +687,20 @@ impl WorkspaceService { let mut manager = self.manager.write().await; *manager.get_workspaces_mut() = data.workspaces; + manager.set_opened_workspace_ids(data.opened_workspace_ids.clone()); manager.set_recent_workspaces(data.recent_workspaces); + + let current_id = data + .current_workspace_id + .or_else(|| data.opened_workspace_ids.first().cloned()); + + if let Some(current_id) = current_id { + if manager.get_workspaces().contains_key(¤t_id) { + if let Err(e) = manager.set_current_workspace(current_id) { + warn!("Failed to restore current workspace on startup: {}", e); + } + } + } } Ok(()) @@ -755,6 +791,8 @@ pub struct WorkspaceQuickSummary { #[derive(Debug, Serialize, Deserialize)] struct WorkspacePersistenceData { pub workspaces: std::collections::HashMap, + #[serde(default)] + pub opened_workspace_ids: Vec, pub current_workspace_id: Option, pub recent_workspaces: Vec, pub saved_at: chrono::DateTime, diff --git a/src/web-ui/public/panda_1.png b/src/web-ui/public/panda_1.png new file mode 100644 index 00000000..3ef50c28 Binary files /dev/null and b/src/web-ui/public/panda_1.png differ diff --git a/src/web-ui/public/panda_2.png b/src/web-ui/public/panda_2.png new file mode 100644 index 00000000..d78a2865 Binary files /dev/null and b/src/web-ui/public/panda_2.png differ diff --git a/src/web-ui/public/panda_full_1.png b/src/web-ui/public/panda_full_1.png new file mode 100644 index 00000000..d718c67e Binary files /dev/null and b/src/web-ui/public/panda_full_1.png differ diff --git a/src/web-ui/public/panda_full_2.png b/src/web-ui/public/panda_full_2.png new file mode 100644 index 00000000..450940b1 Binary files /dev/null and b/src/web-ui/public/panda_full_2.png differ diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.scss b/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.scss new file mode 100644 index 00000000..f158f3a6 --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.scss @@ -0,0 +1,85 @@ +@use '../../../component-library/styles/tokens' as *; + +.gallery-detail-modal { + display: flex; + flex-direction: column; + gap: $size-gap-4; + padding: $size-gap-4 0 $size-gap-4; + + &__hero { + display: flex; + gap: $size-gap-4; + align-items: flex-start; + } + + &__icon { + --gallery-detail-gradient: linear-gradient(135deg, rgba(59,130,246,0.28) 0%, rgba(139,92,246,0.18) 100%); + + width: 56px; + height: 56px; + border-radius: $size-radius-lg; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--color-text-primary); + background: var(--gallery-detail-gradient); + border: 1px solid color-mix(in srgb, var(--border-subtle) 60%, transparent); + } + + &__summary { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: $size-gap-2; + } + + &__badges { + display: flex; + align-items: center; + gap: $size-gap-2; + flex-wrap: wrap; + } + + &__description { + margin: 0; + color: var(--color-text-secondary); + font-size: $font-size-sm; + line-height: $line-height-relaxed; + } + + &__meta { + display: flex; + align-items: center; + gap: $size-gap-2; + flex-wrap: wrap; + color: var(--color-text-muted); + font-size: $font-size-xs; + } + + &__content { + display: flex; + flex-direction: column; + gap: $size-gap-3; + padding-top: $size-gap-3; + border-top: 1px solid var(--border-subtle); + } + + &__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $size-gap-2; + padding-top: $size-gap-3; + border-top: 1px solid var(--border-subtle); + } +} + +@media (max-width: 720px) { + .gallery-detail-modal { + &__hero { + flex-direction: column; + } + } +} diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.tsx b/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.tsx new file mode 100644 index 00000000..567dd804 --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryDetailModal.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Modal } from '@/component-library'; +import './GalleryDetailModal.scss'; + +interface GalleryDetailModalProps { + isOpen: boolean; + onClose: () => void; + icon?: React.ReactNode; + iconGradient?: string; + title: string; + badges?: React.ReactNode; + description?: string; + meta?: React.ReactNode; + actions?: React.ReactNode; + children?: React.ReactNode; +} + +const GalleryDetailModal: React.FC = ({ + isOpen, + onClose, + icon, + iconGradient, + title, + badges, + description, + meta, + actions, + children, +}) => ( + +
+
+ {icon ? ( +
+ {icon} +
+ ) : null} +
+ {badges ?
{badges}
: null} + {description?.trim() ? ( +

{description.trim()}

+ ) : null} + {meta ?
{meta}
: null} +
+
+ + {children ?
{children}
: null} + + {actions ?
{actions}
: null} +
+
+); + +export default GalleryDetailModal; +export type { GalleryDetailModalProps }; diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryEmpty.tsx b/src/web-ui/src/app/components/GalleryLayout/GalleryEmpty.tsx new file mode 100644 index 00000000..d0778922 --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryEmpty.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +interface GalleryEmptyProps { + icon: React.ReactNode; + message: React.ReactNode; + isError?: boolean; + action?: React.ReactNode; + className?: string; +} + +const GalleryEmpty: React.FC = ({ + icon, + message, + isError = false, + action, + className, +}) => ( +
+ {icon} + {message} + {action} +
+); + +export default GalleryEmpty; +export type { GalleryEmptyProps }; diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryGrid.tsx b/src/web-ui/src/app/components/GalleryLayout/GalleryGrid.tsx new file mode 100644 index 00000000..43716dfb --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryGrid.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +interface GalleryGridProps { + children: React.ReactNode; + minCardWidth?: number; + className?: string; +} + +const GalleryGrid: React.FC = ({ + children, + minCardWidth = 320, + className, +}) => ( +
+ {children} +
+); + +export default GalleryGrid; +export type { GalleryGridProps }; diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.scss b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.scss new file mode 100644 index 00000000..41e058c4 --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.scss @@ -0,0 +1,487 @@ +@use '../../../component-library/styles/tokens' as *; + +$gutter: clamp(40px, 6vw, 80px); +$content-max: 1480px; + +.gallery-layout { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + + &__body { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 2px; + } + } + + &__body-inner { + min-height: 100%; + display: flex; + flex-direction: column; + } +} + +.gallery-page-header { + display: flex; + align-items: flex-start; + gap: $size-gap-4; + width: min(100%, $content-max); + padding: $size-gap-8 $gutter $size-gap-6; + margin: 0 auto; + box-sizing: border-box; + position: sticky; + top: 0; + z-index: 10; + background: linear-gradient( + to bottom, + var(--color-bg-scene) 0%, + var(--color-bg-scene) 72%, + transparent 100% + ); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + + &__identity { + flex: 1; + min-width: 0; + } + + &__title { + margin: 0 0 $size-gap-1; + font-size: clamp(18px, 2vw, 22px); + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + letter-spacing: -0.02em; + line-height: $line-height-tight; + } + + &__subtitle { + margin: 0; + font-size: $font-size-xs; + color: var(--color-text-muted); + line-height: $line-height-relaxed; + + a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; + } + } + + &__extra { + margin-top: $size-gap-3; + } + + &__actions { + display: flex; + align-items: center; + gap: $size-gap-2; + flex-shrink: 0; + padding-top: 3px; + } +} + +.gallery-zones { + display: flex; + flex-direction: column; + gap: $size-gap-5; + width: min(100%, $content-max); + padding: $size-gap-6 $gutter $size-gap-8; + margin: 0 auto; + box-sizing: border-box; +} + +.gallery-zone { + display: flex; + flex-direction: column; + gap: $size-gap-3; + scroll-margin-top: 152px; + + &__header { + display: flex; + align-items: center; + gap: $size-gap-3; + min-height: 28px; + } + + &__heading { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + &__title { + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + } + + &__subtitle { + font-size: $font-size-xs; + color: var(--color-text-muted); + line-height: $line-height-relaxed; + } + + &__tools { + display: flex; + align-items: center; + gap: $size-gap-2; + margin-left: auto; + flex-wrap: wrap; + justify-content: flex-end; + } +} + +.gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--gallery-grid-min, 320px), 1fr)); + gap: $size-gap-2; + align-content: start; + + &--skeleton { + --skeleton-shimmer-0: rgba(255, 255, 255, 0); + --skeleton-shimmer-peak: rgba(255, 255, 255, 0.1); + pointer-events: none; + } +} + +.gallery-skeleton-card { + height: var(--gallery-skeleton-height, 140px); + border-radius: $size-radius-lg; + background: var(--element-bg-subtle); + border: 1px solid var(--border-subtle); + position: relative; + overflow: hidden; + animation: gallery-item-in 0.28s $easing-decelerate both; + animation-delay: calc(var(--card-index, 0) * 60ms); + + &::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + var(--skeleton-shimmer-0) 0%, + var(--skeleton-shimmer-peak) 50%, + var(--skeleton-shimmer-0) 100% + ); + animation: gallery-shimmer 1.6s ease-in-out calc(var(--card-index, 0) * 0.12s) infinite; + } +} + +.gallery-empty { + min-height: 180px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $size-gap-3; + padding: $size-gap-8 $size-gap-6; + font-size: $font-size-sm; + color: var(--color-text-muted); + text-align: center; + + &--error { + color: var(--color-error); + } +} + +.gallery-action-btn, +.gallery-search-btn, +.gallery-plain-icon-btn, +.gallery-filter-chip, +.gallery-anchor-btn, +.gallery-cat-chip { + transition: + background $motion-fast $easing-standard, + color $motion-fast $easing-standard, + border-color $motion-fast $easing-standard; +} + +.gallery-search-btn, +.gallery-plain-icon-btn, +.gallery-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + min-width: 32px; + padding: 0 10px; + border-radius: $size-radius-base; + border: none; + background: var(--element-bg-soft); + color: var(--color-text-secondary); + cursor: pointer; + + &:hover:not(:disabled) { + background: var(--element-bg-medium); + color: var(--color-text-primary); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: -1px; + } +} + +.gallery-search-btn { + width: 24px; + height: 24px; + min-width: 24px; + padding: 0; + border-radius: $size-radius-sm; + background: transparent; +} + +.gallery-plain-icon-btn { + width: 28px; + min-width: 28px; + height: 28px; + padding: 0; + background: transparent; +} + +.gallery-action-btn { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + + &--primary { + color: var(--color-accent-500); + background: color-mix(in srgb, var(--color-accent-500) 10%, var(--element-bg-soft)); + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-accent-500) 16%, var(--element-bg-medium)); + color: var(--color-accent-400); + } + } +} + +.gallery-anchor-bar, +.gallery-filter-bar, +.gallery-chip-row, +.gallery-filter-clusters, +.gallery-tag-bar { + display: flex; + align-items: center; + gap: $size-gap-2; + flex-wrap: wrap; +} + +.gallery-filter-group { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + background: var(--element-bg-subtle); + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + flex-wrap: wrap; +} + +.gallery-filter-chip, +.gallery-anchor-btn, +.gallery-cat-chip { + display: inline-flex; + align-items: center; + gap: $size-gap-1; + height: 24px; + padding: 0 $size-gap-2; + border-radius: $size-radius-full; + background: transparent; + color: var(--color-text-muted); + font-size: $font-size-xs; + font-weight: $font-weight-medium; + cursor: pointer; + + &:hover { + color: var(--color-text-primary); + background: var(--element-bg-soft); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: -1px; + } +} + +.gallery-filter-chip, +.gallery-cat-chip { + border: 1px solid var(--border-subtle); +} + +.gallery-anchor-btn { + padding: 0 $size-gap-3; + height: 28px; + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.03); +} + +.gallery-filter-chip.is-active, +.gallery-cat-chip--active { + color: var(--color-accent-500); + background: var(--color-accent-100); + border-color: var(--color-accent-300); +} + +.gallery-filter-count, +.gallery-zone-count, +.gallery-zone-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + line-height: 1; +} + +.gallery-filter-count { + height: 16px; + padding: 0 4px; + border-radius: $size-radius-full; + background: var(--element-bg-medium); + color: var(--color-text-muted); + font-size: 10px; +} + +.gallery-zone-count { + min-width: 24px; + height: 24px; + padding: 0 8px; + border-radius: $size-radius-full; + background: var(--element-bg-medium); + border: 1px solid var(--border-subtle); + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + color: var(--color-text-muted); + white-space: nowrap; +} + +.gallery-zone-badge { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: $size-radius-full; + background: rgba(52, 211, 153, 0.12); + border: 1px solid rgba(52, 211, 153, 0.28); + color: #34d399; + font-size: 11px; + font-weight: $font-weight-semibold; +} + +.gallery-run-empty { + font-size: $font-size-xs; + color: var(--color-text-muted); + padding: $size-gap-3 0; + text-align: center; +} + +.gallery-spinning { + animation: gallery-spin 0.8s linear infinite; +} + +@keyframes gallery-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes gallery-shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(200%); } +} + +@keyframes gallery-item-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +:root[data-theme-type='light'] .gallery-grid--skeleton { + --skeleton-shimmer-0: rgba(0, 0, 0, 0); + --skeleton-shimmer-peak: rgba(0, 0, 0, 0.07); +} + +@media (max-width: 1080px) { + .gallery-page-header { + flex-direction: column; + + &__actions { + width: 100%; + flex-wrap: wrap; + } + } +} + +@media (max-width: 720px) { + .gallery-page-header { + padding: $size-gap-6 $size-gap-4 $size-gap-5; + } + + .gallery-zones { + padding: $size-gap-5 $size-gap-4 $size-gap-6; + } + + .gallery-zone { + &__header { + flex-direction: column; + align-items: flex-start; + } + + &__tools { + width: 100%; + margin-left: 0; + justify-content: space-between; + } + } + + .gallery-grid { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + .gallery-search-btn, + .gallery-plain-icon-btn, + .gallery-action-btn, + .gallery-filter-chip, + .gallery-anchor-btn, + .gallery-cat-chip, + .gallery-skeleton-card, + .gallery-spinning { + transition: none; + animation: none; + } + + .gallery-skeleton-card::after { + animation: none; + } +} diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.tsx b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.tsx new file mode 100644 index 00000000..b106399e --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import './GalleryLayout.scss'; + +interface GalleryLayoutProps { + children: React.ReactNode; + className?: string; +} + +const GalleryLayout: React.FC = ({ children, className }) => ( +
+
+
+ {children} +
+
+
+); + +export default GalleryLayout; +export type { GalleryLayoutProps }; diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryPageHeader.tsx b/src/web-ui/src/app/components/GalleryLayout/GalleryPageHeader.tsx new file mode 100644 index 00000000..e23c02db --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryPageHeader.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +interface GalleryPageHeaderProps { + title: string; + subtitle?: React.ReactNode; + actions?: React.ReactNode; + extraContent?: React.ReactNode; +} + +const GalleryPageHeader: React.FC = ({ + title, + subtitle, + actions, + extraContent, +}) => ( +
+
+

{title}

+ {subtitle ?
{subtitle}
: null} + {extraContent ?
{extraContent}
: null} +
+ {actions ?
{actions}
: null} +
+); + +export default GalleryPageHeader; +export type { GalleryPageHeaderProps }; diff --git a/src/web-ui/src/app/components/GalleryLayout/GallerySkeleton.tsx b/src/web-ui/src/app/components/GalleryLayout/GallerySkeleton.tsx new file mode 100644 index 00000000..36d12f85 --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/GallerySkeleton.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +interface GallerySkeletonProps { + count?: number; + cardHeight?: number; + minCardWidth?: number; + className?: string; +} + +const GallerySkeleton: React.FC = ({ + count = 6, + cardHeight = 140, + minCardWidth = 320, + className, +}) => ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+ ))} +
+); + +export default GallerySkeleton; +export type { GallerySkeletonProps }; diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryZone.tsx b/src/web-ui/src/app/components/GalleryLayout/GalleryZone.tsx new file mode 100644 index 00000000..d022ec2b --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryZone.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +interface GalleryZoneProps { + id?: string; + title: string; + subtitle?: React.ReactNode; + tools?: React.ReactNode; + children: React.ReactNode; + className?: string; +} + +const GalleryZone: React.FC = ({ + id, + title, + subtitle, + tools, + children, + className, +}) => ( +
+
+
+ {title} + {subtitle ? {subtitle} : null} +
+ {tools ?
{tools}
: null} +
+ {children} +
+); + +export default GalleryZone; +export type { GalleryZoneProps }; diff --git a/src/web-ui/src/app/components/GalleryLayout/index.ts b/src/web-ui/src/app/components/GalleryLayout/index.ts new file mode 100644 index 00000000..d1a8b285 --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/index.ts @@ -0,0 +1,15 @@ +export { default as GalleryLayout } from './GalleryLayout'; +export { default as GalleryPageHeader } from './GalleryPageHeader'; +export { default as GalleryZone } from './GalleryZone'; +export { default as GalleryGrid } from './GalleryGrid'; +export { default as GallerySkeleton } from './GallerySkeleton'; +export { default as GalleryEmpty } from './GalleryEmpty'; +export { default as GalleryDetailModal } from './GalleryDetailModal'; + +export type { GalleryLayoutProps } from './GalleryLayout'; +export type { GalleryPageHeaderProps } from './GalleryPageHeader'; +export type { GalleryZoneProps } from './GalleryZone'; +export type { GalleryGridProps } from './GalleryGrid'; +export type { GallerySkeletonProps } from './GallerySkeleton'; +export type { GalleryEmptyProps } from './GalleryEmpty'; +export type { GalleryDetailModalProps } from './GalleryDetailModal'; diff --git a/src/web-ui/src/app/components/NavBar/NavBar.scss b/src/web-ui/src/app/components/NavBar/NavBar.scss index 50ad1a43..14ac7dc8 100644 --- a/src/web-ui/src/app/components/NavBar/NavBar.scss +++ b/src/web-ui/src/app/components/NavBar/NavBar.scss @@ -4,28 +4,6 @@ @use '../../../component-library/styles/tokens.scss' as *; -@keyframes bitfun-logo-menu-in { - from { - opacity: 0; - transform: translateY(-6px) scale(0.96); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes bitfun-logo-menu-out { - from { - opacity: 1; - transform: translateY(0) scale(1); - } - to { - opacity: 0; - transform: translateY(-6px) scale(0.96); - } -} - .bitfun-nav-bar { display: flex; align-items: center; @@ -47,13 +25,6 @@ padding-left: calc(#{$size-gap-2} + 72px); padding-right: 2px; - .bitfun-nav-bar__logo-menu { - position: fixed; - top: 7px; - right: 10px; - margin: 0; - z-index: $z-dropdown; - } } &--macos#{&}--collapsed { @@ -61,44 +32,6 @@ padding-right: 2px; } - // ── App logo ──────────────────────────────────────────── - - &__logo-menu { - position: relative; - flex-shrink: 0; - margin-right: $size-gap-1; - } - - &__logo-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - padding: 0; - border: none; - border-radius: 4px; - background: transparent; - cursor: pointer; - - &:hover { - background: var(--element-bg-soft); - } - - &:focus-visible { - outline: 2px solid var(--color-accent-500); - outline-offset: -1px; - } - } - - &__logo { - width: 22px; - height: 22px; - object-fit: contain; - border-radius: $size-radius-sm; - pointer-events: none; - } - // ── Back / Forward buttons ────────────────────────────── &__btn { @@ -164,102 +97,11 @@ .window-controls { flex-shrink: 0; } - - &__menu { - position: fixed; - min-width: 220px; - z-index: $z-popover; - background: var(--color-bg-elevated, #1e1e22); - border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); - border-radius: $size-radius-base; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); - padding: $size-gap-1 0; - transform-origin: top left; - animation: bitfun-logo-menu-in $motion-fast $easing-decelerate forwards; - - &.is-closing { - animation: bitfun-logo-menu-out $motion-fast $easing-accelerate forwards; - } - } - - &__menu-item { - display: flex; - align-items: center; - gap: $size-gap-2; - width: 100%; - padding: $size-gap-2 $size-gap-3; - border: none; - background: transparent; - color: var(--color-text-secondary); - cursor: pointer; - font-size: $font-size-sm; - text-align: left; - transition: color $motion-fast $easing-standard, background $motion-fast $easing-standard; - - &:hover { - color: var(--color-text-primary); - background: var(--element-bg-soft); - } - - svg { - flex-shrink: 0; - } - - span { - flex: 1; - } - } - - &__menu-item-main { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - } - - &__menu-item--workspace { - padding-right: $size-gap-2; - } - - &__menu-divider { - height: 1px; - background: var(--border-subtle); - margin: $size-gap-1 0; - } - - &__menu-section-title { - display: flex; - align-items: center; - gap: $size-gap-1; - padding: $size-gap-1 $size-gap-3; - color: var(--color-text-muted); - font-size: $font-size-xs; - text-transform: uppercase; - letter-spacing: 0.04em; - } - - &__menu-empty { - padding: $size-gap-2 $size-gap-3; - color: var(--color-text-muted); - font-size: $font-size-xs; - } - - &__menu-workspaces { - max-height: 240px; - overflow-y: auto; - overscroll-behavior: contain; - } } @media (prefers-reduced-motion: reduce) { .bitfun-nav-bar__btn, - .bitfun-nav-bar__menu-item, .bitfun-nav-bar__panel-toggle { transition: none; } - - .bitfun-nav-bar__menu, - .bitfun-nav-bar__menu.is-closing { - animation: none; - } } diff --git a/src/web-ui/src/app/components/NavBar/NavBar.tsx b/src/web-ui/src/app/components/NavBar/NavBar.tsx index 7bd6ba46..792fe784 100644 --- a/src/web-ui/src/app/components/NavBar/NavBar.tsx +++ b/src/web-ui/src/app/components/NavBar/NavBar.tsx @@ -9,12 +9,10 @@ * - WindowControls (minimize/maximize/close) replace the old TitleBar chrome. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { ArrowLeft, ArrowRight, FolderOpen, FolderPlus, History, Check } from 'lucide-react'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; import { Tooltip } from '@/component-library'; import { useNavSceneStore } from '../../stores/navSceneStore'; -import { useWorkspaceContext } from '../../../infrastructure/contexts/WorkspaceContext'; import { useI18n } from '../../../infrastructure/i18n'; import { PanelLeftIcon } from '../TitleBar/PanelIcons'; import { createLogger } from '@/shared/utils/logger'; @@ -54,12 +52,6 @@ const NavBar: React.FC = ({ const goForward = useNavSceneStore(s => s.goForward); const canGoBack = showSceneNav && !!navSceneId; const canGoForward = !showSceneNav && !!navSceneId; - const { currentWorkspace, recentWorkspaces, openWorkspace, switchWorkspace } = useWorkspaceContext(); - const [showLogoMenu, setShowLogoMenu] = useState(false); - const [logoMenuClosing, setLogoMenuClosing] = useState(false); - const [menuPos, setMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); - const containerRef = useRef(null); - const menuPortalRef = useRef(null); const lastMouseDownTimeRef = useRef(0); const handleBarMouseDown = useCallback((e: React.MouseEvent) => { @@ -90,150 +82,11 @@ const NavBar: React.FC = ({ onMaximize?.(); }, [onMaximize]); - const closeLogoMenu = useCallback(() => { - setLogoMenuClosing(true); - setTimeout(() => { - setShowLogoMenu(false); - setLogoMenuClosing(false); - }, 150); - }, []); - - const openLogoMenu = useCallback(() => { - const btn = containerRef.current?.querySelector('.bitfun-nav-bar__logo-button'); - if (btn) { - const rect = btn.getBoundingClientRect(); - setMenuPos({ top: rect.bottom + 4, left: rect.left }); - } - setShowLogoMenu(true); - }, []); - - useEffect(() => { - if (!showLogoMenu) return; - const onMouseDown = (event: MouseEvent) => { - const target = event.target as Node | null; - if (!target) return; - if (containerRef.current?.contains(target)) return; - if (menuPortalRef.current?.contains(target)) return; - closeLogoMenu(); - }; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') closeLogoMenu(); - }; - document.addEventListener('mousedown', onMouseDown); - document.addEventListener('keydown', onKeyDown); - return () => { - document.removeEventListener('mousedown', onMouseDown); - document.removeEventListener('keydown', onKeyDown); - }; - }, [showLogoMenu, closeLogoMenu]); - - const handleOpenProject = useCallback(async () => { - closeLogoMenu(); - try { - const { open } = await import('@tauri-apps/plugin-dialog'); - const selected = await open({ directory: true, multiple: false }) as string; - if (selected) await openWorkspace(selected); - } catch {} - }, [closeLogoMenu, openWorkspace]); - - const handleNewProject = useCallback(() => { - closeLogoMenu(); - window.dispatchEvent(new CustomEvent('nav:new-project')); - }, [closeLogoMenu]); - - const handleSwitchWorkspace = useCallback(async (workspaceId: string) => { - const targetWorkspace = recentWorkspaces.find(item => item.id === workspaceId); - if (!targetWorkspace) return; - closeLogoMenu(); - try { - await switchWorkspace(targetWorkspace); - } catch {} - }, [closeLogoMenu, recentWorkspaces, switchWorkspace]); - const recentWorkspaceItems = useMemo( - () => - recentWorkspaces.map((workspace) => ( - - - - )), - [recentWorkspaces, handleSwitchWorkspace, currentWorkspace?.id] - ); - - const logoMenuPortal = showLogoMenu - ? createPortal( -
- {!isMacOS && ( - <> - - -
- - )} -
-
- - {recentWorkspaceItems.length === 0 ? ( -
- {t('header.noRecentWorkspaces')} -
- ) : ( -
{recentWorkspaceItems}
- )} -
, - document.body - ) - : null; - const rootClassName = `bitfun-nav-bar${isCollapsed ? ' bitfun-nav-bar--collapsed' : ''}${isMacOS ? ' bitfun-nav-bar--macos' : ''} ${className}`; if (isCollapsed) { return (
-
- - {logoMenuPortal} -
- {logoMenuPortal} -
+ {/* Back / Forward */} diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 71cd29ae..d1cccf8d 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -1,7 +1,7 @@ /** * MainNav — default workspace navigation sidebar. * - * Renders WorkspaceHeader and nav sections. When a scene-nav transition + * Renders nav sections. When a scene-nav transition * is active (`isDeparting=true`), every item/section receives a positional * CSS class relative to the anchor item (`anchorNavSceneId`): * - items above the anchor → `.is-departing-up` (slide up + fade) @@ -11,8 +11,9 @@ * the outer Grid accordion handles the actual height collapse. */ -import React, { useCallback, useState, useMemo, useEffect } from 'react'; -import { Plus } from 'lucide-react'; +import React, { useCallback, useState, useMemo, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { Plus, FolderOpen, FolderPlus, History, Check, Code2, Users } from 'lucide-react'; import { useApp } from '../../hooks/useApp'; import { useSceneManager } from '../../hooks/useSceneManager'; import { useNavSceneStore } from '../../stores/navSceneStore'; @@ -24,17 +25,14 @@ import type { NavItem as NavItemConfig } from './types'; import type { SceneTabId } from '../SceneBar/types'; import NavItem from './components/NavItem'; import SectionHeader from './components/SectionHeader'; -import SessionsSection from './sections/sessions/SessionsSection'; -import ShellsSection from './sections/shells/ShellsSection'; -import ShellHubSection from './sections/shell-hub/ShellHubSection'; -import GitSection from './sections/git/GitSection'; -import TeamSection from './sections/team/TeamSection'; -import SkillsSection from './sections/skills/SkillsSection'; -import ToolboxSection from './sections/toolbox/ToolboxSection'; -import WorkspaceHeader from './components/WorkspaceHeader'; +import ToolboxEntry from './components/ToolboxEntry'; +import WorkspaceListSection from './sections/workspaces/WorkspaceListSection'; import { useSceneStore } from '../../stores/sceneStore'; +import { useMyAgentStore } from '../../scenes/my-agent/myAgentStore'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createLogger } from '@/shared/utils/logger'; const DEFAULT_MODE_CONFIG_KEY = 'app.session_config.default_mode'; @@ -42,16 +40,6 @@ import './NavPanel.scss'; const log = createLogger('MainNav'); -const INLINE_SECTIONS: Partial> = { - sessions: SessionsSection, - terminal: ShellsSection, - 'shell-hub': ShellHubSection, - git: GitSection, - team: TeamSection, - skills: SkillsSection, - toolbox: ToolboxSection, -}; - type DepartDir = 'up' | 'anchor' | 'down' | null; /** @@ -88,9 +76,15 @@ const MainNav: React.FC = ({ const { openScene } = useSceneManager(); const openNavScene = useNavSceneStore(s => s.openNavScene); const activeTabId = useSceneStore(s => s.activeTabId); + const setMyAgentView = useMyAgentStore(s => s.setActiveView); const { t } = useI18n('common'); + const { currentWorkspace, recentWorkspaces, switchWorkspace } = useWorkspaceContext(); const activeTab = state.layout.leftPanelActiveTab; + const activeMiniAppId = useMemo( + () => (typeof activeTabId === 'string' && activeTabId.startsWith('miniapp:') ? activeTabId.slice('miniapp:'.length) : null), + [activeTabId] + ); const anchorIdx = useMemo(() => getAnchorIndex(anchorNavSceneId), [anchorNavSceneId]); @@ -125,22 +119,17 @@ const MainNav: React.FC = ({ }); return init; }); - - const [inlineExpanded, setInlineExpanded] = useState>( - () => new Set(['sessions']) - ); - - React.useEffect(() => { - if (activeTabId === 'git') { - setInlineExpanded(prev => (prev.has('git') ? prev : new Set([...prev, 'git']))); - } - if (activeTabId === 'team') { - setInlineExpanded(prev => (prev.has('team') ? prev : new Set([...prev, 'team']))); - } - if (activeTabId === 'skills') { - setInlineExpanded(prev => (prev.has('skills') ? prev : new Set([...prev, 'skills']))); - } - }, [activeTabId]); + const workspaceMenuButtonRef = useRef(null); + const workspaceMenuRef = useRef(null); + const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false); + const [workspaceMenuClosing, setWorkspaceMenuClosing] = useState(false); + const [workspaceMenuPos, setWorkspaceMenuPos] = useState({ top: 0, left: 0 }); + + const modeDropdownButtonRef = useRef(null); + const modeDropdownRef = useRef(null); + const [modeDropdownOpen, setModeDropdownOpen] = useState(false); + const [modeDropdownClosing, setModeDropdownClosing] = useState(false); + const [modeDropdownPos, setModeDropdownPos] = useState({ top: 0, left: 0 }); const getSectionLabel = useCallback( (sectionId: string, fallbackLabel: string | null) => { @@ -148,7 +137,7 @@ const MainNav: React.FC = ({ const keyMap: Record = { workspace: 'nav.sections.workspace', 'my-agent': 'nav.sections.myAgent', - 'dev-suite': 'nav.sections.devSuite', + toolbox: 'scenes.toolbox', }; const key = keyMap[sectionId]; return key ? t(key) || fallbackLabel : fallbackLabel; @@ -164,30 +153,34 @@ const MainNav: React.FC = ({ }); }, []); - const handleItemClick = useCallback( - (tab: PanelType, item: NavItemConfig) => { - if (item.inlineExpandable) { - setInlineExpanded(prev => { - const next = new Set(prev); - next.has(tab) ? next.delete(tab) : next.add(tab); - return next; - }); - return; - } + const closeWorkspaceMenu = useCallback(() => { + setWorkspaceMenuClosing(true); + window.setTimeout(() => { + setWorkspaceMenuOpen(false); + setWorkspaceMenuClosing(false); + }, 150); + }, []); - if (item.behavior === 'scene' && item.sceneId) { - openScene(item.sceneId); - } else { - if (item.navSceneId) { - openNavScene(item.navSceneId); - } - switchLeftPanelTab(tab); - } - }, - [switchLeftPanelTab, openScene, openNavScene] - ); + const openWorkspaceMenu = useCallback(() => { + const rect = workspaceMenuButtonRef.current?.getBoundingClientRect(); + if (!rect) return; + setWorkspaceMenuPos({ + top: rect.bottom + 6, + left: rect.left, + }); + setWorkspaceMenuOpen(true); + setWorkspaceMenuClosing(false); + }, []); - const sessionMode = useSessionModeStore(s => s.mode); + const toggleWorkspaceMenu = useCallback(() => { + if (workspaceMenuOpen) { + closeWorkspaceMenu(); + return; + } + openWorkspaceMenu(); + }, [closeWorkspaceMenu, openWorkspaceMenu, workspaceMenuOpen]); + + const setSessionMode = useSessionModeStore(s => s.setMode); const [defaultSessionMode, setDefaultSessionMode] = useState<'code' | 'cowork'>('code'); @@ -204,29 +197,288 @@ const MainNav: React.FC = ({ return () => unwatch(); }, []); + const closeModeDropdown = useCallback(() => { + setModeDropdownClosing(true); + window.setTimeout(() => { + setModeDropdownOpen(false); + setModeDropdownClosing(false); + }, 150); + }, []); + + const openModeDropdown = useCallback(() => { + const rect = modeDropdownButtonRef.current?.getBoundingClientRect(); + if (!rect) return; + setModeDropdownPos({ + top: rect.bottom + 4, + left: rect.left, + }); + setModeDropdownOpen(true); + setModeDropdownClosing(false); + }, []); + + const toggleModeDropdown = useCallback(() => { + if (modeDropdownOpen) { + closeModeDropdown(); + return; + } + openModeDropdown(); + }, [closeModeDropdown, openModeDropdown, modeDropdownOpen]); + + const handleSetDefaultMode = useCallback(async (mode: 'code' | 'cowork') => { + closeModeDropdown(); + setDefaultSessionMode(mode); + setSessionMode(mode); + try { + await configManager.setConfig(DEFAULT_MODE_CONFIG_KEY, mode); + } catch (err) { + log.error('Failed to save default session mode', err); + } + }, [closeModeDropdown, setSessionMode]); + + const handleItemClick = useCallback( + (tab: PanelType, item: NavItemConfig) => { + if (item.behavior === 'scene' && item.sceneId) { + openScene(item.sceneId); + } else { + if (item.navSceneId) { + openNavScene(item.navSceneId); + } + switchLeftPanelTab(tab); + } + }, + [switchLeftPanelTab, openScene, openNavScene] + ); + const handleCreateSession = useCallback(async () => { openScene('session'); switchLeftPanelTab('sessions'); try { await flowChatManager.createChatSession( { modelName: 'claude-sonnet-4.5' }, - sessionMode === 'cowork' ? 'Cowork' : 'agentic' + defaultSessionMode === 'cowork' ? 'Cowork' : 'agentic' ); } catch (err) { log.error('Failed to create session', err); } - }, [openScene, switchLeftPanelTab, sessionMode]); + }, [openScene, switchLeftPanelTab, defaultSessionMode]); + + const handleOpenProject = useCallback(async () => { + try { + const { open } = await import('@tauri-apps/plugin-dialog'); + const selected = await open({ directory: true, multiple: false, title: t('header.selectProjectDirectory') }); + if (selected && typeof selected === 'string') { + await workspaceManager.openWorkspace(selected); + } + } catch (err) { + log.error('Failed to open project', err); + } + }, [t]); + + const handleNewProject = useCallback(() => { + window.dispatchEvent(new Event('nav:new-project')); + }, []); + + const handleSwitchWorkspace = useCallback(async (workspaceId: string) => { + const targetWorkspace = recentWorkspaces.find(item => item.id === workspaceId); + if (!targetWorkspace) return; + closeWorkspaceMenu(); + try { + await switchWorkspace(targetWorkspace); + } catch (err) { + log.error('Failed to switch workspace', err); + } + }, [closeWorkspaceMenu, recentWorkspaces, switchWorkspace]); + + useEffect(() => { + if (!workspaceMenuOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node | null; + if (!target) return; + if (workspaceMenuButtonRef.current?.contains(target)) return; + if (workspaceMenuRef.current?.contains(target)) return; + closeWorkspaceMenu(); + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeWorkspaceMenu(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [closeWorkspaceMenu, workspaceMenuOpen]); + + useEffect(() => { + if (!modeDropdownOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node | null; + if (!target) return; + if (modeDropdownButtonRef.current?.contains(target)) return; + if (modeDropdownRef.current?.contains(target)) return; + closeModeDropdown(); + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') closeModeDropdown(); + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [closeModeDropdown, modeDropdownOpen]); + + const handleOpenProfile = useCallback(() => { + setMyAgentView('profile'); + switchLeftPanelTab('profile'); + openScene('my-agent'); + }, [openScene, setMyAgentView, switchLeftPanelTab]); let flatCounter = 0; + const workspaceMenuPortal = workspaceMenuOpen ? createPortal( +
+ + +
+
+
+ {recentWorkspaces.length === 0 ? ( +
+ {t('header.noRecentWorkspaces')} +
+ ) : ( +
+ {recentWorkspaces.map((workspace) => ( + + ))} +
+ )} +
, + document.body + ) : null; + + const ModeIcon = defaultSessionMode === 'cowork' ? Users : Code2; + + const modeDropdownPortal = modeDropdownOpen ? createPortal( +
+ + +
, + document.body + ) : null; + return ( <> -
- +
+ +
+ + +
- {NAV_SECTIONS.map(section => { + {NAV_SECTIONS.filter(section => section.id !== 'toolbox').map(section => { const isSectionOpen = expandedSections.has(section.id); const isCollapsible = !!section.collapsible; const showItems = !isCollapsible || isSectionOpen; @@ -241,50 +493,57 @@ const MainNav: React.FC = ({ collapsible={isCollapsible} isOpen={isSectionOpen} onToggle={() => toggleSection(section.id)} + onSceneOpen={section.sceneId ? () => openScene(section.sceneId) : undefined} + actions={section.id === 'workspace' ? ( +
+ +
+ ) : undefined} /> )}
+ {section.id === 'workspace' && } {section.items.map(item => { const currentFlatIdx = flatCounter++; const { tab } = item; const dir = getDepartDir(currentFlatIdx); - const isActive = item.inlineExpandable || item.navSceneId + const isActive = tab === 'toolbox' + ? activeTabId === 'toolbox' + : item.navSceneId ? false : item.sceneId ? item.sceneId === activeTabId : activeTabId === 'session' && tab === activeTab; - const isOpen = !!item.inlineExpandable && inlineExpanded.has(tab); - const InlineContent = INLINE_SECTIONS[tab]; - const displayLabel = item.labelKey ? t(item.labelKey) : (item.label ?? ''); + const displayLabel = item.labelKey ? t(item.labelKey) : (item.label ?? ''); const tooltipContent = item.tooltipKey ? t(item.tooltipKey) : undefined; const departCls = dir ? ` is-departing-${dir}` : ''; return ( - -
- handleItemClick(tab, item)} - actionIcon={tab === 'sessions' ? Plus : undefined} - actionTitle={tab === 'sessions' ? (defaultSessionMode === 'cowork' ? t('nav.sessions.newCoworkSession') : t('nav.sessions.newCodeSession')) : undefined} - onActionClick={tab === 'sessions' ? handleCreateSession : undefined} - /> -
- {InlineContent && ( -
-
- -
-
- )} -
+
+ handleItemClick(tab, item)} + actionIcon={tab === 'sessions' ? Plus : undefined} + actionTitle={tab === 'sessions' ? (defaultSessionMode === 'cowork' ? t('nav.sessions.newCoworkSession') : t('nav.sessions.newCodeSession')) : undefined} + onActionClick={tab === 'sessions' ? handleCreateSession : undefined} + /> +
); })}
@@ -294,6 +553,18 @@ const MainNav: React.FC = ({ ); })}
+ +
+ openScene('toolbox')} + onOpenMiniApp={(appId) => openScene(`miniapp:${appId}`)} + /> +
+ + {workspaceMenuPortal} + {modeDropdownPortal} ); }; diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 083bae4d..588de454 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -91,13 +91,9 @@ $_section-header-height: 24px; opacity: 1; } - &__section { - margin-bottom: $size-gap-2; - } - // Split-open scenes (file-viewer): clip-path reveal from anchor. // The opaque background ensures revealed portions fully cover MainNav - // so departing items (e.g. WorkspaceHeader) never bleed through. + // so departing items never bleed through. .is-split-open & { clip-path: inset(var(--clip-origin-top) 0 var(--clip-origin-bottom) 0); transition: clip-path 0.26s cubic-bezier(0.22, 1, 0.36, 1), @@ -117,6 +113,223 @@ $_section-header-height: 24px; flex-direction: column; } + &__workspace-toolbar { + display: flex; + align-items: center; + gap: $size-gap-2; + margin: 0 $size-gap-2 $size-gap-1; + padding: 0 $size-gap-1; + overflow: visible; + } + + &__workspace-bot { + width: 52px; + height: 52px; + padding: 0; + border: none; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: transparent; + color: var(--color-text-secondary); + position: relative; + z-index: 1; + overflow: hidden; + cursor: pointer; + transition: transform $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 28%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: 2px; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + position: absolute; + top: 0; + left: 0; + } + + &-default { + z-index: 1; + } + + &-hover { + z-index: 2; + opacity: 0; + transition: opacity $motion-fast $easing-standard; + } + + &:hover &-hover { + opacity: 1; + } + } + + &__workspace-create-group { + flex: 1; + display: flex; + align-items: stretch; + gap: 1px; + height: 36px; + min-width: 0; + } + + &__workspace-create-main { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 0; + padding: 0 $size-gap-2; + border: none; + border-radius: $size-radius-base 0 0 $size-radius-base; + background: var(--element-bg-soft); + color: var(--color-primary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background $motion-fast $easing-standard, + color $motion-fast $easing-standard, + transform $motion-fast $easing-standard; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + svg { flex-shrink: 0; } + + &:hover { + background: var(--element-bg-medium); + color: var(--color-text-primary); + } + + &:active { transform: translateY(1px); } + + &:focus-visible { + outline: 1px solid var(--color-accent-500); + outline-offset: 1px; + } + } + + &__workspace-create-mode { + width: 28px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: 0 $size-radius-base $size-radius-base 0; + background: var(--element-bg-soft); + color: var(--color-text-muted); + cursor: pointer; + transition: background $motion-fast $easing-standard, + color $motion-fast $easing-standard, + transform $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-medium); + color: var(--color-text-primary); + } + + &.is-active { + background: var(--element-bg-medium); + color: var(--color-primary); + } + + &:active { transform: translateY(1px); } + + &:focus-visible { + outline: 1px solid var(--color-accent-500); + outline-offset: 1px; + } + } + + // ────────────────────────────────────────────── + // Session mode dropdown + // ────────────────────────────────────────────── + + &__mode-dropdown { + position: fixed; + min-width: 180px; + padding: $size-gap-1 0; + background: var(--color-bg-elevated, #1e1e22); + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + border-radius: $size-radius-base; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + z-index: 9999; + transform-origin: top left; + animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; + + &.is-closing { + animation: bitfun-footer-menu-out $motion-fast $easing-accelerate forwards; + } + } + + &__mode-dropdown-item { + display: flex; + align-items: center; + gap: $size-gap-2; + width: 100%; + padding: $size-gap-2 $size-gap-3; + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + font-size: $font-size-sm; + text-align: left; + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + &:hover { + color: var(--color-text-primary); + background: var(--element-bg-soft); + } + + &.is-active { + color: var(--color-text-primary); + } + } + + &__mode-dropdown-item-icon { + flex-shrink: 0; + + &.is-code { + color: color-mix(in srgb, var(--color-primary) 75%, var(--color-text-muted)); + } + + &.is-cowork { + color: color-mix(in srgb, var(--color-accent-500) 75%, var(--color-text-muted)); + } + } + + &__mode-dropdown-item-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__mode-dropdown-item-check { + flex-shrink: 0; + color: var(--color-primary); + margin-left: auto; + } + // ────────────────────────────────────────────── // Scrollable sections area // ────────────────────────────────────────────── @@ -142,15 +355,16 @@ $_section-header-height: 24px; &__section-header { display: flex; align-items: center; + gap: $size-gap-1; height: $_section-header-height; padding: 0 $size-gap-3; margin: 0 $size-gap-2; border-radius: 4px; - opacity: 0.4; + opacity: 0.72; transition: background $motion-fast $easing-standard, opacity $motion-fast $easing-standard; - &--collapsible { + &--interactive { cursor: pointer; &:hover { @@ -175,11 +389,11 @@ $_section-header-height: 24px; } &__section-label { - font-size: 10px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.06em; text-transform: uppercase; - color: var(--color-text-muted); + color: var(--color-text-secondary); flex: 1; white-space: nowrap; overflow: hidden; @@ -187,6 +401,313 @@ $_section-header-height: 24px; transition: color $motion-fast $easing-standard; } + &__section-header--scene-entry &__section-label { + font-size: 13px; + font-weight: 600; + letter-spacing: normal; + text-transform: none; + color: var(--color-text-primary); + } + + &__toolbox-footer { + flex-shrink: 0; + padding: $size-gap-1 $size-gap-2 $size-gap-2; + } + + &__toolbox-entry-wrap { + margin: 0; + } + + &__toolbox-entry { + width: 100%; + min-height: 42px; + display: flex; + align-items: center; + gap: $size-gap-2; + padding: 8px 10px 8px 12px; + border: 1px dashed color-mix(in srgb, var(--border-subtle) 90%, transparent); + border-radius: 10px; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + text-align: left; + transition: border-color $motion-fast $easing-standard, + background $motion-fast $easing-standard, + color $motion-fast $easing-standard, + transform $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard; + + &:hover { + color: var(--color-text-primary); + background: color-mix(in srgb, var(--element-bg-soft) 78%, transparent); + border-color: color-mix(in srgb, var(--color-primary) 28%, var(--border-subtle)); + } + + &:active { + transform: translateY(1px); + background: color-mix(in srgb, var(--element-bg-medium) 82%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: 0; + } + + &.has-running-apps { + border-style: dashed; + } + + &.is-active { + color: var(--color-text-primary); + background: color-mix(in srgb, var(--element-bg-soft) 88%, transparent); + border-color: color-mix(in srgb, var(--color-primary) 50%, var(--border-subtle)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent); + } + } + + &__toolbox-entry-main { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + flex: 1; + } + + &__toolbox-entry-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: inherit; + opacity: 0.72; + transition: opacity $motion-fast $easing-standard; + + .bitfun-nav-panel__toolbox-entry:hover &, + .bitfun-nav-panel__toolbox-entry.is-active & { + opacity: 1; + } + } + + &__toolbox-entry-copy { + min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; + } + + &__toolbox-entry-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 500; + line-height: 1.1; + opacity: 0.72; + transition: opacity $motion-fast $easing-standard; + + .bitfun-nav-panel__toolbox-entry:hover &, + .bitfun-nav-panel__toolbox-entry.is-active & { + opacity: 1; + } + } + + &__toolbox-entry-apps { + display: flex; + align-items: center; + justify-content: flex-end; + margin-left: auto; + min-width: 0; + } + + &__toolbox-app-bubble { + width: 26px; + height: 26px; + border-radius: 999px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-primary); + border: none; + box-shadow: none; + overflow: hidden; + transition: transform $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard; + + & + & { + margin-left: -8px; + } + + &:hover { + transform: translateY(-2px) scale(1.06); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.24); + } + + &.is-active { + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22), + 0 0 0 3px color-mix(in srgb, var(--color-primary) 28%, transparent); + } + + &--more { + background: color-mix(in srgb, var(--element-bg-medium) 92%, transparent); + color: var(--color-text-secondary); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.01em; + } + } + + &__section-actions { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; + opacity: 1; + } + + &__section-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); + opacity: 0.7; + transition: color $motion-fast $easing-standard, + opacity $motion-fast $easing-standard, + transform $motion-fast $easing-standard; + + .bitfun-nav-panel__section-header--scene-link:hover & { + color: var(--color-text-secondary); + opacity: 1; + transform: translateX(1px); + } + } + + &__section-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + &:hover { + color: var(--color-text-primary); + background: var(--element-bg-soft); + } + + &.is-active { + color: var(--color-text-primary); + background: var(--element-bg-medium); + } + + &:active { + background: var(--element-bg-medium); + } + + &:focus-visible { + outline: 1px solid var(--color-accent-500); + outline-offset: 1px; + } + } + + &__workspace-action-wrap { + position: relative; + } + + &__workspace-menu { + position: fixed; + min-width: 156px; + padding: $size-gap-1 0; + background: var(--color-bg-elevated, #1e1e22); + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + border-radius: $size-radius-base; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + z-index: 9999; + transform-origin: top left; + animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; + + &.is-closing { + animation: bitfun-footer-menu-out $motion-fast $easing-accelerate forwards; + } + } + + &__workspace-menu-item { + display: flex; + align-items: center; + gap: $size-gap-2; + width: 100%; + padding: $size-gap-2 $size-gap-3; + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + font-size: $font-size-sm; + text-align: left; + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + &:hover { + color: var(--color-text-primary); + background: var(--element-bg-soft); + } + + svg { + flex-shrink: 0; + } + + span { + flex: 1; + } + + &--workspace { + padding-right: $size-gap-2; + } + } + + &__workspace-menu-item-main { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__workspace-menu-divider { + height: 1px; + margin: $size-gap-1 0; + background: var(--border-subtle); + } + + &__workspace-menu-section-title { + display: flex; + align-items: center; + gap: $size-gap-1; + padding: $size-gap-1 $size-gap-3; + color: var(--color-text-muted); + font-size: $font-size-xs; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__workspace-menu-empty { + padding: $size-gap-2 $size-gap-3; + color: var(--color-text-muted); + font-size: $font-size-xs; + } + + &__workspace-menu-workspaces { + max-height: 240px; + overflow-y: auto; + overscroll-behavior: contain; + } + // ────────────────────────────────────────────── // Collapsible wrapper (grid-template-rows 0fr→1fr) // ────────────────────────────────────────────── @@ -198,6 +719,10 @@ $_section-header-height: 24px; transform $motion-base $easing-decelerate, opacity $motion-base $easing-decelerate; + &.is-hidden { + display: none; + } + &.is-collapsed { grid-template-rows: 0fr; } @@ -234,16 +759,7 @@ $_section-header-height: 24px; $_depart-duration: 0.24s; $_depart-easing: cubic-bezier(0.4, 0, 0.2, 1); - &__workspace-header-slot { - flex-shrink: 0; - transition: opacity $_depart-duration $_depart-easing; - - &.is-departing-up { - opacity: 0; - } - } - // Inline expanded sections (e.g. SessionsSection, GitSection) - // also depart when their parent item departs. + // Item slots depart with the split-open animation used by scene navs. &__section { margin-bottom: $size-gap-2; @@ -699,7 +1215,6 @@ $_section-header-height: 24px; &__item, &__item-slot, &__item-icon, - &__workspace-header-slot, &__section, &__section-label, &__layer--scene, diff --git a/src/web-ui/src/app/components/NavPanel/components/NavItem.tsx b/src/web-ui/src/app/components/NavPanel/components/NavItem.tsx index d03eef96..83d7dfc6 100644 --- a/src/web-ui/src/app/components/NavPanel/components/NavItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/NavItem.tsx @@ -1,7 +1,7 @@ /** * NavItem — a single navigation row inside the NavPanel. * - * Renders icon + label + optional expand chevron + optional badge. + * Renders icon + label + optional badge. * Scene-type items display a compact badge (e.g. git branch name). * Optional action icon (e.g. Plus for new session) for quick actions. */ @@ -18,7 +18,6 @@ interface NavItemProps { /** Custom tooltip content (overrides displayLabel as tooltip when provided) */ tooltipContent?: string; isActive: boolean; - isOpen?: boolean; /** Optional badge text shown at the right (e.g. branch name) */ badge?: string; /** Called when badge area is clicked (e.g. open BranchQuickSwitch) */ @@ -39,7 +38,6 @@ const NavItem: React.FC = ({ displayLabel, tooltipContent, isActive, - isOpen = false, badge, onBadgeClick, actionIcon: ActionIcon, @@ -48,7 +46,7 @@ const NavItem: React.FC = ({ renderActions, onClick, }) => { - const { Icon, inlineExpandable } = item; + const { Icon } = item; const badgeRef = useRef(null); const handleBadgeClick = (e: React.MouseEvent) => { @@ -69,12 +67,10 @@ const NavItem: React.FC = ({ className={[ 'bitfun-nav-panel__item', isActive && 'is-active', - isOpen && 'is-open', ] .filter(Boolean) .join(' ')} onClick={onClick} - aria-expanded={inlineExpandable ? isOpen : undefined} >
+ + + + setShowAbout(false)} /> setShowRemoteConnect(false)} /> diff --git a/src/web-ui/src/app/components/NavPanel/components/SectionHeader.tsx b/src/web-ui/src/app/components/NavPanel/components/SectionHeader.tsx index 8656766f..ce891682 100644 --- a/src/web-ui/src/app/components/NavPanel/components/SectionHeader.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/SectionHeader.tsx @@ -1,14 +1,17 @@ /** - * SectionHeader — collapsible (or static) section title row in NavPanel. + * SectionHeader — collapsible, scene-opening, or static section title row. */ -import React from 'react'; +import React, { useCallback } from 'react'; +import { ChevronRight } from 'lucide-react'; interface SectionHeaderProps { label: string; collapsible: boolean; isOpen: boolean; onToggle?: () => void; + onSceneOpen?: () => void; + actions?: React.ReactNode; } const SectionHeader: React.FC = ({ @@ -16,27 +19,63 @@ const SectionHeader: React.FC = ({ collapsible, isOpen, onToggle, -}) => ( -
{ - if (e.key === 'Enter' || e.key === ' ') onToggle?.(); - } - : undefined + onSceneOpen, + actions, +}) => { + const isInteractive = collapsible || !!onSceneOpen; + const isSceneEntry = !collapsible && !!onSceneOpen; + + const handleActivate = useCallback(() => { + if (collapsible) { + onToggle?.(); + return; } - > - {label} -
-); + onSceneOpen?.(); + }, [collapsible, onSceneOpen, onToggle]); + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleActivate(); + } + } + : undefined + } + > + {label} + {onSceneOpen ? ( + + ) : null} + {actions ? ( +
e.stopPropagation()} + onKeyDown={e => e.stopPropagation()} + > + {actions} +
+ ) : null} +
+ ); +}; export default SectionHeader; diff --git a/src/web-ui/src/app/components/NavPanel/components/ToolboxEntry.tsx b/src/web-ui/src/app/components/NavPanel/components/ToolboxEntry.tsx new file mode 100644 index 00000000..dfc87e21 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/components/ToolboxEntry.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useMemo, useCallback } from 'react'; +import { Boxes } from 'lucide-react'; +import { Tooltip } from '@/component-library'; +import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; +import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { useToolboxStore } from '@/app/scenes/toolbox/toolboxStore'; +import { renderMiniAppIcon, getMiniAppIconGradient } from '@/app/scenes/toolbox/utils/miniAppIcons'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('ToolboxEntry'); +const MAX_VISIBLE_RUNNING_APPS = 3; + +interface ToolboxEntryProps { + isActive: boolean; + activeMiniAppId?: string | null; + onOpenToolbox: () => void; + onOpenMiniApp: (appId: string) => void; +} + +const ToolboxEntry: React.FC = ({ + isActive, + activeMiniAppId = null, + onOpenToolbox, + onOpenMiniApp, +}) => { + const { t } = useI18n('common'); + const apps = useToolboxStore((s) => s.apps); + const setApps = useToolboxStore((s) => s.setApps); + const runningWorkerIds = useToolboxStore((s) => s.runningWorkerIds); + const setRunningWorkerIds = useToolboxStore((s) => s.setRunningWorkerIds); + const markWorkerRunning = useToolboxStore((s) => s.markWorkerRunning); + const markWorkerStopped = useToolboxStore((s) => s.markWorkerStopped); + + const refreshApps = useCallback(async () => { + try { + const nextApps = await miniAppAPI.listMiniApps(); + setApps(nextApps); + } catch (error) { + log.error('Failed to refresh miniapps for toolbox entry', error); + } + }, [setApps]); + + useEffect(() => { + void refreshApps(); + miniAppAPI.workerListRunning().then(setRunningWorkerIds).catch(() => {}); + + const unlistenCreated = api.listen('miniapp-created', () => { + void refreshApps(); + }); + const unlistenUpdated = api.listen('miniapp-updated', () => { + void refreshApps(); + }); + const unlistenDeleted = api.listen<{ id?: string }>('miniapp-deleted', (payload) => { + if (payload?.id) { + markWorkerStopped(payload.id); + } + void refreshApps(); + }); + const unlistenRestarted = api.listen<{ id?: string }>('miniapp-worker-restarted', (payload) => { + if (payload?.id) { + markWorkerRunning(payload.id); + } + }); + const unlistenStopped = api.listen<{ id?: string }>('miniapp-worker-stopped', (payload) => { + if (payload?.id) { + markWorkerStopped(payload.id); + } + }); + + return () => { + unlistenCreated(); + unlistenUpdated(); + unlistenDeleted(); + unlistenRestarted(); + unlistenStopped(); + }; + }, [markWorkerRunning, markWorkerStopped, refreshApps, setRunningWorkerIds]); + + const runningApps = useMemo(() => { + const appMap = new Map(apps.map((app) => [app.id, app])); + const list = runningWorkerIds + .map((id) => appMap.get(id)) + .filter((app): app is NonNullable => !!app); + + if (!activeMiniAppId) { + return list; + } + + return [...list].sort((a, b) => { + if (a.id === activeMiniAppId) return -1; + if (b.id === activeMiniAppId) return 1; + return 0; + }); + }, [activeMiniAppId, apps, runningWorkerIds]); + + const visibleApps = runningApps.slice(0, MAX_VISIBLE_RUNNING_APPS); + const overflowCount = Math.max(0, runningApps.length - visibleApps.length); + + return ( +
+
0 && 'has-running-apps', + ].filter(Boolean).join(' ')} + onClick={onOpenToolbox} + onKeyDown={(e) => { + if (e.currentTarget !== e.target) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpenToolbox(); + } + }} + role="button" + tabIndex={0} + aria-label={t('scenes.toolbox')} + > + + + + {t('scenes.toolbox')} + + + + + {visibleApps.length > 0 ? ( + <> + {visibleApps.map((app) => { + const isAppActive = app.id === activeMiniAppId; + return ( + + { + e.stopPropagation(); + onOpenMiniApp(app.id); + }} + onMouseDown={(e) => e.stopPropagation()} + role="button" + tabIndex={0} + aria-label={app.name} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + onOpenMiniApp(app.id); + } + }} + > + {renderMiniAppIcon(app.icon || 'box', 14)} + + + ); + })} + {overflowCount > 0 ? ( + + +{overflowCount} + + ) : null} + + ) : null} + +
+
+ ); +}; + +export default ToolboxEntry; diff --git a/src/web-ui/src/app/components/NavPanel/components/WorkspaceHeader.scss b/src/web-ui/src/app/components/NavPanel/components/WorkspaceHeader.scss deleted file mode 100644 index 084819b6..00000000 --- a/src/web-ui/src/app/components/NavPanel/components/WorkspaceHeader.scss +++ /dev/null @@ -1,214 +0,0 @@ -/** - * WorkspaceHeader styles — NavPanel top workspace indicator. - */ - -@use '../../../../component-library/styles/tokens.scss' as *; - -.bitfun-workspace-header { - flex-shrink: 0; - margin: 0 $size-gap-2; - border: 1px dashed var(--border-subtle); - border-radius: $size-radius-base; - background: transparent; - overflow: hidden; - transition: border-color $motion-fast $easing-standard, - background $motion-fast $easing-standard, - box-shadow $motion-fast $easing-standard; - - // ── Card container ───────────────────────────────── - - &__card { - display: flex; - align-items: center; - gap: $size-gap-1; - width: 100%; - padding: $size-gap-2 $size-gap-2 $size-gap-2 $size-gap-3; - border-radius: 0; - background: transparent; - border: none; - cursor: pointer; - text-align: left; - transition: background $motion-fast $easing-standard; - - &:hover { - background: var(--element-bg-soft); - } - - &:focus-visible { - outline: none; - background: var(--element-bg-soft); - } - - &--empty { - gap: $size-gap-2; - } - } - - &__empty-icon { - color: var(--color-text-muted); - flex-shrink: 0; - } - - // ── Identity area (icon + name) ───────────────────── - - &__identity { - display: flex; - align-items: center; - gap: $size-gap-2; - flex: 1; - min-width: 0; - cursor: default; - } - - &__name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - font-size: $font-size-sm; - font-weight: 600; - color: var(--color-text-primary); - letter-spacing: 0.01em; - line-height: 1.2; - - &--muted { - font-weight: 500; - color: var(--color-text-muted); - font-size: $font-size-xs; - } - } - - &__branch { - display: inline-flex; - align-items: center; - gap: 3px; - flex-shrink: 0; - max-width: 96px; - padding: 1px $size-gap-1; - border-radius: $size-radius-sm; - background: var(--element-bg-soft); - color: var(--color-text-secondary); - font-size: $font-size-xs; - font-weight: 500; - line-height: 1.4; - - span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - svg { flex-shrink: 0; } - } - - // ── Dropdown menu ──────────────────────────────────── - - &__menu { - max-height: 0; - opacity: 0; - transform: translateY(-4px); - pointer-events: none; - overflow: hidden; - padding: 0; - transition: max-height 0.2s $easing-standard, - opacity 0.16s $easing-standard, - transform 0.2s $easing-standard; - } - - &__menu-item { - display: flex; - align-items: center; - gap: $size-gap-2; - width: 100%; - padding: $size-gap-2 $size-gap-3; - border: none; - background: transparent; - color: var(--color-text-secondary); - cursor: pointer; - font-size: $font-size-sm; - text-align: left; - transition: color $motion-fast $easing-standard, - background $motion-fast $easing-standard; - - &:hover { - color: var(--color-text-primary); - background: var(--element-bg-soft); - } - - svg { flex-shrink: 0; } - span { flex: 1; } - } - - &__menu-item-main { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - } - - &__menu-item--workspace { - padding-right: $size-gap-2; - } - - &__menu-item--open { - color: var(--color-text-secondary); - - &:hover { - color: var(--color-accent); - background: var(--element-bg-soft); - } - } - - &__menu-divider { - height: 1px; - background: var(--border-subtle); - margin: $size-gap-1 0; - } - - &__menu-section-title { - display: flex; - align-items: center; - gap: $size-gap-1; - padding: $size-gap-2 $size-gap-3 $size-gap-1; - color: var(--color-text-muted); - font-size: $font-size-xs; - text-transform: uppercase; - letter-spacing: 0.04em; - border-top: 1px dashed var(--border-subtle); - } - - &__menu-empty { - padding: $size-gap-2 $size-gap-3; - color: var(--color-text-muted); - font-size: $font-size-xs; - } - - &__menu-workspaces { - max-height: 220px; - overflow-y: auto; - overscroll-behavior: contain; - } - - &.is-expanded { - background: var(--color-bg-elevated); - border-color: var(--border-medium); - box-shadow: var(--shadow-md); - - .bitfun-workspace-header__menu { - max-height: 360px; - opacity: 1; - transform: translateY(0); - pointer-events: auto; - padding-bottom: $size-gap-1; - } - } -} - -@media (prefers-reduced-motion: reduce) { - .bitfun-workspace-header { - transition: none; - &__card { transition: none; } - &__menu { transition: none; } - &__menu-item { transition: none; } - } -} diff --git a/src/web-ui/src/app/components/NavPanel/components/WorkspaceHeader.tsx b/src/web-ui/src/app/components/NavPanel/components/WorkspaceHeader.tsx deleted file mode 100644 index 578db08a..00000000 --- a/src/web-ui/src/app/components/NavPanel/components/WorkspaceHeader.tsx +++ /dev/null @@ -1,206 +0,0 @@ -/** - * WorkspaceHeader — NavPanel top section showing current workspace name. - * - * Extracted from StatusBar. Displays folder icon + workspace name. - * Clicking the card opens a context-menu style dropdown to switch workspace. - * When no workspace is open, shows a prompt to open one. - */ - -import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; -import { FolderOpen, GitBranch, History, FolderSearch, Plus } from 'lucide-react'; -import { Tooltip } from '@/component-library'; -import { useCurrentWorkspace } from '../../../../infrastructure/contexts/WorkspaceContext'; -import { useWorkspaceContext } from '../../../../infrastructure/contexts/WorkspaceContext'; -import { useI18n } from '../../../../infrastructure/i18n'; -import { useGitBasicInfo } from '../../../../tools/git/hooks/useGitState'; -import './WorkspaceHeader.scss'; - -interface WorkspaceHeaderProps { - className?: string; -} - -const WorkspaceHeader: React.FC = ({ className = '' }) => { - const { t } = useI18n('common'); - const { workspaceName, workspacePath } = useCurrentWorkspace(); - const { currentWorkspace, recentWorkspaces, switchWorkspace, openWorkspace } = useWorkspaceContext(); - const { isRepository, currentBranch } = useGitBasicInfo(workspacePath || ''); - const [showMenu, setShowMenu] = useState(false); - const containerRef = useRef(null); - const visibleRecentWorkspaces = useMemo( - () => recentWorkspaces.filter(workspace => workspace.id !== currentWorkspace?.id), - [recentWorkspaces, currentWorkspace?.id] - ); - - const handleCardClick = useCallback(() => setShowMenu(p => !p), []); - - useEffect(() => { - if (!showMenu) return; - const onMouseDown = (e: MouseEvent) => { - if (!containerRef.current?.contains(e.target as Node)) setShowMenu(false); - }; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setShowMenu(false); - }; - document.addEventListener('mousedown', onMouseDown); - document.addEventListener('keydown', onKeyDown); - return () => { - document.removeEventListener('mousedown', onMouseDown); - document.removeEventListener('keydown', onKeyDown); - }; - }, [showMenu]); - - const handleSwitchWorkspace = useCallback(async (workspaceId: string) => { - const targetWorkspace = recentWorkspaces.find(item => item.id === workspaceId); - if (!targetWorkspace) return; - - setShowMenu(false); - try { - await switchWorkspace(targetWorkspace); - } catch {} - }, [recentWorkspaces, switchWorkspace]); - - const handleOpenFolder = useCallback(async () => { - setShowMenu(false); - try { - const { open } = await import('@tauri-apps/plugin-dialog'); - const selected = await open({ directory: true, multiple: false }); - if (selected && typeof selected === 'string') { - await openWorkspace(selected); - } - } catch {} - }, [openWorkspace]); - - // No workspace — show a placeholder card - if (!workspaceName) { - return ( -
- - -
- - - {visibleRecentWorkspaces.length > 0 && ( - <> -
-
-
- {visibleRecentWorkspaces.map((workspace) => ( - - - - ))} -
- - )} -
-
- ); - } - - const cardButton = ( - - ); - - return ( -
- {workspacePath ? ( - - {cardButton} - - ) : ( - cardButton - )} - -
-
-
- - {visibleRecentWorkspaces.length === 0 ? ( -
- {t('header.noRecentWorkspaces')} -
- ) : ( -
- {visibleRecentWorkspaces.map((workspace) => ( - - - - ))} -
- )} - -
- -
-
- ); -}; - -export default WorkspaceHeader; diff --git a/src/web-ui/src/app/components/NavPanel/config.ts b/src/web-ui/src/app/components/NavPanel/config.ts index c9d5c474..d8b83ba3 100644 --- a/src/web-ui/src/app/components/NavPanel/config.ts +++ b/src/web-ui/src/app/components/NavPanel/config.ts @@ -6,117 +6,23 @@ * * Section groups: * - workspace: project workspace essentials (sessions, files) - * - my-agent: everything describing the super agent (profile, team) - * - dev-suite: developer toolkit (git, terminal, shell-hub) + * - my-agent: everything describing the super agent (profile, agents) */ -import { - MessageSquare, - Folder, - Terminal, - GitBranch, - UserCircle2, - Users, - SquareTerminal, - Puzzle, - Sparkles, -} from 'lucide-react'; import type { NavSection } from './types'; export const NAV_SECTIONS: NavSection[] = [ { - id: 'workspace', - label: 'Workspace', + id: 'toolbox', + label: 'Toolbox', collapsible: false, - items: [ - { - tab: 'sessions', - labelKey: 'nav.items.sessions', - Icon: MessageSquare, - behavior: 'contextual', - inlineExpandable: true, - }, - { - tab: 'files', - labelKey: 'nav.items.project', - Icon: Folder, - behavior: 'contextual', - navSceneId: 'file-viewer', - }, - ], - }, - { - id: 'my-agent', - label: 'My Agent', - collapsible: true, - defaultExpanded: false, - items: [ - { - tab: 'profile', - labelKey: 'nav.items.persona', - tooltipKey: 'nav.tooltips.persona', - Icon: UserCircle2, - behavior: 'scene', - sceneId: 'profile', - }, - { - tab: 'team', - labelKey: 'nav.items.team', - tooltipKey: 'nav.tooltips.team', - Icon: Users, - behavior: 'scene', - sceneId: 'team', - inlineExpandable: true, - }, - { - tab: 'skills', - labelKey: 'nav.items.skills', - tooltipKey: 'nav.tooltips.skills', - Icon: Puzzle, - behavior: 'scene', - sceneId: 'skills', - inlineExpandable: true, - }, - ], + sceneId: 'toolbox', + items: [], }, { - id: 'dev-suite', - label: '开发套件', - collapsible: true, - defaultExpanded: false, - items: [ - { - tab: 'toolbox', - labelKey: 'nav.items.miniApps', - tooltipKey: 'nav.tooltips.toolbox', - Icon: Sparkles, - behavior: 'scene', - sceneId: 'toolbox', - inlineExpandable: true, - }, - { - tab: 'git', - labelKey: 'nav.items.git', - Icon: GitBranch, - behavior: 'scene', - sceneId: 'git', - inlineExpandable: true, - }, - { - tab: 'terminal', - labelKey: 'nav.items.terminal', - Icon: Terminal, - behavior: 'scene', - sceneId: 'terminal', - inlineExpandable: true, - }, - { - tab: 'shell-hub', - labelKey: 'nav.items.shellHub', - Icon: SquareTerminal, - behavior: 'contextual', - inlineExpandable: true, - }, - ], + id: 'workspace', + label: 'Workspace', + collapsible: false, + items: [], }, ]; diff --git a/src/web-ui/src/app/components/NavPanel/sections/git/GitSection.scss b/src/web-ui/src/app/components/NavPanel/sections/git/GitSection.scss deleted file mode 100644 index d24da7ba..00000000 --- a/src/web-ui/src/app/components/NavPanel/sections/git/GitSection.scss +++ /dev/null @@ -1,7 +0,0 @@ -/** - * GitSection — reuses nav-panel inline list styles; only add git-specific overrides if needed. - */ - -.bitfun-nav-panel__inline-list--git { - // Same as SessionsSection; parent __inline-list already has border-left, margin, etc. -} diff --git a/src/web-ui/src/app/components/NavPanel/sections/git/GitSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/git/GitSection.tsx deleted file mode 100644 index 977266bf..00000000 --- a/src/web-ui/src/app/components/NavPanel/sections/git/GitSection.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/** - * GitSection — inline sub-list under the "Git" nav item (like SessionsSection). - * Items: Working copy / Branches / Graph; clicking one opens the Git scene and sets the active view. - */ - -import React, { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { GitBranch, Layers2 } from 'lucide-react'; -import { Tooltip } from '@/component-library'; -import { useSceneStore } from '../../../../stores/sceneStore'; -import { useGitSceneStore, type GitSceneView } from '../../../../scenes/git/gitSceneStore'; -import { useApp } from '../../../../hooks/useApp'; -import './GitSection.scss'; - -const GIT_VIEWS: { id: GitSceneView; icon: React.ElementType; labelKey: string }[] = [ - { id: 'working-copy', icon: GitBranch, labelKey: 'tabs.changes' }, - { id: 'branches', icon: Layers2, labelKey: 'tabs.branches' }, - { id: 'graph', icon: Layers2, labelKey: 'tabs.branchGraph' }, -]; - -const GitSection: React.FC = () => { - const { t } = useTranslation('panels/git'); - const activeTabId = useSceneStore(s => s.activeTabId); - const openScene = useSceneStore(s => s.openScene); - const activeView = useGitSceneStore(s => s.activeView); - const setActiveView = useGitSceneStore(s => s.setActiveView); - const { switchLeftPanelTab } = useApp(); - - const handleSelect = useCallback( - (view: GitSceneView) => { - openScene('git'); - setActiveView(view); - switchLeftPanelTab('git'); - }, - [openScene, setActiveView, switchLeftPanelTab] - ); - - return ( -
- {GIT_VIEWS.map(({ id, icon: Icon, labelKey }) => { - const label = t(labelKey); - return ( - - - - ); - })} -
- ); -}; - -export default GitSection; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 5326b055..80ffade8 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -13,18 +13,9 @@ flex-direction: column; padding: 2px $size-gap-2 $size-gap-1; gap: 1px; - border-left: 1px solid var(--border-subtle); margin: 1px $size-gap-2 2px calc(#{$size-gap-2} + 14px); } - // Row containing the new-session button + mode chips - &__inline-action-row { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: $size-gap-3; - } - &__inline-action { display: flex; align-items: center; @@ -52,17 +43,6 @@ white-space: nowrap; } - &.is-code:hover { - color: var(--color-primary); - background: color-mix(in srgb, var(--color-primary) 8%, transparent); - border-color: var(--color-primary); - } - - &.is-cowork:hover { - color: var(--color-accent-500); - background: color-mix(in srgb, var(--color-accent-500) 8%, transparent); - border-color: var(--color-accent-500); - } } &__inline-empty { @@ -267,7 +247,7 @@ } } - // Shell running-status dot (used by ShellsSection) + // Shell running-status dot &__shell-dot { flex-shrink: 0; border-radius: 50%; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 50c74608..bcbbef86 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -6,14 +6,13 @@ */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Plus, Pencil, Trash2, Check, X, Code2, Users } from 'lucide-react'; +import { Pencil, Trash2, Check, X, Code2, Users } from 'lucide-react'; import { IconButton, Input, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { flowChatStore } from '../../../../../flow_chat/store/FlowChatStore'; import { flowChatManager } from '../../../../../flow_chat/services/FlowChatManager'; import type { FlowChatState, Session } from '../../../../../flow_chat/types/flow-chat'; import { useSceneStore } from '../../../../stores/sceneStore'; -import type { SessionMode } from '../../../../stores/sessionModeStore'; import { useApp } from '../../../../hooks/useApp'; import type { SceneTabId } from '../../../SceneBar/types'; import { createLogger } from '@/shared/utils/logger'; @@ -21,6 +20,8 @@ import { workspaceManager } from '@/infrastructure/services/business/workspaceMa import './SessionsSection.scss'; const MAX_VISIBLE_SESSIONS = 8; +const INACTIVE_WORKSPACE_COLLAPSED_SESSIONS = 3; +const INACTIVE_WORKSPACE_EXPANDED_SESSIONS = 7; const log = createLogger('SessionsSection'); const AGENT_SCENE: SceneTabId = 'session'; @@ -31,7 +32,17 @@ const resolveSessionMode = (session: Session): SessionMode => { const getTitle = (session: Session): string => session.title?.trim() || `Session ${session.sessionId.slice(0, 6)}`; -const SessionsSection: React.FC = () => { +interface SessionsSectionProps { + workspaceId?: string; + workspacePath?: string; + isActiveWorkspace?: boolean; +} + +const SessionsSection: React.FC = ({ + workspaceId, + workspacePath, + isActiveWorkspace = true, +}) => { const { t } = useI18n('common'); const { switchLeftPanelTab } = useApp(); const openScene = useSceneStore(s => s.openScene); @@ -53,10 +64,8 @@ const SessionsSection: React.FC = () => { }, []); useEffect(() => { - const removeListener = workspaceManager.addEventListener((event) => { - if (event.type === 'workspace:opened' || event.type === 'workspace:switched') { - setCurrentWorkspacePath(event.workspace.rootPath); - } + const removeListener = workspaceManager.addEventListener(() => { + setCurrentWorkspacePath(workspaceManager.getWorkspacePath()); }); return removeListener; }, []); @@ -68,25 +77,47 @@ const SessionsSection: React.FC = () => { } }, [editingSessionId]); + useEffect(() => { + setShowAll(false); + }, [workspaceId, workspacePath, isActiveWorkspace]); + const sessions = useMemo( () => Array.from(flowChatState.sessions.values()) .filter((s: Session) => { + if (workspacePath) { + return s.workspacePath === workspacePath || (!s.workspacePath && isActiveWorkspace); + } if (!s.workspacePath || !currentWorkspacePath) return true; return s.workspacePath === currentWorkspacePath; }) .sort( (a: Session, b: Session) => b.lastActiveAt - a.lastActiveAt ), - [flowChatState.sessions, currentWorkspacePath] + [flowChatState.sessions, currentWorkspacePath, isActiveWorkspace, workspacePath] ); + const sessionDisplayLimit = useMemo(() => { + if (isActiveWorkspace) { + return showAll || sessions.length <= MAX_VISIBLE_SESSIONS + ? sessions.length + : MAX_VISIBLE_SESSIONS; + } + + return showAll + ? Math.min(sessions.length, INACTIVE_WORKSPACE_EXPANDED_SESSIONS) + : Math.min(sessions.length, INACTIVE_WORKSPACE_COLLAPSED_SESSIONS); + }, [isActiveWorkspace, sessions.length, showAll]); + const visibleSessions = useMemo( - () => (showAll || sessions.length <= MAX_VISIBLE_SESSIONS ? sessions : sessions.slice(0, MAX_VISIBLE_SESSIONS)), - [sessions, showAll] + () => sessions.slice(0, sessionDisplayLimit), + [sessionDisplayLimit, sessions] ); - const hiddenCount = sessions.length - MAX_VISIBLE_SESSIONS; + const toggleThreshold = isActiveWorkspace + ? MAX_VISIBLE_SESSIONS + : INACTIVE_WORKSPACE_COLLAPSED_SESSIONS; + const hiddenCount = Math.max(0, sessions.length - toggleThreshold); const activeSessionId = flowChatState.activeSessionId; @@ -97,6 +128,9 @@ const SessionsSection: React.FC = () => { switchLeftPanelTab('sessions'); if (sessionId === activeSessionId) return; try { + if (workspaceId && !isActiveWorkspace) { + await workspaceManager.setActiveWorkspace(workspaceId); + } await flowChatManager.switchChatSession(sessionId); window.dispatchEvent( new CustomEvent('flowchat:switch-session', { detail: { sessionId } }) @@ -105,22 +139,9 @@ const SessionsSection: React.FC = () => { log.error('Failed to switch session', err); } }, - [activeSessionId, openScene, switchLeftPanelTab, editingSessionId] + [activeSessionId, editingSessionId, isActiveWorkspace, openScene, switchLeftPanelTab, workspaceId] ); - const handleCreate = useCallback(async (mode: SessionMode) => { - openScene('session'); - switchLeftPanelTab('sessions'); - try { - await flowChatManager.createChatSession( - { modelName: 'claude-sonnet-4.5' }, - mode === 'cowork' ? 'Cowork' : 'agentic' - ); - } catch (err) { - log.error('Failed to create session', err); - } - }, [openScene, switchLeftPanelTab]); - const resolveSessionTitle = useCallback( (session: Session): string => { const rawTitle = getTitle(session); @@ -192,29 +213,6 @@ const SessionsSection: React.FC = () => { return (
-
- - - - - - -
- {sessions.length === 0 ? (
{t('nav.sessions.noSessions')}
) : ( @@ -309,7 +307,7 @@ const SessionsSection: React.FC = () => { }) )} - {sessions.length > MAX_VISIBLE_SESSIONS && ( + {sessions.length > toggleThreshold && ( - - {running && ( - - - - )} - - - -
-
- ); - }; - - const hasContent = hubConfig.terminals.length > 0 || nonMainWorktrees.length > 0; - - return ( -
- {/* Action bar: New + Refresh + Worktree */} -
- - - - - - - {isGitRepo && ( - - - - )} -
- - {/* Hub terminals (main workspace) */} - {hubConfig.terminals.map(entry => renderTerminalItem(entry))} - - {/* Worktree groups */} - {nonMainWorktrees.map(wt => { - const expanded = expandedWorktrees.has(wt.path); - const terms = hubConfig.worktrees[wt.path] || []; - const branchLabel = wt.branch || wt.path.split(/[/\\]/).pop(); - - return ( -
-
toggleWorktree(wt.path)} - onKeyDown={(e) => activateOnEnterOrSpace(e, () => toggleWorktree(wt.path))} - > - - - {branchLabel} - {terms.length} -
- - - -
-
- {expanded && terms.length > 0 && ( -
- {terms.map(entry => renderTerminalItem(entry, wt.path))} -
- )} -
- ); - })} - - {/* Empty state */} - {!hasContent && ( -
{t('sections.terminalHub')}
- )} - - {/* Modals */} - {workspacePath && ( - setBranchModalOpen(false)} - onSelect={handleBranchSelect} - repositoryPath={workspacePath} - currentBranch={currentBranch} - existingWorktreeBranches={worktrees.map(wt => wt.branch).filter(Boolean) as string[]} - /> - )} - - {editingTerminal && ( - { setEditModalOpen(false); setEditingTerminal(null); }} - onSave={handleSaveTerminalEdit} - initialName={editingTerminal.terminal.name} - initialStartupCommand={editingTerminal.terminal.startupCommand} - /> - )} -
- ); -}; - -export default ShellHubSection; diff --git a/src/web-ui/src/app/components/NavPanel/sections/shells/ShellsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/shells/ShellsSection.tsx deleted file mode 100644 index b5d53c28..00000000 --- a/src/web-ui/src/app/components/NavPanel/sections/shells/ShellsSection.tsx +++ /dev/null @@ -1,516 +0,0 @@ -/** - * ShellsSection — inline accordion content for the "Shell" nav item. - * - * Shows a combined list of: - * • Hub terminals (configured entries from localStorage, running or stopped) - * • Ad-hoc active sessions (non-hub sessions from the terminal service) - * - * Only mounts when the accordion is expanded → zero cost when collapsed. - * - * Click behavior: - * • Current scene is 'session' → open terminal as an AuxPane tab - * (stays inside the agent scene) - * • Any other scene → switch to terminal scene and show the terminal - * content directly (via terminalSceneStore) - * - * For stopped hub entries, clicking starts the terminal process first. - */ - -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Plus, SquareTerminal, Circle, Trash2, Square, Edit2 } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { getTerminalService } from '../../../../../tools/terminal'; -import type { TerminalService } from '../../../../../tools/terminal'; -import type { SessionResponse, TerminalEvent } from '../../../../../tools/terminal/types/session'; -import { createTerminalTab } from '../../../../../shared/utils/tabUtils'; -import { useTerminalSceneStore } from '../../../../stores/terminalSceneStore'; -import { resolveAndFocusOpenTarget } from '../../../../../shared/services/sceneOpenTargetResolver'; -import { useCurrentWorkspace } from '../../../../../infrastructure/contexts/WorkspaceContext'; -import { configManager } from '../../../../../infrastructure/config/services/ConfigManager'; -import type { TerminalConfig } from '../../../../../infrastructure/config/types'; -import { Tooltip } from '@/component-library'; -import { TerminalEditModal } from '../../../panels/TerminalEditModal'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('ShellsSection'); - -// ── Hub config (shared localStorage schema for terminal hub) ───────────────── - -const TERMINAL_HUB_STORAGE_KEY = 'bitfun-terminal-hub-config'; -const HUB_TERMINAL_ID_PREFIX = 'hub_'; - -interface HubTerminalEntry { - sessionId: string; - name: string; - startupCommand?: string; -} - -interface HubConfig { - terminals: HubTerminalEntry[]; - worktrees: Record; -} - -function loadHubConfig(workspacePath: string): HubConfig { - try { - const raw = localStorage.getItem(`${TERMINAL_HUB_STORAGE_KEY}:${workspacePath}`); - if (raw) return JSON.parse(raw) as HubConfig; - } catch {} - return { terminals: [], worktrees: {} }; -} - -function saveHubConfig(workspacePath: string, config: HubConfig) { - try { - localStorage.setItem(`${TERMINAL_HUB_STORAGE_KEY}:${workspacePath}`, JSON.stringify(config)); - } catch (err) { - log.error('Failed to save hub config', err); - } -} - -interface ShellEntry { - sessionId: string; - name: string; - isRunning: boolean; - isHub: boolean; - worktreePath?: string; - startupCommand?: string; -} - -function activateOnEnterOrSpace( - event: React.KeyboardEvent, - action: () => void -) { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - action(); - } -} - -const ShellsSection: React.FC = () => { - const { t } = useTranslation('panels/terminal'); - const setActiveSession = useTerminalSceneStore(s => s.setActiveSession); - const { workspacePath } = useCurrentWorkspace(); - - const [sessions, setSessions] = useState([]); - const [hubConfig, setHubConfig] = useState({ terminals: [], worktrees: {} }); - const [editModalOpen, setEditModalOpen] = useState(false); - const [editingTerminal, setEditingTerminal] = useState<{ - terminal: HubTerminalEntry; - worktreePath?: string; - } | null>(null); - const serviceRef = useRef(null); - - const runningIds = useMemo(() => new Set(sessions.map(s => s.id)), [sessions]); - - useEffect(() => { - if (!workspacePath) return; - setHubConfig(loadHubConfig(workspacePath)); - }, [workspacePath]); - - const refreshSessions = useCallback(async () => { - const service = serviceRef.current; - if (!service) return; - try { - setSessions(await service.listSessions()); - } catch (err) { - log.error('Failed to list sessions', err); - } - }, []); - - useEffect(() => { - const service = getTerminalService(); - serviceRef.current = service; - - const init = async () => { - try { - await service.connect(); - await refreshSessions(); - } catch (err) { - log.error('Failed to connect terminal service', err); - } - }; - - init(); - - const unsub = service.onEvent((event: TerminalEvent) => { - if (event.type === 'ready' || event.type === 'exit') { - refreshSessions(); - } - }); - - return () => unsub(); - }, [refreshSessions]); - - const entries = useMemo(() => { - const result: ShellEntry[] = []; - - // Hub terminals (main + all worktrees) - for (const t of hubConfig.terminals) { - result.push({ - sessionId: t.sessionId, - name: t.name, - isRunning: runningIds.has(t.sessionId), - isHub: true, - startupCommand: t.startupCommand, - }); - } - for (const [wtPath, terms] of Object.entries(hubConfig.worktrees)) { - for (const t of terms) { - result.push({ - sessionId: t.sessionId, - name: t.name, - isRunning: runningIds.has(t.sessionId), - isHub: true, - worktreePath: wtPath, - startupCommand: t.startupCommand, - }); - } - } - - // Ad-hoc active sessions (not managed by hub) - for (const s of sessions) { - if (!s.id.startsWith(HUB_TERMINAL_ID_PREFIX)) { - result.push({ - sessionId: s.id, - name: s.name, - isRunning: true, - isHub: false, - }); - } - } - - return result; - }, [hubConfig, sessions, runningIds]); - - const startHubTerminal = useCallback( - async (entry: ShellEntry): Promise => { - const service = serviceRef.current; - if (!service || !workspacePath) return false; - - try { - let shellType: string | undefined; - try { - const cfg = await configManager.getConfig('terminal'); - if (cfg?.default_shell) shellType = cfg.default_shell; - } catch {} - - await service.createSession({ - sessionId: entry.sessionId, - workingDirectory: entry.worktreePath ?? workspacePath, - name: entry.name, - shellType, - }); - - if (entry.startupCommand?.trim()) { - // Brief wait for the shell to initialise before sending command - await new Promise(r => setTimeout(r, 800)); - try { - await service.sendCommand(entry.sessionId, entry.startupCommand); - } catch {} - } - - await refreshSessions(); - return true; - } catch (err) { - log.error('Failed to start hub terminal', err); - return false; - } - }, - [workspacePath, refreshSessions] - ); - - const handleOpen = useCallback( - async (entry: ShellEntry) => { - // Start the terminal if it's a hub entry that isn't running yet - if (!entry.isRunning) { - const ok = await startHubTerminal(entry); - if (!ok) return; - } - - const { mode } = resolveAndFocusOpenTarget('terminal'); - if (mode === 'agent') { - // Stay in agent scene: open as AuxPane tab - createTerminalTab(entry.sessionId, entry.name, 'agent'); - } else { - // Any other scene: navigate to terminal scene and show content directly - setActiveSession(entry.sessionId); - } - }, - [startHubTerminal, setActiveSession] - ); - - const handleCreate = useCallback(async () => { - const service = serviceRef.current; - if (!service) return; - - try { - let shellType: string | undefined; - try { - const cfg = await configManager.getConfig('terminal'); - if (cfg?.default_shell) shellType = cfg.default_shell; - } catch {} - - const session = await service.createSession({ - workingDirectory: workspacePath, - name: `Shell ${sessions.length + 1}`, - shellType, - }); - - setSessions(prev => [...prev, session]); - - const { mode } = resolveAndFocusOpenTarget('terminal'); - if (mode === 'agent') { - createTerminalTab(session.id, session.name, 'agent'); - } else { - setActiveSession(session.id); - } - } catch (err) { - log.error('Failed to create shell', err); - } - }, [workspacePath, sessions.length, setActiveSession]); - - /** - * Stop terminal session - * - For hub terminals: keep in list but stop the process, right panel stays open - * - For ad-hoc terminals: same as delete (close session and right panel tab) - */ - const handleStopTerminal = useCallback( - async (entry: ShellEntry, e: React.MouseEvent) => { - e.stopPropagation(); - const service = serviceRef.current; - if (!service || !runningIds.has(entry.sessionId)) return; - - try { - await service.closeSession(entry.sessionId); - - // For ad-hoc terminals, dispatch destroyed event to close right panel tab - // (since they won't be preserved in the list anyway) - if (!entry.isHub) { - window.dispatchEvent( - new CustomEvent('terminal-session-destroyed', { detail: { sessionId: entry.sessionId } }) - ); - } - - await refreshSessions(); - } catch (err) { - log.error('Failed to stop terminal', err); - } - }, - [runningIds, refreshSessions] - ); - - /** - * Delete terminal - close session, close right panel tab, and remove from list - * For hub terminals: also remove from localStorage config - */ - const handleDeleteTerminal = useCallback( - async (entry: ShellEntry, e: React.MouseEvent) => { - e.stopPropagation(); - - // Close the terminal session if running - if (entry.isRunning) { - const service = serviceRef.current; - if (service) { - try { - await service.closeSession(entry.sessionId); - } catch (err) { - log.error('Failed to close terminal session', err); - } - } - } - - // Dispatch event to close the tab in right panel - window.dispatchEvent( - new CustomEvent('terminal-session-destroyed', { detail: { sessionId: entry.sessionId } }) - ); - - // For hub terminals, also remove from localStorage config - if (entry.isHub && workspacePath) { - setHubConfig(prev => { - let next: HubConfig; - if (entry.worktreePath) { - const terms = (prev.worktrees[entry.worktreePath] || []).filter( - t => t.sessionId !== entry.sessionId - ); - next = { ...prev, worktrees: { ...prev.worktrees, [entry.worktreePath]: terms } }; - } else { - next = { ...prev, terminals: prev.terminals.filter(t => t.sessionId !== entry.sessionId) }; - } - saveHubConfig(workspacePath, next); - return next; - }); - } - - // Refresh the session list - await refreshSessions(); - }, - [workspacePath, refreshSessions] - ); - - /** - * Open edit modal for a terminal - */ - const handleOpenEditModal = useCallback( - (entry: ShellEntry, e: React.MouseEvent) => { - e.stopPropagation(); - - if (entry.isHub) { - // For hub terminals, find the entry from config - let hubEntry: HubTerminalEntry | undefined; - if (entry.worktreePath) { - hubEntry = hubConfig.worktrees[entry.worktreePath]?.find(t => t.sessionId === entry.sessionId); - } else { - hubEntry = hubConfig.terminals.find(t => t.sessionId === entry.sessionId); - } - - if (hubEntry) { - setEditingTerminal({ terminal: hubEntry, worktreePath: entry.worktreePath }); - setEditModalOpen(true); - } - } else { - // For ad-hoc sessions, create a temporary entry for editing - setEditingTerminal({ - terminal: { sessionId: entry.sessionId, name: entry.name }, - worktreePath: undefined, - }); - setEditModalOpen(true); - } - }, - [hubConfig] - ); - - /** - * Save terminal edit (name and optionally startup command) - */ - const handleSaveTerminalEdit = useCallback( - (newName: string, newStartupCommand?: string) => { - if (!editingTerminal) return; - const { terminal, worktreePath } = editingTerminal; - - // For hub terminals, update localStorage config - if (terminal.sessionId.startsWith(HUB_TERMINAL_ID_PREFIX) && workspacePath) { - setHubConfig(prev => { - let next: HubConfig; - if (worktreePath) { - const terms = (prev.worktrees[worktreePath] || []).map(t => - t.sessionId === terminal.sessionId - ? { ...t, name: newName, startupCommand: newStartupCommand } - : t - ); - next = { ...prev, worktrees: { ...prev.worktrees, [worktreePath]: terms } }; - } else { - const terms = prev.terminals.map(t => - t.sessionId === terminal.sessionId - ? { ...t, name: newName, startupCommand: newStartupCommand } - : t - ); - next = { ...prev, terminals: terms }; - } - saveHubConfig(workspacePath, next); - return next; - }); - } - - // Update session name in state and notify other components - if (runningIds.has(terminal.sessionId)) { - setSessions(prev => - prev.map(s => (s.id === terminal.sessionId ? { ...s, name: newName } : s)) - ); - window.dispatchEvent( - new CustomEvent('terminal-session-renamed', { - detail: { sessionId: terminal.sessionId, newName }, - }) - ); - } - - setEditingTerminal(null); - }, - [editingTerminal, workspacePath, runningIds] - ); - - const renderTerminalItem = (entry: ShellEntry) => { - return ( -
handleOpen(entry)} - onKeyDown={(e) => activateOnEnterOrSpace(e, () => handleOpen(entry))} - title={entry.name} - > - - {entry.name} - -
- - - - {entry.isRunning && ( - - - - )} - - - -
-
- ); - }; - - return ( -
- - - {entries.length === 0 ? ( -
No shells
- ) : ( - entries.map(entry => renderTerminalItem(entry)) - )} - - {editingTerminal && ( - { - setEditModalOpen(false); - setEditingTerminal(null); - }} - onSave={handleSaveTerminalEdit} - initialName={editingTerminal.terminal.name} - initialStartupCommand={editingTerminal.terminal.startupCommand} - /> - )} -
- ); -}; - -export default ShellsSection; diff --git a/src/web-ui/src/app/components/NavPanel/sections/skills/SkillsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/skills/SkillsSection.scss deleted file mode 100644 index 9b27f458..00000000 --- a/src/web-ui/src/app/components/NavPanel/sections/skills/SkillsSection.scss +++ /dev/null @@ -1,7 +0,0 @@ -/** - * SkillsSection — reuses nav-panel inline list styles; no skills-specific overrides needed. - */ - -.bitfun-nav-panel__inline-list--skills { - // Inherits border-left, margin, item styles from SessionsSection base styles. -} diff --git a/src/web-ui/src/app/components/NavPanel/sections/skills/SkillsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/skills/SkillsSection.tsx deleted file mode 100644 index 08d2ddfe..00000000 --- a/src/web-ui/src/app/components/NavPanel/sections/skills/SkillsSection.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * SkillsSection — inline sub-list under the "Skills" nav item. - * Shows two fixed entries: Market and Installed. - * Clicking either opens the Skills scene and activates the corresponding view. - */ - -import React, { useCallback } from 'react'; -import { Store, Package } from 'lucide-react'; -import { Tooltip } from '@/component-library'; -import { useSceneStore } from '../../../../stores/sceneStore'; -import { useSkillsSceneStore, type SkillsView } from '../../../../scenes/skills/skillsSceneStore'; -import { useI18n } from '@/infrastructure/i18n'; -import './SkillsSection.scss'; - -const SKILLS_VIEWS: { id: SkillsView; Icon: React.ElementType; labelKey: string }[] = [ - { id: 'market', Icon: Store, labelKey: 'nav.items.market' }, - { id: 'installed-all', Icon: Package, labelKey: 'nav.categories.installed' }, -]; - -const SkillsSection: React.FC = () => { - const { t } = useI18n('scenes/skills'); - const activeTabId = useSceneStore((s) => s.activeTabId); - const openScene = useSceneStore((s) => s.openScene); - const activeView = useSkillsSceneStore((s) => s.activeView); - const setActiveView = useSkillsSceneStore((s) => s.setActiveView); - - const handleSelect = useCallback( - (view: SkillsView) => { - openScene('skills'); - setActiveView(view); - }, - [openScene, setActiveView], - ); - - return ( -
- {SKILLS_VIEWS.map(({ id, Icon, labelKey }) => { - const label = t(labelKey); - const isActive = activeTabId === 'skills' && activeView === id; - return ( - - - - ); - })} -
- ); -}; - -export default SkillsSection; diff --git a/src/web-ui/src/app/components/NavPanel/sections/team/TeamSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/team/TeamSection.tsx deleted file mode 100644 index 2b6fbcab..00000000 --- a/src/web-ui/src/app/components/NavPanel/sections/team/TeamSection.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * TeamSection — inline sub-list under the "Team" nav item (like GitSection). - * Items: Agents / Expert teams; clicking one opens the Team scene and sets the active view. - */ - -import React, { useCallback } from 'react'; -import { Bot, Users } from 'lucide-react'; -import { Tooltip } from '@/component-library'; -import { useSceneStore } from '../../../../stores/sceneStore'; -import { useTeamStore, type TeamScenePage } from '../../../../scenes/team/teamStore'; -import { useApp } from '../../../../hooks/useApp'; -import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; - -const TEAM_VIEWS: { id: TeamScenePage; icon: React.ElementType; labelKey: string }[] = [ - { id: 'agentsOverview', icon: Bot, labelKey: 'nav.team.agentsOverview' }, - { id: 'expertTeamsOverview', icon: Users, labelKey: 'nav.team.expertTeams' }, -]; - -const TeamSection: React.FC = () => { - const { t } = useI18n('common'); - const activeTabId = useSceneStore((s) => s.activeTabId); - const openScene = useSceneStore((s) => s.openScene); - const page = useTeamStore((s) => s.page); - const openAgentsOverview = useTeamStore((s) => s.openAgentsOverview); - const openExpertTeamsOverview = useTeamStore((s) => s.openExpertTeamsOverview); - const { switchLeftPanelTab } = useApp(); - - const handleSelect = useCallback( - (view: TeamScenePage) => { - if (view === 'editor') return; - openScene('team'); - if (view === 'agentsOverview') { - openAgentsOverview(); - } else { - openExpertTeamsOverview(); - } - switchLeftPanelTab('team'); - }, - [openScene, openAgentsOverview, openExpertTeamsOverview, switchLeftPanelTab] - ); - - return ( -
- {TEAM_VIEWS.map(({ id, icon: Icon, labelKey }) => { - const label = t(labelKey); - const isActive = activeTabId === 'team' && page === id; - return ( - - - - ); - })} -
- ); -}; - -export default TeamSection; diff --git a/src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.scss b/src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.scss deleted file mode 100644 index 52ddaf25..00000000 --- a/src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.scss +++ /dev/null @@ -1,27 +0,0 @@ -@use '../../../../../component-library/styles/tokens.scss' as *; - -.bitfun-nav-panel__inline-item-action-btn--toolbox { - border: none; - background: transparent; - color: var(--color-text-muted); - box-shadow: none; - transition: color $motion-fast $easing-standard, - opacity $motion-fast $easing-standard; - - &:hover, - &:active { - background: transparent; - color: var(--color-text-primary); - } - - &:focus-visible { - outline: 1px solid var(--color-accent-500); - outline-offset: 1px; - } -} - -@media (prefers-reduced-motion: reduce) { - .bitfun-nav-panel__inline-item-action-btn--toolbox { - transition: none; - } -} diff --git a/src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.tsx deleted file mode 100644 index d986702a..00000000 --- a/src/web-ui/src/app/components/NavPanel/sections/toolbox/ToolboxSection.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/** - * ToolboxSection — inline list under the Mini App nav item. - * - * Prioritises three things: - * - open the Mini App gallery - * - quick access to running apps - * - visibility for recently opened apps that still have tabs - */ -import React, { useCallback, useEffect } from 'react'; -import { FolderPlus, Puzzle, Sparkles, Circle, Square } from 'lucide-react'; -import { useSceneManager } from '@/app/hooks/useSceneManager'; -import { useToolboxStore } from '@/app/scenes/toolbox/toolboxStore'; -import { useMiniAppList } from '@/app/scenes/toolbox/hooks/useMiniAppList'; -import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; -import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; -import { Tooltip } from '@/component-library'; -import type { SceneTabId } from '@/app/components/SceneBar/types'; -import './ToolboxSection.scss'; - -const ToolboxSection: React.FC = () => { - const { t } = useI18n('common'); - const { openScene, activateScene, closeScene, openTabs } = useSceneManager(); - useMiniAppList(); - const openedAppIds = useToolboxStore((s) => s.openedAppIds); - const runningWorkerIds = useToolboxStore((s) => s.runningWorkerIds); - const apps = useToolboxStore((s) => s.apps); - const markWorkerStopped = useToolboxStore((s) => s.markWorkerStopped); - const visibleAppIds = Array.from(new Set([...runningWorkerIds, ...openedAppIds])); - - useEffect(() => { - miniAppAPI.workerListRunning().then((ids) => { - useToolboxStore.getState().setRunningWorkerIds(ids); - }).catch(() => {}); - }, []); - - const openTabIds = new Set(openTabs.map((tab) => tab.id)); - - const handleOpenGallery = useCallback(() => { - openScene('toolbox'); - }, [openScene]); - - const runningApps = visibleAppIds.filter((appId) => runningWorkerIds.includes(appId)); - const openedOnlyApps = visibleAppIds.filter((appId) => !runningWorkerIds.includes(appId)); - - const handleRowClick = useCallback( - (appId: string) => { - const tabId: SceneTabId = `miniapp:${appId}`; - if (openTabIds.has(tabId)) { - activateScene(tabId); - } else { - openScene(tabId); - } - }, - [openTabIds, openScene, activateScene] - ); - - const handleStop = useCallback( - async (appId: string, e: React.MouseEvent) => { - e.stopPropagation(); - try { - await miniAppAPI.workerStop(appId); - markWorkerStopped(appId); - const tabId: SceneTabId = `miniapp:${appId}`; - if (openTabIds.has(tabId)) { - closeScene(tabId); - } - } catch { - markWorkerStopped(appId); - const tabId: SceneTabId = `miniapp:${appId}`; - if (openTabIds.has(tabId)) { - closeScene(tabId); - } - } - }, - [markWorkerStopped, closeScene, openTabIds] - ); - - return ( -
-
- - -
- - {visibleAppIds.length === 0 ? ( -
- {t('nav.toolbox.noApps')} -
- ) : ( - <> - {runningApps.length > 0 && ( - <> -
- {t('nav.toolbox.runningApps')} -
- {runningApps.map((appId) => { - const app = apps.find((a) => a.id === appId); - const name = app?.name ?? appId; - return ( -
handleRowClick(appId)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleRowClick(appId); - } - }} - title={name} - > - - {name} - -
- - - -
-
- ); - })} - - )} - - {openedOnlyApps.length > 0 && ( - <> -
- {t('nav.toolbox.myApps')} -
- {openedOnlyApps.map((appId) => { - const app = apps.find((a) => a.id === appId); - const name = app?.name ?? appId; - return ( -
handleRowClick(appId)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleRowClick(appId); - } - }} - title={name} - > - - {name} -
- ); - })} - - )} - - )} -
- ); -}; - -export default ToolboxSection; 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 new file mode 100644 index 00000000..1eff2680 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -0,0 +1,279 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Folder, FolderOpen, MoreHorizontal, GitBranch, FolderSearch, Plus, ChevronDown } from 'lucide-react'; +import { Tooltip } from '@/component-library'; +import { useI18n } from '@/infrastructure/i18n'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { useNavSceneStore } from '@/app/stores/navSceneStore'; +import { useApp } from '@/app/hooks/useApp'; +import { useGitBasicInfo } from '@/tools/git/hooks/useGitState'; +import { workspaceAPI, gitAPI } from '@/infrastructure/api'; +import { notificationService } from '@/shared/notification-system'; +import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; +import { BranchSelectModal, type BranchSelectResult } from '../../../panels/BranchSelectModal'; +import SessionsSection from '../sessions/SessionsSection'; +import type { WorkspaceInfo } from '@/shared/types'; + +interface WorkspaceItemProps { + workspace: WorkspaceInfo; + isActive: boolean; + isSingle?: boolean; +} + +const WorkspaceItem: React.FC = ({ workspace, isActive, isSingle = false }) => { + const { t } = useI18n('common'); + const { setActiveWorkspace, closeWorkspaceById } = useWorkspaceContext(); + const { switchLeftPanelTab } = useApp(); + const openNavScene = useNavSceneStore(s => s.openNavScene); + const { isRepository, currentBranch } = useGitBasicInfo(workspace.rootPath); + const [menuOpen, setMenuOpen] = useState(false); + const [worktreeModalOpen, setWorktreeModalOpen] = useState(false); + const [sessionsCollapsed, setSessionsCollapsed] = useState(false); + const menuRef = useRef(null); + const menuAnchorRef = useRef(null); + const menuPopoverRef = useRef(null); + const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); + + const updateMenuPosition = useCallback(() => { + const anchor = menuAnchorRef.current; + if (!anchor) return; + + const rect = anchor.getBoundingClientRect(); + const viewportPadding = 8; + const estimatedWidth = 240; + const maxLeft = window.innerWidth - estimatedWidth - viewportPadding; + + setMenuPosition({ + top: Math.max(viewportPadding, rect.bottom + 6), + left: Math.max(viewportPadding, Math.min(rect.left, maxLeft)), + }); + }, []); + + useEffect(() => { + if (!menuOpen) return; + const handleOutside = (event: MouseEvent) => { + const target = event.target as Node; + const isInsideTriggerArea = menuRef.current?.contains(target); + const isInsidePopover = menuPopoverRef.current?.contains(target); + if (!isInsideTriggerArea && !isInsidePopover) { + setMenuOpen(false); + } + }; + document.addEventListener('mousedown', handleOutside); + return () => document.removeEventListener('mousedown', handleOutside); + }, [menuOpen]); + + useEffect(() => { + if (!menuOpen) return; + + updateMenuPosition(); + + const handleViewportChange = () => updateMenuPosition(); + window.addEventListener('resize', handleViewportChange); + window.addEventListener('scroll', handleViewportChange, true); + + return () => { + window.removeEventListener('resize', handleViewportChange); + window.removeEventListener('scroll', handleViewportChange, true); + }; + }, [menuOpen, updateMenuPosition]); + + const handleActivate = useCallback(async () => { + if (!isActive) { + await setActiveWorkspace(workspace.id); + } + }, [isActive, setActiveWorkspace, workspace.id]); + + const handleCollapseToggle = useCallback(() => { + setSessionsCollapsed(prev => !prev); + }, []); + + const handleCardNameClick = useCallback(async () => { + if (!isActive) { + await setActiveWorkspace(workspace.id); + } else { + setSessionsCollapsed(prev => !prev); + } + }, [isActive, setActiveWorkspace, workspace.id]); + + const handleCloseWorkspace = useCallback(async () => { + setMenuOpen(false); + try { + await closeWorkspaceById(workspace.id); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('nav.workspaces.closeFailed'), + { duration: 4000 } + ); + } + }, [closeWorkspaceById, t, workspace.id]); + + const handleReveal = useCallback(async () => { + setMenuOpen(false); + try { + await workspaceAPI.revealInExplorer(workspace.rootPath); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('nav.workspaces.revealFailed'), + { duration: 4000 } + ); + } + }, [t, workspace.rootPath]); + + const handleCreateSession = useCallback(async () => { + setMenuOpen(false); + try { + await handleActivate(); + await flowChatManager.createChatSession({}); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('nav.workspaces.createSessionFailed'), + { duration: 4000 } + ); + } + }, [handleActivate, t]); + + const handleCreateWorktree = useCallback(async (result: BranchSelectResult) => { + try { + await gitAPI.addWorktree(workspace.rootPath, result.branch, result.isNew); + notificationService.success(t('nav.workspaces.worktreeCreated'), { duration: 2500 }); + } catch (error) { + notificationService.error( + t('nav.workspaces.worktreeCreateFailed', { + error: error instanceof Error ? error.message : String(error), + }), + { duration: 4000 } + ); + } + }, [t, workspace.rootPath]); + + const handleOpenFiles = useCallback(async () => { + try { + await handleActivate(); + switchLeftPanelTab('files'); + openNavScene('file-viewer'); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('nav.workspaces.revealFailed'), + { duration: 4000 } + ); + } + }, [handleActivate, openNavScene, switchLeftPanelTab, t]); + + return ( +
+
+ + +
+ +
+ + + +
+ +
+ + {menuOpen && menuPosition && createPortal( +
+ + + + +
, + document.body + )} +
+ +
+ +
+ + setWorktreeModalOpen(false)} + onSelect={(result) => { void handleCreateWorktree(result); }} + repositoryPath={workspace.rootPath} + title={t('nav.workspaces.actions.newWorktree')} + /> +
+ ); +}; + +export default WorkspaceItem; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss new file mode 100644 index 00000000..4aa90425 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss @@ -0,0 +1,360 @@ +@use '../../../../../component-library/styles/tokens.scss' as *; + +.bitfun-nav-panel { + &__workspace-list { + display: flex; + flex-direction: column; + gap: $size-gap-2; + padding: 4px 0 0; + } + + &__workspace-list-empty { + margin: 0 $size-gap-1; + padding: $size-gap-2; + border-radius: $size-radius-base; + color: var(--color-text-muted); + font-size: 12px; + background: color-mix(in srgb, var(--element-bg-soft) 55%, transparent); + } + + &__workspace-item { + position: relative; + display: flex; + flex-direction: column; + gap: 2px; + padding: $size-gap-1; + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--element-bg-soft) 38%, transparent); + border: 1px solid transparent; + transition: background $motion-fast $easing-standard, + border-color $motion-fast $easing-standard; + + &.is-active { + background: color-mix(in srgb, var(--element-bg-soft) 75%, transparent); + border-color: transparent; + } + + &.is-single { + background: transparent; + border-color: transparent; + + &.is-active { + background: transparent; + } + } + + &:not(.is-active) { + .bitfun-nav-panel__workspace-item-card { + gap: 1px; + + &:hover { + background: transparent; + + .bitfun-nav-panel__workspace-item-collapse-btn, + .bitfun-nav-panel__workspace-item-name-btn { + background: color-mix(in srgb, var(--element-bg-soft) 72%, transparent); + } + } + } + + .bitfun-nav-panel__workspace-item-collapse-btn { + border-radius: 6px 0 0 6px; + } + + .bitfun-nav-panel__workspace-item-name-btn { + border-radius: 0 6px 6px 0; + } + } + + &:not(.is-active):not(:hover):not(:focus-within):not(.is-menu-open) { + .bitfun-nav-panel__workspace-item-branch { + max-width: 0; + margin-left: 0; + padding-left: 0; + padding-right: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; + } + + .bitfun-nav-panel__workspace-item-menu { + opacity: 0; + visibility: hidden; + pointer-events: none; + } + } + } + + &__workspace-item-card { + display: flex; + align-items: center; + width: 100%; + min-height: 30px; + border-radius: 6px; + color: var(--color-text-secondary); + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + &:hover { + color: var(--color-text-primary); + background: color-mix(in srgb, var(--element-bg-soft) 72%, transparent); + + .bitfun-nav-panel__workspace-item-icon-default { + opacity: 0; + transform: scale(0.7); + } + + .bitfun-nav-panel__workspace-item-icon-toggle { + opacity: 1; + transform: scale(1); + } + } + } + + &__workspace-item-collapse-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + min-height: 30px; + padding: 0 0 0 $size-gap-1; + border: none; + border-radius: 6px 0 0 6px; + background: transparent; + color: inherit; + cursor: pointer; + + &:hover { + background: color-mix(in srgb, var(--element-bg-medium) 60%, transparent); + + .bitfun-nav-panel__workspace-item-icon-default { + opacity: 0; + transform: scale(0.7); + } + + .bitfun-nav-panel__workspace-item-icon-toggle { + opacity: 1; + transform: scale(1); + } + } + } + + &__workspace-item-name-btn { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 0 58px 0 2px; + border: none; + border-radius: 0 6px 6px 0; + background: transparent; + color: inherit; + cursor: pointer; + text-align: left; + } + + &__workspace-item-icon { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + flex-shrink: 0; + color: var(--color-primary); + opacity: 0.85; + } + + &__workspace-item-icon-default, + &__workspace-item-icon-toggle { + position: absolute; + display: inline-flex; + align-items: center; + justify-content: center; + transition: opacity $motion-fast $easing-standard, + transform $motion-fast $easing-standard; + } + + &__workspace-item-icon-default { + opacity: 1; + transform: scale(1); + } + + &__workspace-item-icon-toggle { + opacity: 0; + transform: scale(0.7); + color: var(--color-text-secondary); + + svg { + transition: transform $motion-fast $easing-standard; + } + + &.is-collapsed svg { + transform: rotate(-90deg); + } + } + + &__workspace-item-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; + } + + &__workspace-item-branch { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; + min-width: 0; + max-width: 96px; + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--element-bg-medium) 75%, transparent); + color: var(--color-text-muted); + font-size: 10px; + transition: max-width $motion-fast $easing-standard, + padding $motion-fast $easing-standard, + opacity $motion-fast $easing-standard, + margin-left $motion-fast $easing-standard; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__workspace-item-menu { + position: absolute; + top: 6px; + right: 6px; + display: inline-flex; + align-items: center; + gap: 4px; + transition: opacity $motion-fast $easing-standard, + visibility $motion-fast $easing-standard; + } + + &__workspace-item-menu-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + &:hover, + &.is-open { + color: var(--color-text-primary); + background: var(--element-bg-medium); + } + } + + &__workspace-item-menu-popover { + position: fixed; + width: max-content; + min-width: 180px; + max-width: min(320px, calc(100vw - 24px)); + padding: $size-gap-1 0; + border-radius: $size-radius-base; + background: var(--color-bg-elevated, #1e1e22); + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + z-index: 10000; + transform-origin: top left; + animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; + } + + &__workspace-item-menu-item { + display: flex; + align-items: center; + gap: $size-gap-2; + width: 100%; + min-height: 30px; + padding: 0 $size-gap-2; + border: none; + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-secondary); + font-size: $font-size-sm; + font-weight: 400; + cursor: pointer; + text-align: left; + white-space: nowrap; + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + svg { + flex-shrink: 0; + opacity: 0.7; + transition: opacity $motion-fast $easing-standard; + } + + &:hover:not(:disabled) { + color: var(--color-text-primary); + background: var(--element-bg-soft); + + svg { + opacity: 1; + } + } + + &:active:not(:disabled) { + background: var(--element-bg-medium); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + &.is-danger:hover:not(:disabled) { + color: var(--color-error, #ef4444); + background: color-mix(in srgb, var(--color-error, #ef4444) 10%, transparent); + } + } + + &__workspace-item-menu-label { + flex: 1; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__workspace-item-sessions { + overflow: hidden; + max-height: 600px; + transition: max-height $motion-base $easing-standard, + opacity $motion-fast $easing-standard; + opacity: 1; + + &.is-collapsed { + max-height: 0; + opacity: 0; + pointer-events: none; + } + + .bitfun-nav-panel__inline-list { + margin-left: 18px; + margin-right: 0; + padding-top: 0; + } + + .bitfun-nav-panel__inline-empty { + padding-left: $size-gap-1; + } + } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx new file mode 100644 index 00000000..f0369af3 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx @@ -0,0 +1,39 @@ +import React, { useEffect } from 'react'; +import { useI18n } from '@/infrastructure/i18n'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import WorkspaceItem from './WorkspaceItem'; +import './WorkspaceListSection.scss'; + +const WorkspaceListSection: React.FC = () => { + const { t } = useI18n('common'); + const { openedWorkspacesList, activeWorkspaceId } = useWorkspaceContext(); + + useEffect(() => { + openedWorkspacesList.forEach(workspace => { + void flowChatStore.initializeFromDisk(workspace.rootPath); + }); + }, [openedWorkspacesList]); + + return ( +
+ {openedWorkspacesList.length === 0 ? ( +
+ {t('nav.workspaces.empty')} +
+ ) : ( + openedWorkspacesList.map(workspace => ( + + )) + )} + +
+ ); +}; + +export default WorkspaceListSection; diff --git a/src/web-ui/src/app/components/NavPanel/types.ts b/src/web-ui/src/app/components/NavPanel/types.ts index 40410e7e..fb89369b 100644 --- a/src/web-ui/src/app/components/NavPanel/types.ts +++ b/src/web-ui/src/app/components/NavPanel/types.ts @@ -21,14 +21,14 @@ export interface NavItem { sceneId?: SceneTabId; /** Optional nav-panel scene switch without opening right-side scene */ navSceneId?: SceneTabId; - /** When true, clicking this item toggles an inline content area below it */ - inlineExpandable?: boolean; } export interface NavSection { id: string; /** Null hides the section header row entirely */ label: string | null; + /** Optional scene opened when clicking the section header */ + sceneId?: SceneTabId; collapsible?: boolean; defaultExpanded?: boolean; items: NavItem[]; diff --git a/src/web-ui/src/app/components/SceneBar/types.ts b/src/web-ui/src/app/components/SceneBar/types.ts index 9ef86683..16bd65c8 100644 --- a/src/web-ui/src/app/components/SceneBar/types.ts +++ b/src/web-ui/src/app/components/SceneBar/types.ts @@ -5,7 +5,20 @@ import type { LucideIcon } from 'lucide-react'; /** Scene tab identifier — max 3 open at a time */ -export type SceneTabId = 'welcome' | 'session' | 'terminal' | 'git' | 'settings' | 'file-viewer' | 'profile' | 'team' | 'skills' | 'toolbox' | `miniapp:${string}`; +export type SceneTabId = + | 'welcome' + | 'session' + | 'terminal' + | 'git' + | 'settings' + | 'file-viewer' + | 'profile' + | 'agents' + | 'skills' + | 'toolbox' + | 'my-agent' + | 'shell' + | `miniapp:${string}`; /** Static definition (from registry) for a scene tab type */ export interface SceneTabDef { diff --git a/src/web-ui/src/app/components/TitleBar/TitleBar.tsx b/src/web-ui/src/app/components/TitleBar/TitleBar.tsx index 4a84c993..85bd2c77 100644 --- a/src/web-ui/src/app/components/TitleBar/TitleBar.tsx +++ b/src/web-ui/src/app/components/TitleBar/TitleBar.tsx @@ -179,7 +179,7 @@ const TitleBar: React.FC = ({ setIsOrbHovered(false); }, []); - // Listen for nav panel events dispatched by WorkspaceHeader + // Listen for nav panel events dispatched by the workspace area useEffect(() => { const onNewProject = () => handleNewProject(); const onGoHome = () => handleGoHome(); diff --git a/src/web-ui/src/app/components/index.ts b/src/web-ui/src/app/components/index.ts index 0a0844a1..a5a21c88 100644 --- a/src/web-ui/src/app/components/index.ts +++ b/src/web-ui/src/app/components/index.ts @@ -6,4 +6,5 @@ export { default as TitleBar } from './TitleBar/TitleBar'; export { default as NavPanel } from './NavPanel/NavPanel'; export { SceneBar } from './SceneBar'; export type { SceneTabId, SceneTabDef, SceneTab } from './SceneBar'; +export * from './GalleryLayout'; export * from './panels'; diff --git a/src/web-ui/src/app/components/panels/BranchSelectModal.scss b/src/web-ui/src/app/components/panels/BranchSelectModal.scss index ba64e715..f4d058be 100644 --- a/src/web-ui/src/app/components/panels/BranchSelectModal.scss +++ b/src/web-ui/src/app/components/panels/BranchSelectModal.scss @@ -119,27 +119,6 @@ .branch-select-dialog__input { width: 100%; - padding: 8px 12px; - font-size: 13px; - border: 1px solid var(--border-subtle); - border-radius: 6px; - background: var(--color-bg-secondary); - color: var(--color-text-primary); - outline: none !important; - transition: border-color 0.15s ease, background 0.15s ease; - - &:focus, - &:focus-visible, - &:focus-within, - &:active { - border-color: var(--color-accent-500); - outline: none !important; - box-shadow: none; - } - - &::placeholder { - color: var(--color-text-muted); - } } // ==================== Error Message ==================== diff --git a/src/web-ui/src/app/components/panels/BranchSelectModal.tsx b/src/web-ui/src/app/components/panels/BranchSelectModal.tsx index 1479c94a..1cfc1823 100644 --- a/src/web-ui/src/app/components/panels/BranchSelectModal.tsx +++ b/src/web-ui/src/app/components/panels/BranchSelectModal.tsx @@ -4,6 +4,7 @@ */ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { GitBranch, Plus, X } from 'lucide-react'; import { createLogger } from '@/shared/utils/logger'; import { IconButton, Button, Input } from '@/component-library'; @@ -153,7 +154,7 @@ export const BranchSelectModal: React.FC = ({ if (!isOpen) return null; - return ( + const modalContent = (
e.stopPropagation()}> = ({
); + + if (typeof document === 'undefined') { + return modalContent; + } + + return createPortal(modalContent, document.body); }; export default BranchSelectModal; diff --git a/src/web-ui/src/app/components/panels/TerminalEditModal.tsx b/src/web-ui/src/app/components/panels/TerminalEditModal.tsx index 2adca065..4c8967c2 100644 --- a/src/web-ui/src/app/components/panels/TerminalEditModal.tsx +++ b/src/web-ui/src/app/components/panels/TerminalEditModal.tsx @@ -14,6 +14,7 @@ export interface TerminalEditModalProps { onSave: (name: string, startupCommand?: string) => void; initialName: string; initialStartupCommand?: string; + showStartupCommand?: boolean; } export const TerminalEditModal: React.FC = ({ @@ -21,7 +22,8 @@ export const TerminalEditModal: React.FC = ({ onClose, onSave, initialName, - initialStartupCommand = '' + initialStartupCommand = '', + showStartupCommand = true, }) => { const { t } = useI18n('panels/terminal'); const [name, setName] = useState(initialName); @@ -79,14 +81,16 @@ export const TerminalEditModal: React.FC = ({ placeholder={t('dialog.editTerminal.namePlaceholder')} /> - setStartupCommand(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={t('dialog.editTerminal.startupCommandPlaceholder')} - hint={t('dialog.editTerminal.startupCommandHint')} - /> + {showStartupCommand ? ( + setStartupCommand(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t('dialog.editTerminal.startupCommandPlaceholder')} + hint={t('dialog.editTerminal.startupCommandHint')} + /> + ) : null}
diff --git a/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.scss b/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.scss index 7f298fa8..01d55f17 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.scss @@ -1,6 +1,6 @@ /** * ThumbnailCard component styles. - * Transparent glassmorphism design. + * Lightweight token-based thumbnail surface. */ .canvas-thumbnail-card { diff --git a/src/web-ui/src/app/layout/AppLayout.scss b/src/web-ui/src/app/layout/AppLayout.scss index 37e01238..841ef3b5 100644 --- a/src/web-ui/src/app/layout/AppLayout.scss +++ b/src/web-ui/src/app/layout/AppLayout.scss @@ -1,6 +1,6 @@ /** * BitFun main layout styles (SCSS). - * Blue glassmorphism with a consistent dark background. + * Token-driven application shell with a consistent dark background. * Follows BEM naming and design tokens. */ diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index a92dfb72..5762b695 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -86,7 +86,7 @@ const AppLayout: React.FC = ({ className = '' }) => { } }, [openWorkspace]); - // Listen for nav-panel events dispatched by WorkspaceHeader + // Listen for nav-panel events dispatched by the workspace area useEffect(() => { const onNewProject = () => handleNewProject(); window.addEventListener('nav:new-project', onNewProject); diff --git a/src/web-ui/src/app/layout/WorkspaceBody.tsx b/src/web-ui/src/app/layout/WorkspaceBody.tsx index 3d077300..1fc0889b 100644 --- a/src/web-ui/src/app/layout/WorkspaceBody.tsx +++ b/src/web-ui/src/app/layout/WorkspaceBody.tsx @@ -87,7 +87,7 @@ const WorkspaceBody: React.FC = ({ {/* Left: nav history bar + navigation sidebar — always rendered for slide animation */}
- + {!isNavCollapsed && (
import('./git/GitScene')); const SettingsScene = lazy(() => import('./settings/SettingsScene')); const FileViewerScene = lazy(() => import('./file-viewer/FileViewerScene')); const ProfileScene = lazy(() => import('./profile/ProfileScene')); -const TeamScene = lazy(() => import('./team/TeamScene')); +const AgentsScene = lazy(() => import('./agents/AgentsScene')); const SkillsScene = lazy(() => import('./skills/SkillsScene')); const ToolboxScene = lazy(() => import('./toolbox/ToolboxScene')); +const MyAgentScene = lazy(() => import('./my-agent/MyAgentScene')); +const ShellScene = lazy(() => import('./shell/ShellScene')); const WelcomeScene = lazy(() => import('./welcome/WelcomeScene')); const MiniAppScene = lazy(() => import('./toolbox/MiniAppScene')); @@ -45,13 +47,12 @@ const SceneViewport: React.FC = ({ workspacePath, isEntering
{[ { id: 'session' as SceneTabId, Icon: MessageSquare, labelKey: 'scenes.aiAgent' }, - { id: 'terminal' as SceneTabId, Icon: Terminal, labelKey: 'scenes.terminal' }, + { id: 'shell' as SceneTabId, Icon: Terminal, labelKey: 'scenes.shell' }, { id: 'git' as SceneTabId, Icon: GitBranch, labelKey: 'scenes.git' }, { id: 'settings' as SceneTabId, Icon: Settings, labelKey: 'scenes.settings' }, { id: 'file-viewer' as SceneTabId, Icon: FileCode2, labelKey: 'scenes.fileViewer' }, - { id: 'profile' as SceneTabId, Icon: CircleUserRound, labelKey: 'scenes.projectContext' }, - { id: 'skills' as SceneTabId, Icon: Puzzle, labelKey: 'scenes.skills' }, - { id: 'toolbox' as SceneTabId, Icon: Wrench, labelKey: 'scenes.toolbox' }, + { id: 'my-agent' as SceneTabId, Icon: UserCircle2, labelKey: 'scenes.myAgent' }, + { id: 'toolbox' as SceneTabId, Icon: Boxes, labelKey: 'scenes.toolbox' }, ].map(({ id, Icon, labelKey }) => { const label = t(labelKey); return ( @@ -107,12 +108,16 @@ function renderScene(id: SceneTabId, workspacePath?: string, isEntering?: boolea return ; case 'profile': return ; - case 'team': - return ; + case 'agents': + return ; case 'skills': return ; case 'toolbox': return ; + case 'my-agent': + return ; + case 'shell': + return ; default: if (typeof id === 'string' && id.startsWith('miniapp:')) { return ; diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.scss b/src/web-ui/src/app/scenes/agents/AgentsScene.scss new file mode 100644 index 00000000..662859db --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.scss @@ -0,0 +1,70 @@ +@use '../../../component-library/styles/tokens' as *; + +.bitfun-agents-scene { + .gallery-page-header__title { + margin: 0; + font-size: clamp(24px, 3vw, 32px); + line-height: 1.08; + letter-spacing: -0.03em; + } + + .gallery-page-header__subtitle { + margin-top: $size-gap-2; + max-width: 720px; + font-size: $font-size-sm; + color: var(--color-text-secondary); + } + + .gallery-zone__subtitle { + font-size: $font-size-sm; + color: var(--color-text-secondary); + } + + &__agent-filters { + display: flex; + align-items: center; + gap: $size-gap-3; + flex-wrap: wrap; + } + + &__agent-filter-group { + display: flex; + align-items: center; + gap: $size-gap-2; + flex-wrap: wrap; + } + + &__agent-filter-label { + font-size: 11px; + font-weight: $font-weight-medium; + color: var(--color-text-muted); + white-space: nowrap; + } +} + +@media (max-width: 1080px) { + .bitfun-agents-scene { + .gallery-page-header__actions { + width: 100%; + flex-wrap: wrap; + } + } +} + +@media (max-width: 720px) { + .bitfun-agents-scene { + .gallery-zone__tools { + justify-content: flex-start; + } + + &__agent-filters { + width: 100%; + align-items: flex-start; + gap: $size-gap-2; + } + + &__agent-filter-group { + width: 100%; + } + } +} diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx new file mode 100644 index 00000000..50bf185d --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -0,0 +1,700 @@ +import React, { useCallback, useMemo } from 'react'; +import { + ArrowLeft, + Bot, + Cpu, + Plus, + Puzzle, + RefreshCw, + Search as SearchIcon, + Users, + Wrench, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Badge, Button, IconButton, Search } from '@/component-library'; +import { + GalleryDetailModal, + GalleryEmpty, + GalleryGrid, + GalleryLayout, + GalleryPageHeader, + GallerySkeleton, + GalleryZone, +} from '@/app/components'; +import AgentCard from './components/AgentCard'; +import AgentTeamCard from './components/AgentTeamCard'; +import AgentTeamTabBar from './components/AgentTeamTabBar'; +import AgentGallery from './components/AgentGallery'; +import AgentTeamComposer from './components/AgentTeamComposer'; +import CapabilityBar from './components/CapabilityBar'; +import CreateAgentPage from './components/CreateAgentPage'; +import { + CAPABILITY_CATEGORIES, + MOCK_AGENT_TEAMS, + computeAgentTeamCapabilities, + type AgentWithCapabilities, + useAgentsStore, +} from './agentsStore'; +import { useAgentsList } from './hooks/useAgentsList'; +import { AGENT_ICON_MAP, CAPABILITY_ACCENT, AGENT_TEAM_ICON_MAP, getAgentTeamAccent } from './agentsIcons'; +import { getCardGradient } from '@/shared/utils/cardGradients'; +import { getAgentBadge } from './utils'; +import './AgentsView.scss'; +import './AgentsScene.scss'; + +const EXAMPLE_TEAM_IDS = new Set(MOCK_AGENT_TEAMS.map((team) => team.id)); + +const AgentTeamEditorView: React.FC = () => { + const { t } = useTranslation('scenes/agents'); + const { openHome } = useAgentsStore(); + + return ( +
+
+ +
+ + + +
+ + +
+ +
+
+ + +
+ ); +}; + +const AgentsHomeView: React.FC = () => { + const { t } = useTranslation('scenes/agents'); + const { + agentTeams, + agentSoloEnabled, + searchQuery, + agentFilterLevel, + agentFilterType, + setSearchQuery, + setAgentFilterLevel, + setAgentFilterType, + setAgentSoloEnabled, + openAgentTeamEditor, + openCreateAgent, + addAgentTeam, + } = useAgentsStore(); + const [selectedAgentId, setSelectedAgentId] = React.useState(null); + const [selectedTeamId, setSelectedTeamId] = React.useState(null); + const [toolsEditing, setToolsEditing] = React.useState(false); + const [skillsEditing, setSkillsEditing] = React.useState(false); + + const { + allAgents, + filteredAgents, + loading, + availableTools, + availableSkills, + counts, + loadAgents, + getModeConfig, + handleToggleTool, + handleResetTools, + handleToggleSkill, + } = useAgentsList({ + searchQuery, + filterLevel: agentFilterLevel, + filterType: agentFilterType, + t, + }); + + const filteredTeams = useMemo(() => agentTeams.filter((team) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return team.name.toLowerCase().includes(query) || team.description.toLowerCase().includes(query); + }), [agentTeams, searchQuery]); + + const handleCreateTeam = useCallback(() => { + const id = `agent-team-${Date.now()}`; + addAgentTeam({ + id, + name: t('teamsZone.newTeamName', '新团队'), + icon: 'users', + description: '', + strategy: 'collaborative', + shareContext: true, + }); + openAgentTeamEditor(id); + }, [addAgentTeam, openAgentTeamEditor, t]); + + const scrollToZone = useCallback((targetId: string) => { + document.getElementById(targetId)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, []); + + const levelFilters = [ + { key: 'builtin', label: t('filters.builtin', '内置'), count: counts.builtin }, + { key: 'user', label: t('filters.user', '用户'), count: counts.user }, + { key: 'project', label: t('filters.project', '项目'), count: counts.project }, + ] as const; + + const typeFilters = [ + { key: 'mode', label: t('filters.mode', 'Agent'), count: counts.mode }, + { key: 'subagent', label: t('filters.subagent', 'Sub-Agent'), count: counts.subagent }, + ] as const; + + const renderSkeletons = (prefix: string) => ( + + ); + + const selectedAgent = useMemo( + () => allAgents.find((agent) => agent.id === selectedAgentId) ?? null, + [allAgents, selectedAgentId], + ); + const selectedAgentModeConfig = useMemo( + () => (selectedAgent?.agentKind === 'mode' ? getModeConfig(selectedAgent.id) : null), + [getModeConfig, selectedAgent], + ); + const selectedAgentTools = selectedAgent?.agentKind === 'mode' + ? (selectedAgentModeConfig?.available_tools ?? selectedAgent.defaultTools ?? []) + : (selectedAgent?.defaultTools ?? []); + const selectedAgentSkills = selectedAgentModeConfig?.available_skills ?? []; + const selectedAgentSkillItems = availableSkills.filter((skill) => selectedAgentSkills.includes(skill.name)); + const selectedTeam = useMemo( + () => agentTeams.find((team) => team.id === selectedTeamId) ?? null, + [agentTeams, selectedTeamId], + ); + const selectedAgentTeamMembers = useMemo( + () => selectedTeam + ? selectedTeam.members + .map((member) => allAgents.find((agent) => agent.id === member.agentId)) + .filter((agent): agent is AgentWithCapabilities => Boolean(agent)) + : [], + [allAgents, selectedTeam], + ); + const selectedTeamTopCaps = useMemo(() => { + if (!selectedTeam) return []; + const caps = computeAgentTeamCapabilities(selectedTeam, allAgents); + return CAPABILITY_CATEGORIES + .filter((category) => caps[category] > 0) + .sort((a, b) => caps[b] - caps[a]) + .slice(0, 3); + }, [allAgents, selectedTeam]); + + const openAgentDetails = useCallback((agent: AgentWithCapabilities) => { + setSelectedTeamId(null); + setSelectedAgentId(agent.id); + setToolsEditing(false); + setSkillsEditing(false); + }, []); + + const closeAgentDetails = useCallback(() => { + setSelectedAgentId(null); + setToolsEditing(false); + setSkillsEditing(false); + }, []); + + const openTeamDetails = useCallback((teamId: string) => { + setSelectedAgentId(null); + setToolsEditing(false); + setSkillsEditing(false); + setSelectedTeamId(teamId); + }, []); + + return ( + + + + +
+ )} + actions={( + <> + } + suffixContent={( + + )} + /> + + + )} + /> + +
+ +
+
+ + {t('filters.source', '来源')} + + {levelFilters.map(({ key, label, count }) => ( + + ))} +
+
+ + {t('filters.kind', '类型')} + + {typeFilters.map(({ key, label, count }) => ( + + ))} +
+
+ + {filteredAgents.length} + + )} + > + {loading ? renderSkeletons('agent') : null} + + {!loading && filteredAgents.length === 0 ? ( + } + message={allAgents.length === 0 ? t('agentsZone.empty.noAgents') : t('agentsZone.empty.noMatch')} + /> + ) : null} + + {!loading && filteredAgents.length > 0 ? ( + + {filteredAgents.map((agent, index) => ( + + ))} + + ) : null} +
+ + + + {filteredTeams.length} + + )} + > + {filteredTeams.length === 0 ? ( + } + message={agentTeams.length === 0 ? t('teamsZone.empty.noTeams') : t('teamsZone.empty.noMatch')} + /> + ) : ( + + {filteredTeams.map((team, index) => { + const caps = computeAgentTeamCapabilities(team, allAgents); + const topCaps = CAPABILITY_CATEGORIES + .filter((category) => caps[category] > 0) + .sort((a, b) => caps[b] - caps[a]) + .slice(0, 3); + + return ( + openTeamDetails(currentTeam.id)} + topCapabilities={topCaps} + /> + ); + })} + + )} + +
+ + } + iconGradient={selectedAgent ? getCardGradient(selectedAgent.id || selectedAgent.name) : undefined} + title={selectedAgent?.name ?? ''} + badges={selectedAgent ? ( + <> + + {selectedAgent.agentKind === 'mode' ? : } + {getAgentBadge(t, selectedAgent.agentKind, selectedAgent.subagentSource).label} + + {!selectedAgent.enabled ? {t('agentCard.badges.disabled', '已禁用')} : null} + {selectedAgent.model ? {selectedAgent.model} : null} + + ) : null} + description={selectedAgent?.description} + meta={selectedAgent ? ( + <> + {t('agentCard.meta.tools', '{{count}} 个工具', { count: selectedAgent.toolCount ?? selectedAgentTools.length })} + {selectedAgent.agentKind === 'mode' ? ( + {t('agentCard.meta.skills', '{{count}} 个 Skills', { count: selectedAgentSkills.length })} + ) : null} + + ) : null} + > + {selectedAgent ? ( + <> +
+ {selectedAgent.capabilities.map((cap) => ( +
+ + {cap.category} + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ {cap.level}/5 +
+ ))} +
+ + {selectedAgentTools.length > 0 ? ( +
+
+
+ + {t('agentsOverview.tools', '工具')} + + {selectedAgent.agentKind === 'mode' + ? `${selectedAgentTools.length}/${availableTools.length}` + : `${selectedAgentTools.length}`} + +
+ {selectedAgent.agentKind === 'mode' ? ( +
+ + {toolsEditing ? ( + void handleResetTools(selectedAgent.id)} + > + + + ) : null} +
+ ) : null} +
+ + {selectedAgent.agentKind === 'mode' && toolsEditing ? ( +
+ {[...availableTools] + .sort((a, b) => { + const aOn = selectedAgentTools.includes(a.name); + const bOn = selectedAgentTools.includes(b.name); + if (aOn && !bOn) return -1; + if (!aOn && bOn) return 1; + return 0; + }) + .map((tool) => { + const isOn = selectedAgentTools.includes(tool.name); + return ( + + ); + })} +
+ ) : ( +
+ {selectedAgentTools.map((tool) => ( + + {tool.replace(/_/g, ' ')} + + ))} +
+ )} +
+ ) : null} + + {selectedAgent.agentKind === 'mode' && availableSkills.length > 0 ? ( +
+
+
+ + {t('agentsOverview.skills', 'Skills')} + + {`${selectedAgentSkills.length}/${availableSkills.length}`} + +
+
+ +
+
+ + {skillsEditing ? ( +
+ {[...availableSkills] + .sort((a, b) => { + const aOn = selectedAgentSkills.includes(a.name); + const bOn = selectedAgentSkills.includes(b.name); + if (aOn && !bOn) return -1; + if (!aOn && bOn) return 1; + return 0; + }) + .map((skill) => { + const isOn = selectedAgentSkills.includes(skill.name); + return ( + + ); + })} +
+ ) : ( +
+ {selectedAgentSkillItems.length === 0 ? ( + + {t('agentsOverview.noSkills', '未启用任何 Skill')} + + ) : ( + selectedAgentSkillItems.map((skill) => ( + + {skill.name} + + )) + )} +
+ )} +
+ ) : null} + + ) : null} +
+ + setSelectedTeamId(null)} + icon={selectedTeam ? React.createElement( + AGENT_TEAM_ICON_MAP[(selectedTeam.icon ?? 'users') as keyof typeof AGENT_TEAM_ICON_MAP] ?? Users, + { size: 24, strokeWidth: 1.7 }, + ) : } + iconGradient={selectedTeam ? `linear-gradient(135deg, ${getAgentTeamAccent(selectedTeam.id)}33 0%, ${getAgentTeamAccent(selectedTeam.id)}14 100%)` : undefined} + title={selectedTeam?.name ?? ''} + badges={selectedTeam ? ( + <> + {EXAMPLE_TEAM_IDS.has(selectedTeam.id) ? {t('teamCard.badges.example', '示例')} : null} + + {selectedTeam.strategy === 'collaborative' + ? t('composer.strategy.collaborative') + : selectedTeam.strategy === 'sequential' + ? t('composer.strategy.sequential') + : t('composer.strategy.free')} + + {selectedTeam.shareContext ? ( + {t('teamCard.badges.sharedContext', '共享上下文')} + ) : null} + + ) : null} + description={selectedTeam?.description} + meta={selectedTeam ? {t('home.members', { count: selectedTeam.members.length })} : null} + actions={selectedTeam ? ( + + ) : null} + > + {selectedAgentTeamMembers.length > 0 ? ( +
+
{t('teamCard.sections.members', '成员')}
+
+ {selectedAgentTeamMembers.map((agent) => { + const member = selectedTeam?.members.find((item) => item.agentId === agent.id); + const roleLabel = + member?.role === 'leader' + ? t('composer.role.leader') + : member?.role === 'reviewer' + ? t('composer.role.reviewer') + : t('composer.role.member'); + const AgentIcon = AGENT_ICON_MAP[(agent.iconKey ?? 'bot') as keyof typeof AGENT_ICON_MAP] ?? Bot; + + return ( + + + {agent.name} + {roleLabel} + + ); + })} +
+
+ ) : null} + + {selectedTeamTopCaps.length > 0 ? ( +
+
{t('teamCard.sections.capabilities', '能力')}
+
+ {selectedTeamTopCaps.map((cap) => ( + + {cap} + + ))} +
+
+ ) : null} +
+ + ); +}; + +const AgentsScene: React.FC = () => { + const { page } = useAgentsStore(); + + if (page === 'editor') { + return ( +
+ +
+ ); + } + + if (page === 'createAgent') { + return ( +
+ +
+ ); + } + + return ; +}; + +export default AgentsScene; diff --git a/src/web-ui/src/app/scenes/team/TeamView.scss b/src/web-ui/src/app/scenes/agents/AgentsView.scss similarity index 94% rename from src/web-ui/src/app/scenes/team/TeamView.scss rename to src/web-ui/src/app/scenes/agents/AgentsView.scss index ea8add84..4c5c41b7 100644 --- a/src/web-ui/src/app/scenes/team/TeamView.scss +++ b/src/web-ui/src/app/scenes/agents/AgentsView.scss @@ -24,9 +24,13 @@ flex-shrink: 0; display: flex; align-items: center; - padding: $size-gap-2 $size-gap-4; + padding: $size-gap-3 clamp(16px, 2.2vw, 28px); border-bottom: 1px solid var(--border-subtle); - background: var(--element-bg-subtle); + background: linear-gradient( + to bottom, + var(--color-bg-scene) 0%, + var(--element-bg-subtle) 100% + ); } &__back-btn { @@ -55,22 +59,25 @@ display: flex; flex: 1; overflow: hidden; + min-height: 0; } &__gallery { flex-shrink: 0; - width: 320px; + width: 336px; min-width: 280px; display: flex; flex-direction: column; overflow: hidden; + border-right: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.02); @media (max-width: 960px) { width: 260px; min-width: 220px; } } &__panel-label { flex-shrink: 0; - padding: 8px $size-gap-4; + padding: 10px $size-gap-4; font-size: $font-size-xs; font-weight: $font-weight-semibold; color: var(--color-text-muted); @@ -82,6 +89,7 @@ &__composer { flex: 1; overflow: hidden; + min-width: 0; } } diff --git a/src/web-ui/src/app/scenes/team/teamIcons.ts b/src/web-ui/src/app/scenes/agents/agentsIcons.ts similarity index 78% rename from src/web-ui/src/app/scenes/team/teamIcons.ts rename to src/web-ui/src/app/scenes/agents/agentsIcons.ts index f69cec80..2a9dc6be 100644 --- a/src/web-ui/src/app/scenes/team/teamIcons.ts +++ b/src/web-ui/src/app/scenes/agents/agentsIcons.ts @@ -1,6 +1,6 @@ /** - * Icon and color mapping for the team scene - * All visuals use lucide-react icons + CSS custom properties — no emoji + * Icon and color mapping for the agents scene + * All visuals use lucide-react icons + CSS custom properties. */ import { Code2, @@ -30,7 +30,7 @@ export type AgentIconKey = | 'globe' | 'barchart' | 'layers' | 'penline' | 'server' | 'bot' | 'terminal' | 'microscope' | 'cpu'; -export type TeamIconKey = +export type AgentTeamIconKey = | 'code' | 'chart' | 'layout' | 'rocket' | 'users' | 'briefcase' | 'layers'; @@ -51,7 +51,7 @@ export const AGENT_ICON_MAP: Record> = { cpu: Cpu, }; -export const TEAM_ICON_MAP: Record> = { +export const AGENT_TEAM_ICON_MAP: Record> = { code: Code2, chart: BarChart2, layout: LayoutTemplate, @@ -71,8 +71,8 @@ export const CAPABILITY_ACCENT: Record = { 运维: '#5ea3a3', }; -// Each team has a deterministic accent derived from its id -const TEAM_ACCENTS = [ +// Each agent team has a deterministic accent derived from its id. +const AGENT_TEAM_ACCENTS = [ '#60a5fa', // blue '#6eb88c', // green '#8b5cf6', // purple @@ -81,8 +81,8 @@ const TEAM_ACCENTS = [ '#5ea3a3', // teal ]; -export function getTeamAccent(id: string): string { +export function getAgentTeamAccent(id: string): string { let hash = 0; for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0; - return TEAM_ACCENTS[hash % TEAM_ACCENTS.length]; + return AGENT_TEAM_ACCENTS[hash % AGENT_TEAM_ACCENTS.length]; } diff --git a/src/web-ui/src/app/scenes/team/teamStore.ts b/src/web-ui/src/app/scenes/agents/agentsStore.ts similarity index 78% rename from src/web-ui/src/app/scenes/team/teamStore.ts rename to src/web-ui/src/app/scenes/agents/agentsStore.ts index 558f8295..0637d711 100644 --- a/src/web-ui/src/app/scenes/team/teamStore.ts +++ b/src/web-ui/src/app/scenes/agents/agentsStore.ts @@ -1,5 +1,5 @@ /** - * Team scene state management + mock data + * Agents scene state management + mock data */ import { create } from 'zustand'; import type { SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI'; @@ -7,8 +7,8 @@ import type { SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI' // ─── Types ──────────────────────────────────────────────────────────────────── export type MemberRole = 'leader' | 'member' | 'reviewer'; -export type TeamStrategy = 'sequential' | 'collaborative' | 'free'; -export type TeamViewMode = 'formation' | 'list'; +export type AgentTeamStrategy = 'sequential' | 'collaborative' | 'free'; +export type AgentTeamViewMode = 'formation' | 'list'; export const CAPABILITY_CATEGORIES = ['编码', '文档', '分析', '测试', '创意', '运维'] as const; export type CapabilityCategory = (typeof CAPABILITY_CATEGORIES)[number]; @@ -28,20 +28,20 @@ export interface AgentWithCapabilities extends SubagentInfo { agentKind?: AgentKind; } -export interface TeamMember { +export interface AgentTeamMember { agentId: string; role: MemberRole; modelOverride?: string; order: number; } -export interface Team { +export interface AgentTeam { id: string; name: string; icon: string; description: string; - members: TeamMember[]; - strategy: TeamStrategy; + members: AgentTeamMember[]; + strategy: AgentTeamStrategy; shareContext: boolean; } @@ -231,11 +231,11 @@ export const MOCK_AGENTS: AgentWithCapabilities[] = [ }, ]; -// ─── Mock teams with pre-seeded members ─────────────────────────────────────── +// ─── Mock agent teams with pre-seeded members ───────────────────────────────── -export const MOCK_TEAMS: Team[] = [ +export const MOCK_AGENT_TEAMS: AgentTeam[] = [ { - id: 'team-coding', + id: 'agent-team-coding', name: '编码团队', icon: 'code', description: '代码审查、重构与质量保障', @@ -249,7 +249,7 @@ export const MOCK_TEAMS: Team[] = [ shareContext: true, }, { - id: 'team-research', + id: 'agent-team-research', name: '调研团队', icon: 'chart', description: '信息搜集、数据分析与报告撰写', @@ -262,7 +262,7 @@ export const MOCK_TEAMS: Team[] = [ shareContext: true, }, { - id: 'team-ppt', + id: 'agent-team-ppt', name: 'PPT 制作', icon: 'layout', description: '内容策划、视觉设计与文案润色', @@ -275,9 +275,9 @@ export const MOCK_TEAMS: Team[] = [ }, ]; -// ─── Team templates (for "use template" quick start) ───────────────────────── +// ─── Agent team templates (for "use template" quick start) ──────────────────── -export const TEAM_TEMPLATES: Array<{ +export const AGENT_TEAM_TEMPLATES: Array<{ id: string; name: string; icon: string; @@ -314,10 +314,10 @@ export const TEAM_TEMPLATES: Array<{ }, ]; -// ─── Helper: compute team capability coverage ───────────────────────────────── +// ─── Helper: compute agent team capability coverage ──────────────────────────── -export function computeTeamCapabilities( - team: Team, +export function computeAgentTeamCapabilities( + team: AgentTeam, allAgents: AgentWithCapabilities[], ): Record { const result: Record = { @@ -335,46 +335,53 @@ export function computeTeamCapabilities( // ─── Scene page ─────────────────────────────────────────────────────────────── -export type TeamScenePage = 'agentsOverview' | 'expertTeamsOverview' | 'editor' | 'createAgent'; -export type HomeFilter = 'all' | 'agent' | 'team'; +export type AgentsScenePage = 'home' | 'editor' | 'createAgent'; +export type AgentFilterLevel = 'all' | 'builtin' | 'user' | 'project'; +export type AgentFilterType = 'all' | 'mode' | 'subagent'; // ─── Store ──────────────────────────────────────────────────────────────────── -interface TeamStoreState { +interface AgentsStoreState { // Scene navigation - page: TeamScenePage; - homeFilter: HomeFilter; - setPage: (page: TeamScenePage) => void; - setHomeFilter: (filter: HomeFilter) => void; - openAgentsOverview: () => void; - openExpertTeamsOverview: () => void; - openTeamEditor: (teamId: string) => void; + page: AgentsScenePage; + searchQuery: string; + agentFilterLevel: AgentFilterLevel; + agentFilterType: AgentFilterType; + setPage: (page: AgentsScenePage) => void; + setSearchQuery: (query: string) => void; + setAgentFilterLevel: (filter: AgentFilterLevel) => void; + setAgentFilterType: (filter: AgentFilterType) => void; + openHome: () => void; + openAgentTeamEditor: (teamId: string) => void; openCreateAgent: () => void; agentSoloEnabled: Record; setAgentSoloEnabled: (agentId: string, enabled: boolean) => void; - teams: Team[]; - activeTeamId: string | null; - viewMode: TeamViewMode; + agentTeams: AgentTeam[]; + activeAgentTeamId: string | null; + viewMode: AgentTeamViewMode; - setActiveTeam: (id: string | null) => void; - setViewMode: (mode: TeamViewMode) => void; - addTeam: (team: Omit) => void; - updateTeam: (id: string, patch: Partial>) => void; - deleteTeam: (id: string) => void; + setActiveAgentTeam: (id: string | null) => void; + setViewMode: (mode: AgentTeamViewMode) => void; + addAgentTeam: (team: Omit) => void; + updateAgentTeam: (id: string, patch: Partial>) => void; + deleteAgentTeam: (id: string) => void; addMember: (teamId: string, agentId: string, role?: MemberRole) => void; removeMember: (teamId: string, agentId: string) => void; updateMemberRole: (teamId: string, agentId: string, role: MemberRole) => void; } -export const useTeamStore = create((set) => ({ - page: 'agentsOverview', - homeFilter: 'all', +export const useAgentsStore = create((set) => ({ + page: 'home', + searchQuery: '', + agentFilterLevel: 'all', + agentFilterType: 'all', setPage: (page) => set({ page }), - setHomeFilter: (filter) => set({ homeFilter: filter }), - openAgentsOverview: () => set({ page: 'agentsOverview' }), - openExpertTeamsOverview: () => set({ page: 'expertTeamsOverview' }), - openTeamEditor: (teamId) => set({ page: 'editor', activeTeamId: teamId }), + setSearchQuery: (query) => set({ searchQuery: query }), + setAgentFilterLevel: (filter) => set({ agentFilterLevel: filter }), + setAgentFilterType: (filter) => set({ agentFilterType: filter }), + openHome: () => set({ page: 'home' }), + openAgentTeamEditor: (teamId) => set({ page: 'editor', activeAgentTeamId: teamId }), openCreateAgent: () => set({ page: 'createAgent' }), agentSoloEnabled: {}, setAgentSoloEnabled: (agentId, enabled) => @@ -385,43 +392,43 @@ export const useTeamStore = create((set) => ({ }, })), - teams: MOCK_TEAMS, - activeTeamId: MOCK_TEAMS[0].id, + agentTeams: MOCK_AGENT_TEAMS, + activeAgentTeamId: MOCK_AGENT_TEAMS[0].id, viewMode: 'formation', - setActiveTeam: (id) => set({ activeTeamId: id }), + setActiveAgentTeam: (id) => set({ activeAgentTeamId: id }), setViewMode: (mode) => set({ viewMode: mode }), - addTeam: (team) => { - const newTeam: Team = { ...team, members: [] }; - set((s) => ({ teams: [...s.teams, newTeam], activeTeamId: newTeam.id })); + addAgentTeam: (team) => { + const newAgentTeam: AgentTeam = { ...team, members: [] }; + set((s) => ({ agentTeams: [...s.agentTeams, newAgentTeam], activeAgentTeamId: newAgentTeam.id })); }, - updateTeam: (id, patch) => + updateAgentTeam: (id, patch) => set((s) => ({ - teams: s.teams.map((t) => (t.id === id ? { ...t, ...patch } : t)), + agentTeams: s.agentTeams.map((t) => (t.id === id ? { ...t, ...patch } : t)), })), - deleteTeam: (id) => + deleteAgentTeam: (id) => set((s) => { - const next = s.teams.filter((t) => t.id !== id); - const activeId = s.activeTeamId === id ? (next[0]?.id ?? null) : s.activeTeamId; - return { teams: next, activeTeamId: activeId }; + const next = s.agentTeams.filter((t) => t.id !== id); + const activeId = s.activeAgentTeamId === id ? (next[0]?.id ?? null) : s.activeAgentTeamId; + return { agentTeams: next, activeAgentTeamId: activeId }; }), addMember: (teamId, agentId, role = 'member') => set((s) => ({ - teams: s.teams.map((t) => { + agentTeams: s.agentTeams.map((t) => { if (t.id !== teamId) return t; if (t.members.some((m) => m.agentId === agentId)) return t; - const newMember: TeamMember = { agentId, role, order: t.members.length }; + const newMember: AgentTeamMember = { agentId, role, order: t.members.length }; return { ...t, members: [...t.members, newMember] }; }), })), removeMember: (teamId, agentId) => set((s) => ({ - teams: s.teams.map((t) => + agentTeams: s.agentTeams.map((t) => t.id === teamId ? { ...t, members: t.members.filter((m) => m.agentId !== agentId) } : t, @@ -430,7 +437,7 @@ export const useTeamStore = create((set) => ({ updateMemberRole: (teamId, agentId, role) => set((s) => ({ - teams: s.teams.map((t) => + agentTeams: s.agentTeams.map((t) => t.id === teamId ? { ...t, members: t.members.map((m) => (m.agentId === agentId ? { ...m, role } : m)) } : t, diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCard.scss b/src/web-ui/src/app/scenes/agents/components/AgentCard.scss new file mode 100644 index 00000000..bafca7bb --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/AgentCard.scss @@ -0,0 +1,367 @@ +@use '../../../../component-library/styles/tokens' as *; + +.agent-card { + display: flex; + border-radius: $size-radius-lg; + background: var(--element-bg-soft); + border: 1px solid transparent; + cursor: pointer; + position: relative; + animation: agent-card-in 0.22s $easing-decelerate both; + animation-delay: calc(var(--card-index, 0) * 35ms); + transition: + background $motion-fast $easing-standard, + border-color $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard, + transform $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-medium); + border-color: var(--border-subtle); + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: 2px; + } + + &--disabled { + .agent-card__name, + .agent-card__desc, + .agent-card__meta { + opacity: 0.8; + } + } + + &__icon-area { + width: 56px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--agent-card-gradient); + border-radius: $size-radius-lg 0 0 $size-radius-lg; + } + + &__icon { + color: var(--color-text-primary); + } + + &__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + padding: $size-gap-3; + gap: $size-gap-2; + overflow: hidden; + } + + &__header { + display: flex; + align-items: flex-start; + gap: $size-gap-2; + } + + &__header-main { + flex: 1; + min-width: 0; + } + + &__title-row { + display: flex; + align-items: flex-start; + gap: $size-gap-2; + margin-bottom: 4px; + } + + &__name { + flex: 1; + min-width: 0; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + line-height: $line-height-tight; + word-break: break-word; + } + + &__badges { + display: inline-flex; + align-items: center; + gap: $size-gap-1; + flex-wrap: wrap; + justify-content: flex-end; + flex-shrink: 0; + } + + &__desc { + margin: 0; + font-size: $font-size-xs; + color: var(--color-text-secondary); + line-height: $line-height-relaxed; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + } + + &__actions { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + } + + &__meta { + display: flex; + align-items: center; + gap: $size-gap-2; + flex-wrap: wrap; + margin-top: auto; + padding-top: $size-gap-2; + color: var(--color-text-muted); + font-size: $font-size-xs; + line-height: $line-height-base; + } + + &__meta-item { + display: inline-flex; + align-items: center; + gap: 4px; + } + + &__cap-chips { + display: inline-flex; + align-items: center; + gap: $size-gap-1; + flex-wrap: wrap; + } + + &__cap-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: $size-radius-full; + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.04); + font-size: 10px; + font-weight: $font-weight-medium; + white-space: nowrap; + } + + &__detail { + overflow: hidden; + min-height: 0; + display: flex; + flex-direction: column; + gap: $size-gap-3; + padding-top: $size-gap-2; + color: var(--color-text-secondary); + font-size: $font-size-xs; + line-height: $line-height-relaxed; + } + + &__cap-grid { + display: flex; + flex-direction: column; + gap: $size-gap-2; + } + + &__cap-row { + display: flex; + align-items: center; + gap: $size-gap-3; + } + + &__cap-label { + min-width: 28px; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + } + + &__cap-bar { + display: flex; + gap: 3px; + } + + &__cap-pip { + width: 8px; + height: 8px; + border-radius: 2px; + background: var(--element-bg-medium); + } + + &__cap-level { + min-width: 24px; + font-size: 10px; + color: var(--color-text-muted); + } + + &__section { + display: flex; + flex-direction: column; + gap: $size-gap-2; + padding-top: $size-gap-3; + border-top: 1px dashed var(--border-subtle); + } + + &__section-head { + display: flex; + align-items: center; + gap: $size-gap-2; + } + + &__section-title { + display: inline-flex; + align-items: center; + gap: $size-gap-2; + color: var(--color-text-secondary); + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + } + + &__section-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: $size-radius-full; + background: var(--element-bg-medium); + border: 1px solid var(--border-subtle); + font-size: 10px; + font-weight: $font-weight-semibold; + color: var(--color-text-muted); + line-height: 1; + } + + &__section-actions { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 2px; + } + + &__chip-grid, + &__token-grid { + display: flex; + flex-wrap: wrap; + gap: $size-gap-1; + } + + &__chip { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: $size-radius-sm; + background: var(--element-bg-base); + border: 1px solid var(--border-subtle); + font-size: 10px; + font-weight: $font-weight-medium; + color: var(--color-text-secondary); + font-family: $font-family-mono; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__token-grid { + padding: $size-gap-2; + background: var(--element-bg-subtle); + border-radius: $size-radius-base; + border: 1px solid var(--border-subtle); + max-height: 220px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 2px; + } + } + + &__token { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: $size-radius-sm; + border: 1px solid var(--border-subtle); + background: var(--element-bg-base); + font-size: $font-size-xs; + color: var(--color-text-secondary); + cursor: pointer; + transition: + background $motion-fast $easing-standard, + border-color $motion-fast $easing-standard, + color $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-medium); + border-color: var(--border-medium); + color: var(--color-text-primary); + } + + &.is-on { + border-color: var(--color-primary); + background: rgba(var(--color-primary-rgb, 99 102 241) / 0.08); + color: var(--color-text-primary); + } + } + + &__token-name { + font-family: $font-family-mono; + font-size: 11px; + } + + &__empty-inline { + font-size: 10px; + color: var(--color-text-tertiary); + font-style: italic; + opacity: 0.7; + } +} + +@media (max-width: 720px) { + .agent-card { + &__icon-area { + width: 48px; + } + + &__header { + flex-direction: column; + } + + &__actions { + width: 100%; + justify-content: flex-end; + } + } +} + +@keyframes agent-card-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-card { + animation: none; + transition: none; + } +} diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx b/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx new file mode 100644 index 00000000..e9fccb18 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { + Bot, + SlidersHorizontal, + Wrench, + Puzzle, + Cpu, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Badge, IconButton, Switch } from '@/component-library'; +import type { AgentWithCapabilities } from '../agentsStore'; +import { AGENT_ICON_MAP, CAPABILITY_ACCENT } from '../agentsIcons'; +import { getCardGradient } from '@/shared/utils/cardGradients'; +import { getAgentBadge } from '../utils'; +import './AgentCard.scss'; + +interface AgentCardProps { + agent: AgentWithCapabilities; + index?: number; + soloEnabled: boolean; + skillCount?: number; + onToggleSolo: (agentId: string, enabled: boolean) => void; + onOpenDetails: (agent: AgentWithCapabilities) => void; +} + +const AgentCard: React.FC = ({ + agent, + index = 0, + soloEnabled, + skillCount = 0, + onToggleSolo, + onOpenDetails, +}) => { + const { t } = useTranslation('scenes/agents'); + const badge = getAgentBadge(t, agent.agentKind, agent.subagentSource); + const Icon = AGENT_ICON_MAP[(agent.iconKey ?? 'bot') as keyof typeof AGENT_ICON_MAP] ?? Bot; + const totalTools = agent.toolCount ?? agent.defaultTools?.length ?? 0; + const openDetails = () => onOpenDetails(agent); + + return ( +
e.key === 'Enter' && openDetails()} + aria-label={agent.name} + > +
+
+ +
+
+ +
+
+
+
+ {agent.name} +
+ + {agent.agentKind === 'mode' ? : } + {badge.label} + + {!agent.enabled ? ( + {t('agentCard.badges.disabled', '已禁用')} + ) : null} + {agent.model ? ( + {agent.model} + ) : null} +
+
+

{agent.description?.trim() || '—'}

+
+ +
e.stopPropagation()}> + onToggleSolo(agent.id, !soloEnabled)} + size="small" + /> + + + +
+
+ +
+
+ {agent.capabilities.slice(0, 3).map((cap) => ( + + {cap.category} + + ))} +
+ + + {t('agentCard.meta.tools', '{{count}} 个工具', { count: totalTools })} + + {agent.agentKind === 'mode' && skillCount > 0 ? ( + + + {t('agentCard.meta.skills', '{{count}} 个 Skills', { count: skillCount })} + + ) : null} +
+
+
+ ); +}; +export default AgentCard; diff --git a/src/web-ui/src/app/scenes/team/components/AgentGallery.scss b/src/web-ui/src/app/scenes/agents/components/AgentGallery.scss similarity index 100% rename from src/web-ui/src/app/scenes/team/components/AgentGallery.scss rename to src/web-ui/src/app/scenes/agents/components/AgentGallery.scss diff --git a/src/web-ui/src/app/scenes/team/components/AgentGallery.tsx b/src/web-ui/src/app/scenes/agents/components/AgentGallery.tsx similarity index 73% rename from src/web-ui/src/app/scenes/team/components/AgentGallery.tsx rename to src/web-ui/src/app/scenes/agents/components/AgentGallery.tsx index d5fe074c..b36a4697 100644 --- a/src/web-ui/src/app/scenes/team/components/AgentGallery.tsx +++ b/src/web-ui/src/app/scenes/agents/components/AgentGallery.tsx @@ -1,38 +1,20 @@ import React, { useState, useCallback, useEffect } from 'react'; import { Search, ChevronDown, ChevronUp, Plus, Check, Bot, Cpu } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Badge } from '@/component-library'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import { SubagentAPI } from '@/infrastructure/api/service-api/SubagentAPI'; -import type { SubagentSource } from '@/infrastructure/api/service-api/SubagentAPI'; import { - useTeamStore, + useAgentsStore, CAPABILITY_CATEGORIES, CAPABILITY_COLORS, type AgentWithCapabilities, type CapabilityCategory, - type AgentKind, -} from '../teamStore'; -import { AGENT_ICON_MAP } from '../teamIcons'; +} from '../agentsStore'; +import { AGENT_ICON_MAP } from '../agentsIcons'; +import { enrichCapabilities, getAgentBadge } from '../utils'; import './AgentGallery.scss'; -// ─── Agent badge config ─────────────────────────────────────────────────────── - -interface AgentBadgeConfig { - variant: 'accent' | 'info' | 'success' | 'purple' | 'neutral'; - label: string; -} - -function getAgentBadge(agentKind?: AgentKind, source?: SubagentSource): AgentBadgeConfig { - if (agentKind === 'mode') { - return { variant: 'accent', label: 'Agent' }; - } - switch (source) { - case 'user': return { variant: 'success', label: '用户 Sub-Agent' }; - case 'project': return { variant: 'purple', label: '项目 Sub-Agent' }; - default: return { variant: 'info', label: 'Sub-Agent' }; - } -} - // ─── Agent icon ─────────────────────────────────────────────────────────────── const AgentIcon: React.FC<{ iconKey?: string; primaryCap?: string; size?: number }> = ({ @@ -80,9 +62,10 @@ interface AgentCardProps { } const AgentCard: React.FC = ({ agent, isMember, onAdd, onRemove }) => { + const { t } = useTranslation('scenes/agents'); const [expanded, setExpanded] = useState(false); const primaryCap = agent.capabilities[0]?.category; - const badge = getAgentBadge(agent.agentKind, agent.subagentSource); + const badge = getAgentBadge(t, agent.agentKind, agent.subagentSource); return (
@@ -135,7 +118,7 @@ const AgentCard: React.FC = ({ agent, isMember, onAdd, onRemove @@ -151,8 +134,8 @@ const AgentCard: React.FC = ({ agent, isMember, onAdd, onRemove
- {agent.toolCount} 个工具 - {agent.model && 模型 · {agent.model}} + {t('gallery.toolCount', '{{count}} 个工具', { count: agent.toolCount })} + {agent.model && {t('gallery.modelLabel', '模型')} · {agent.model}} {agent.agentKind === 'mode' ? : } {badge.label} @@ -162,7 +145,7 @@ const AgentCard: React.FC = ({ agent, isMember, onAdd, onRemove className={`ag-card__add-full ${isMember ? 'is-added' : ''}`} onClick={isMember ? onRemove : onAdd} > - {isMember ? '已加入团队' : '加入当前团队'} + {isMember ? t('gallery.joinedTeam', '已加入团队') : t('gallery.addCurrentTeam', '加入当前团队')}
)} @@ -170,42 +153,17 @@ const AgentCard: React.FC = ({ agent, isMember, onAdd, onRemove ); }; -// ─── Enrich capabilities ────────────────────────────────────────────────────── - -function enrichCapabilities(agent: AgentWithCapabilities): AgentWithCapabilities { - if (agent.capabilities?.length) return agent; - const id = agent.id.toLowerCase(); - const name = agent.name.toLowerCase(); - - if (agent.agentKind === 'mode') { - if (id === 'agentic') return { ...agent, capabilities: [{ category: '编码', level: 5 }, { category: '分析', level: 4 }] }; - if (id === 'plan') return { ...agent, capabilities: [{ category: '分析', level: 5 }, { category: '文档', level: 3 }] }; - if (id === 'debug') return { ...agent, capabilities: [{ category: '编码', level: 5 }, { category: '分析', level: 3 }] }; - if (id === 'cowork') return { ...agent, capabilities: [{ category: '分析', level: 4 }, { category: '创意', level: 3 }] }; - } - - if (id === 'explore') return { ...agent, capabilities: [{ category: '分析', level: 4 }, { category: '编码', level: 3 }] }; - if (id === 'file_finder') return { ...agent, capabilities: [{ category: '分析', level: 3 }, { category: '编码', level: 2 }] }; - - if (name.includes('code') || name.includes('debug') || name.includes('test')) { - return { ...agent, capabilities: [{ category: '编码', level: 4 }] }; - } - if (name.includes('doc') || name.includes('write')) { - return { ...agent, capabilities: [{ category: '文档', level: 4 }] }; - } - return { ...agent, capabilities: [{ category: '分析', level: 3 }] }; -} - // ─── Gallery ────────────────────────────────────────────────────────────────── const AgentGallery: React.FC = () => { - const { teams, activeTeamId, addMember, removeMember } = useTeamStore(); + const { t } = useTranslation('scenes/agents'); + const { agentTeams, activeAgentTeamId, addMember, removeMember } = useAgentsStore(); const [agents, setAgents] = useState([]); const [query, setQuery] = useState(''); const [activeCategories, setActiveCategories] = useState>(new Set()); const [showMembersOnly, setShowMembersOnly] = useState(false); - const activeTeam = teams.find((t) => t.id === activeTeamId); + const activeTeam = agentTeams.find((t) => t.id === activeAgentTeamId); const memberIds = new Set(activeTeam?.members.map((m) => m.agentId) ?? []); useEffect(() => { @@ -279,7 +237,7 @@ const AgentGallery: React.FC = () => { setQuery(e.target.value)} /> @@ -291,7 +249,8 @@ const AgentGallery: React.FC = () => { className={`ag__pill ${showMembersOnly ? 'is-active' : ''}`} onClick={() => setShowMembersOnly((v) => !v)} > - 已加入{memberIds.size > 0 && {memberIds.size}} + {t('gallery.filter.joined')} + {memberIds.size > 0 && {memberIds.size}} {CAPABILITY_CATEGORIES.map((cat) => ( + +
+
+ +
+
+ {memberAgents.slice(0, 4).map((agent) => { + const AgentIcon = AGENT_ICON_MAP[(agent.iconKey ?? 'bot') as keyof typeof AGENT_ICON_MAP] ?? Bot; + return ( + + + + ); + })} + {team.members.length > 4 ? ( + + +{team.members.length - 4} + + ) : null} +
+ + {t('home.members', { count: team.members.length })} + + {topCapabilities.length > 0 ? ( +
+ {topCapabilities.map((cap) => ( + + {cap} + + ))} +
+ ) : null} +
+ {isExample ? {t('teamCard.badges.example', '示例')} : null} + {strategyLabel} + {team.shareContext ? ( + {t('teamCard.badges.sharedContext', '共享上下文')} + ) : null} +
+
+
+
+ ); +}; + +export default AgentTeamCard; diff --git a/src/web-ui/src/app/scenes/team/components/TeamComposer.scss b/src/web-ui/src/app/scenes/agents/components/AgentTeamComposer.scss similarity index 100% rename from src/web-ui/src/app/scenes/team/components/TeamComposer.scss rename to src/web-ui/src/app/scenes/agents/components/AgentTeamComposer.scss diff --git a/src/web-ui/src/app/scenes/team/components/TeamComposer.tsx b/src/web-ui/src/app/scenes/agents/components/AgentTeamComposer.tsx similarity index 80% rename from src/web-ui/src/app/scenes/team/components/TeamComposer.tsx rename to src/web-ui/src/app/scenes/agents/components/AgentTeamComposer.tsx index db8e41b0..ad8357dd 100644 --- a/src/web-ui/src/app/scenes/team/components/TeamComposer.tsx +++ b/src/web-ui/src/app/scenes/agents/components/AgentTeamComposer.tsx @@ -1,26 +1,21 @@ import React, { useState, useRef, useLayoutEffect, useCallback } from 'react'; import { LayoutGrid, List, Trash2, ChevronDown, Bot } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { - useTeamStore, + useAgentsStore, MOCK_AGENTS, CAPABILITY_COLORS, - type Team, - type TeamMember, + type AgentTeam, + type AgentTeamMember, type MemberRole, type AgentWithCapabilities, type CapabilityCategory, -} from '../teamStore'; -import { AGENT_ICON_MAP } from '../teamIcons'; -import './TeamComposer.scss'; +} from '../agentsStore'; +import { AGENT_ICON_MAP } from '../agentsIcons'; +import './AgentTeamComposer.scss'; // ─── Constants ──────────────────────────────────────────────────────────────── -const ROLE_LABELS: Record = { - leader: '主导', - member: '执行', - reviewer: '审查', -}; - const ROLE_COLORS: Record = { leader: '#60a5fa', member: '#6eb88c', @@ -47,13 +42,13 @@ const AgentIconSmall: React.FC<{ agent?: AgentWithCapabilities }> = ({ agent }) interface NodePos { x: number; y: number; memberId: string } -function layoutNodes(members: TeamMember[]): NodePos[] { +function layoutNodes(members: AgentTeamMember[]): NodePos[] { const leaders = members.filter((m) => m.role === 'leader'); const middles = members.filter((m) => m.role === 'member'); const reviewers = members.filter((m) => m.role === 'reviewer'); const positions: NodePos[] = []; - const placeRow = (group: TeamMember[], y: number) => { + const placeRow = (group: AgentTeamMember[], y: number) => { const n = group.length; group.forEach((m, i) => { const x = n === 1 ? 50 : 15 + (70 / Math.max(n - 1, 1)) * i; @@ -67,7 +62,7 @@ function layoutNodes(members: TeamMember[]): NodePos[] { return positions; } -function buildEdges(members: TeamMember[]): Array<[string, string]> { +function buildEdges(members: AgentTeamMember[]): Array<[string, string]> { const l = members.filter((m) => m.role === 'leader').map((m) => m.agentId); const m = members.filter((m) => m.role === 'member').map((m) => m.agentId); const r = members.filter((m) => m.role === 'reviewer').map((m) => m.agentId); @@ -90,7 +85,7 @@ const NODE_W = 176; const NODE_H = 72; interface NodeProps { - member: TeamMember; + member: AgentTeamMember; pos: NodePos; cw: number; ch: number; @@ -99,12 +94,18 @@ interface NodeProps { } const FormationNode: React.FC = ({ member, pos, cw, ch, onRoleChange, onRemove }) => { + const { t } = useTranslation('scenes/agents'); const [roleOpen, setRoleOpen] = useState(false); const agent = getAgent(member.agentId); const x = (pos.x / 100) * cw - NODE_W / 2; const y = (pos.y / 100) * ch - NODE_H / 2; const roleColor = ROLE_COLORS[member.role]; const primaryCap = agent?.capabilities[0]?.category; + const roleLabels: Record = { + leader: t('composer.role.leader'), + member: t('composer.role.member'), + reviewer: t('composer.role.reviewer'), + }; return (
@@ -113,7 +114,7 @@ const FormationNode: React.FC = ({ member, pos, cw, ch, onRoleChange,
{agent?.name ?? member.agentId} -
@@ -126,20 +127,20 @@ const FormationNode: React.FC = ({ member, pos, cw, ch, onRoleChange, style={{ color: roleColor }} onClick={() => setRoleOpen((v) => !v)} > - {ROLE_LABELS[member.role]} + {roleLabels[member.role]} {roleOpen && ( <>
- {(Object.keys(ROLE_LABELS) as MemberRole[]).map((r) => ( + {(Object.keys(roleLabels) as MemberRole[]).map((r) => ( ))}
@@ -166,8 +167,9 @@ const FormationNode: React.FC = ({ member, pos, cw, ch, onRoleChange, // ─── Formation View ─────────────────────────────────────────────────────────── -const FormationView: React.FC<{ team: Team }> = ({ team }) => { - const { removeMember, updateMemberRole } = useTeamStore(); +const FormationView: React.FC<{ team: AgentTeam }> = ({ team }) => { + const { t } = useTranslation('scenes/agents'); + const { removeMember, updateMemberRole } = useAgentsStore(); const ref = useRef(null); const [size, setSize] = useState({ w: 600, h: 320 }); @@ -185,8 +187,8 @@ const FormationView: React.FC<{ team: Team }> = ({ team }) => {
-

从左侧选择 Agent 加入团队

-

点击 Agent 卡片的 + 按钮

+

{t('formation.empty')}

+

{t('formation.emptySub')}

); @@ -249,14 +251,20 @@ const FormationView: React.FC<{ team: Team }> = ({ team }) => { // ─── List View ──────────────────────────────────────────────────────────────── -const ListView: React.FC<{ team: Team }> = ({ team }) => { - const { removeMember, updateMemberRole } = useTeamStore(); +const ListView: React.FC<{ team: AgentTeam }> = ({ team }) => { + const { t } = useTranslation('scenes/agents'); + const { removeMember, updateMemberRole } = useAgentsStore(); + const roleLabels: Record = { + leader: t('composer.role.leader'), + member: t('composer.role.member'), + reviewer: t('composer.role.reviewer'), + }; if (team.members.length === 0) { return (
-

暂无成员,从左侧 Agent 图鉴添加

+

{t('composer.emptyMembers', '暂无成员,从左侧 Agent 图鉴添加')}

); } @@ -267,10 +275,10 @@ const ListView: React.FC<{ team: Team }> = ({ team }) => { # - Agent - 角色 - 工具 - 模型 + {t('composer.columns.agent', 'Agent')} + {t('composer.columns.role', '角色')} + {t('composer.columns.tools', '工具')} + {t('composer.columns.model', '模型')} @@ -305,8 +313,8 @@ const ListView: React.FC<{ team: Team }> = ({ team }) => { onChange={(e) => updateMemberRole(team.id, member.agentId, e.target.value as MemberRole)} style={{ color: ROLE_COLORS[member.role] }} > - {(Object.keys(ROLE_LABELS) as MemberRole[]).map((r) => ( - + {(Object.keys(roleLabels) as MemberRole[]).map((r) => ( + ))} @@ -316,7 +324,7 @@ const ListView: React.FC<{ team: Team }> = ({ team }) => { @@ -332,13 +340,19 @@ const ListView: React.FC<{ team: Team }> = ({ team }) => { // ─── Composer shell ─────────────────────────────────────────────────────────── -const TeamComposer: React.FC = () => { - const { teams, activeTeamId, viewMode, setViewMode, updateTeam } = useTeamStore(); +const AgentTeamComposer: React.FC = () => { + const { t } = useTranslation('scenes/agents'); + const { agentTeams, activeAgentTeamId, viewMode, setViewMode, updateAgentTeam } = useAgentsStore(); const [editingName, setEditingName] = useState(false); const [nameVal, setNameVal] = useState(''); const nameRef = useRef(null); + const roleLabels: Record = { + leader: t('composer.role.leader'), + member: t('composer.role.member'), + reviewer: t('composer.role.reviewer'), + }; - const team = teams.find((t) => t.id === activeTeamId); + const team = agentTeams.find((t) => t.id === activeAgentTeamId); const startEdit = useCallback(() => { if (!team) return; @@ -348,14 +362,14 @@ const TeamComposer: React.FC = () => { }, [team]); const commitName = useCallback(() => { - if (team && nameVal.trim()) updateTeam(team.id, { name: nameVal.trim() }); + if (team && nameVal.trim()) updateAgentTeam(team.id, { name: nameVal.trim() }); setEditingName(false); - }, [team, nameVal, updateTeam]); + }, [team, nameVal, updateAgentTeam]); if (!team) { return (
-

请选择或新建一个团队

+

{t('composer.emptyTeam')}

); } @@ -379,24 +393,28 @@ const TeamComposer: React.FC = () => { autoFocus /> ) : ( - + {team.name} )} · - {team.members.length} 名成员 + {t('composer.memberCount', { count: team.members.length })} - {team.strategy === 'collaborative' ? '协作' : team.strategy === 'sequential' ? '顺序' : '自由'} + {team.strategy === 'collaborative' + ? t('composer.strategy.collaborative') + : team.strategy === 'sequential' + ? t('composer.strategy.sequential') + : t('composer.strategy.free')}
{/* Role legend */}
- {(Object.keys(ROLE_LABELS) as MemberRole[]).map((r) => ( + {(Object.keys(roleLabels) as MemberRole[]).map((r) => ( - {ROLE_LABELS[r]} + {roleLabels[r]} ))}
@@ -410,14 +428,14 @@ const TeamComposer: React.FC = () => { onClick={() => setViewMode('formation')} > - 阵型 + {t('composer.viewMode.formation')}
@@ -435,4 +453,4 @@ const TeamComposer: React.FC = () => { ); }; -export default TeamComposer; +export default AgentTeamComposer; diff --git a/src/web-ui/src/app/scenes/team/components/TeamTabBar.scss b/src/web-ui/src/app/scenes/agents/components/AgentTeamTabBar.scss similarity index 99% rename from src/web-ui/src/app/scenes/team/components/TeamTabBar.scss rename to src/web-ui/src/app/scenes/agents/components/AgentTeamTabBar.scss index ca7423c9..5a05adf9 100644 --- a/src/web-ui/src/app/scenes/team/components/TeamTabBar.scss +++ b/src/web-ui/src/app/scenes/agents/components/AgentTeamTabBar.scss @@ -51,7 +51,7 @@ } } - &__team-icon { + &__agent-team-icon { display: flex; align-items: center; flex-shrink: 0; diff --git a/src/web-ui/src/app/scenes/team/components/TeamTabBar.tsx b/src/web-ui/src/app/scenes/agents/components/AgentTeamTabBar.tsx similarity index 71% rename from src/web-ui/src/app/scenes/team/components/TeamTabBar.tsx rename to src/web-ui/src/app/scenes/agents/components/AgentTeamTabBar.tsx index 0279c999..82515fce 100644 --- a/src/web-ui/src/app/scenes/team/components/TeamTabBar.tsx +++ b/src/web-ui/src/app/scenes/agents/components/AgentTeamTabBar.tsx @@ -1,22 +1,23 @@ import React, { useState } from 'react'; import { Plus, X, Code2, BarChart2, LayoutTemplate, Rocket, Users, Briefcase, Layers, type LucideIcon } from 'lucide-react'; -import { useTeamStore, TEAM_TEMPLATES } from '../teamStore'; -import { TEAM_ICON_MAP, getTeamAccent } from '../teamIcons'; -import './TeamTabBar.scss'; +import { useTranslation } from 'react-i18next'; +import { useAgentsStore, AGENT_TEAM_TEMPLATES } from '../agentsStore'; +import { AGENT_TEAM_ICON_MAP, getAgentTeamAccent } from '../agentsIcons'; +import './AgentTeamTabBar.scss'; -// ─── Team icon renderer ─────────────────────────────────────────────────────── +// ─── Agent team icon renderer ───────────────────────────────────────────────── -const TeamIconBadge: React.FC<{ iconKey: string; teamId: string; size?: number }> = ({ +const AgentTeamIconBadge: React.FC<{ iconKey: string; teamId: string; size?: number }> = ({ iconKey, teamId, size = 12, }) => { - const accent = getTeamAccent(teamId); - const key = iconKey as keyof typeof TEAM_ICON_MAP; - const IconComp = TEAM_ICON_MAP[key] ?? Users; + const accent = getAgentTeamAccent(teamId); + const key = iconKey as keyof typeof AGENT_TEAM_ICON_MAP; + const IconComp = AGENT_TEAM_ICON_MAP[key] ?? Users; return ( @@ -24,7 +25,7 @@ const TeamIconBadge: React.FC<{ iconKey: string; teamId: string; size?: number } ); }; -// ─── New team form ──────────────────────────────────────────────────────────── +// ─── New agent team form ────────────────────────────────────────────────────── const ICON_OPTIONS: Array<{ key: string; Icon: LucideIcon }> = [ { key: 'code', Icon: Code2 }, @@ -38,8 +39,9 @@ const ICON_OPTIONS: Array<{ key: string; Icon: LucideIcon }> = [ interface NewTeamForm { name: string; icon: string; description: string } -const TeamTabBar: React.FC = () => { - const { teams, activeTeamId, setActiveTeam, addTeam, deleteTeam } = useTeamStore(); +const AgentTeamTabBar: React.FC = () => { + const { t } = useTranslation('scenes/agents'); + const { agentTeams, activeAgentTeamId, setActiveAgentTeam, addAgentTeam, deleteAgentTeam } = useAgentsStore(); const [panel, setPanel] = useState<'none' | 'create' | 'templates'>('none'); const [form, setForm] = useState({ name: '', icon: 'rocket', description: '' }); @@ -47,14 +49,14 @@ const TeamTabBar: React.FC = () => { const handleCreate = () => { if (!form.name.trim()) return; - addTeam({ id: `team-${Date.now()}`, ...form, strategy: 'collaborative', shareContext: true }); + addAgentTeam({ id: `agent-team-${Date.now()}`, ...form, strategy: 'collaborative', shareContext: true }); setForm({ name: '', icon: 'rocket', description: '' }); closePanel(); }; - const handleUseTemplate = (tpl: typeof TEAM_TEMPLATES[number]) => { - addTeam({ - id: `team-${Date.now()}`, + const handleUseTemplate = (tpl: typeof AGENT_TEAM_TEMPLATES[number]) => { + addAgentTeam({ + id: `agent-team-${Date.now()}`, name: tpl.name, icon: tpl.icon, description: tpl.description, @@ -66,26 +68,26 @@ const TeamTabBar: React.FC = () => { const handleDelete = (e: React.MouseEvent, id: string) => { e.stopPropagation(); - if (teams.length <= 1) return; - deleteTeam(id); + if (agentTeams.length <= 1) return; + deleteAgentTeam(id); }; return (
{/* ── Tabs ── */} - {teams.map((team) => { - const isActive = team.id === activeTeamId; + {agentTeams.map((team) => { + const isActive = team.id === activeAgentTeamId; return (
@@ -122,7 +124,7 @@ const TeamTabBar: React.FC = () => { key={key} className={`bt-tabbar__icon-opt ${form.icon === key ? 'is-sel' : ''}`} onClick={() => setForm((f) => ({ ...f, icon: key }))} - style={form.icon === key ? { color: getTeamAccent(`team-${key}`) } : undefined} + style={form.icon === key ? { color: getAgentTeamAccent(`team-${key}`) } : undefined} > @@ -131,7 +133,7 @@ const TeamTabBar: React.FC = () => { setForm((f) => ({ ...f, name: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && handleCreate()} @@ -139,7 +141,7 @@ const TeamTabBar: React.FC = () => { /> setForm((f) => ({ ...f, description: e.target.value }))} /> @@ -149,18 +151,18 @@ const TeamTabBar: React.FC = () => { className="bt-tabbar__action bt-tabbar__action--ghost" onClick={() => setPanel('templates')} > - 从模板 + {t('tabbar.fromTemplate')}
@@ -170,14 +172,14 @@ const TeamTabBar: React.FC = () => { {panel === 'templates' && (
- 选择团队模板 + {t('tabbar.templateTitle')}
- {TEAM_TEMPLATES.map((tpl) => { - const key = tpl.icon as keyof typeof TEAM_ICON_MAP; - const IconComp = TEAM_ICON_MAP[key] ?? Users; - const accent = getTeamAccent(`team-${tpl.id}`); + {AGENT_TEAM_TEMPLATES.map((tpl) => { + const key = tpl.icon as keyof typeof AGENT_TEAM_ICON_MAP; + const IconComp = AGENT_TEAM_ICON_MAP[key] ?? Users; + const accent = getAgentTeamAccent(`team-${tpl.id}`); return (
)} @@ -213,4 +215,4 @@ const TeamTabBar: React.FC = () => { ); }; -export default TeamTabBar; +export default AgentTeamTabBar; diff --git a/src/web-ui/src/app/scenes/team/components/CapabilityBar.scss b/src/web-ui/src/app/scenes/agents/components/CapabilityBar.scss similarity index 100% rename from src/web-ui/src/app/scenes/team/components/CapabilityBar.scss rename to src/web-ui/src/app/scenes/agents/components/CapabilityBar.scss diff --git a/src/web-ui/src/app/scenes/team/components/CapabilityBar.tsx b/src/web-ui/src/app/scenes/agents/components/CapabilityBar.tsx similarity index 65% rename from src/web-ui/src/app/scenes/team/components/CapabilityBar.tsx rename to src/web-ui/src/app/scenes/agents/components/CapabilityBar.tsx index 46b3ef75..9dcd5d8a 100644 --- a/src/web-ui/src/app/scenes/team/components/CapabilityBar.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CapabilityBar.tsx @@ -1,27 +1,29 @@ import React from 'react'; import { AlertTriangle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { - useTeamStore, + useAgentsStore, MOCK_AGENTS, CAPABILITY_CATEGORIES, CAPABILITY_COLORS, - computeTeamCapabilities, + computeAgentTeamCapabilities, type AgentWithCapabilities, type CapabilityCategory, -} from '../teamStore'; +} from '../agentsStore'; import './CapabilityBar.scss'; const CapabilityBar: React.FC = () => { - const { teams, activeTeamId } = useTeamStore(); - const team = teams.find((t) => t.id === activeTeamId); + const { t } = useTranslation('scenes/agents'); + const { agentTeams, activeAgentTeamId } = useAgentsStore(); + const team = agentTeams.find((t) => t.id === activeAgentTeamId); if (!team) return null; - const coverage = computeTeamCapabilities(team, MOCK_AGENTS as AgentWithCapabilities[]); + const coverage = computeAgentTeamCapabilities(team, MOCK_AGENTS as AgentWithCapabilities[]); const weak = CAPABILITY_CATEGORIES.filter((c) => coverage[c] === 0); return (
- 能力覆盖 + {t('capability.coverage', '能力覆盖')}
{CAPABILITY_CATEGORIES.map((cat) => { @@ -29,7 +31,11 @@ const CapabilityBar: React.FC = () => { const color = CAPABILITY_COLORS[cat as CapabilityCategory]; const pct = Math.round((level / 5) * 100); return ( -
0 ? `Lv${level}` : '无覆盖'}`}> +
0 ? `Lv${level}` : t('capability.none', '无覆盖')}`} + > {cat}
{ {weak.length > 0 && (
- {weak.join('、')} 缺失 + {t('capability.warning', { cats: weak.join('、') })}
)}
diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss new file mode 100644 index 00000000..c7b12ad1 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss @@ -0,0 +1,182 @@ +@use '../../../../component-library/styles/tokens' as *; + +.th__list-body { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: $size-gap-5 clamp(16px, 2.2vw, 28px); + + &::-webkit-scrollbar { width: 4px; } + &::-webkit-scrollbar-track { background: transparent; } + &::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 2px; + } +} + +.th__list-inner { + width: min(100%, 600px); + margin-inline: auto; + display: flex; + flex-direction: column; + gap: $size-gap-4; +} + +.th__title { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + margin: 0; + line-height: $line-height-tight; +} + +.th__title-sub { + font-size: $font-size-sm; + color: var(--color-text-muted); + margin: 0; + line-height: $line-height-relaxed; +} + +.th-create-page__head { + margin-bottom: $size-gap-5; +} + +.th-create-page__form { + display: flex; + flex-direction: column; + gap: $size-gap-4; + width: min(100%, 560px); +} + +.th-create-page__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $size-gap-2; + padding-top: $size-gap-2; + border-top: 1px solid var(--border-subtle); + margin-top: $size-gap-2; +} + +.th-create-panel__field { + display: flex; + flex-direction: column; + gap: $size-gap-1; + + &--row { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +.th-create-panel__label { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: var(--color-text-secondary); +} + +.th-create-panel__label-hint { + font-weight: $font-weight-normal; + color: var(--color-text-muted); + margin-left: 4px; +} + +.th-create-panel__error { + font-size: $font-size-xs; + color: var(--color-error); +} + +.th-create-panel__readonly-row { + display: flex; + align-items: center; + gap: $size-gap-2; + margin-left: auto; + flex-shrink: 0; +} + +.th-create-panel__level-group { + display: inline-flex; + gap: $size-gap-2; +} + +.th-create-panel__level-btn { + display: inline-flex; + align-items: center; + height: 26px; + padding: 0 $size-gap-3; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-full; + background: transparent; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: var(--color-text-secondary); + cursor: pointer; + transition: + border-color $motion-fast $easing-standard, + background $motion-fast $easing-standard, + color $motion-fast $easing-standard; + + &:hover:not(:disabled) { + border-color: var(--border-medium); + color: var(--color-text-primary); + } + + &.is-active { + border-color: var(--color-primary); + background: rgba(var(--color-primary-rgb, 99 102 241) / 0.08); + color: var(--color-text-primary); + font-weight: $font-weight-semibold; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.th-create-panel__tools { + display: flex; + flex-wrap: wrap; + gap: $size-gap-1; +} + +.th-list__tool-item { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: $size-radius-sm; + border: 1px solid var(--border-subtle); + background: var(--element-bg-base); + font-size: $font-size-xs; + color: var(--color-text-secondary); + cursor: pointer; + transition: + background $motion-fast $easing-standard, + border-color $motion-fast $easing-standard, + color $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-medium); + border-color: var(--border-medium); + color: var(--color-text-primary); + } + + &.is-on { + border-color: var(--color-primary); + background: rgba(var(--color-primary-rgb, 99 102 241) / 0.08); + color: var(--color-text-primary); + } +} + +.th-list__tool-item-name { + font-family: $font-family-mono; + font-size: 11px; +} + +@container (max-width: 480px) { + .th__list-body { + padding: $size-gap-4; + } +} diff --git a/src/web-ui/src/app/scenes/team/components/CreateAgentPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx similarity index 95% rename from src/web-ui/src/app/scenes/team/components/CreateAgentPage.tsx rename to src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx index 1b12a1cb..88a6a94c 100644 --- a/src/web-ui/src/app/scenes/team/components/CreateAgentPage.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx @@ -6,15 +6,15 @@ import { SubagentAPI } from '@/infrastructure/api/service-api/SubagentAPI'; import type { SubagentLevel } from '@/infrastructure/api/service-api/SubagentAPI'; import { useNotification } from '@/shared/notification-system'; import { useCurrentWorkspace } from '@/infrastructure/hooks/useWorkspace'; -import { useTeamStore } from '../teamStore'; -import '../TeamView.scss'; -import './TeamHomePage.scss'; +import { useAgentsStore } from '../agentsStore'; +import '../AgentsView.scss'; +import './CreateAgentPage.scss'; const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const CreateAgentPage: React.FC = () => { - const { t } = useTranslation('scenes/team'); - const { openAgentsOverview } = useTeamStore(); + const { t } = useTranslation('scenes/agents'); + const { openHome } = useAgentsStore(); const notification = useNotification(); const { hasWorkspace } = useCurrentWorkspace(); @@ -61,8 +61,8 @@ const CreateAgentPage: React.FC = () => { readonly, tools: selectedTools.size > 0 ? Array.from(selectedTools) : undefined, }); - notification.success(t('agentsOverview.form.createSuccess', `已创建 Agent「${name.trim()}」`)); - openAgentsOverview(); + notification.success(t('agentsOverview.form.createSuccess', { name: name.trim() })); + openHome(); } catch (err) { notification.error( t('agentsOverview.form.createFailed', '创建失败:') + @@ -77,7 +77,7 @@ const CreateAgentPage: React.FC = () => {
{/* 顶部导航栏 */}
- @@ -179,7 +179,7 @@ const CreateAgentPage: React.FC = () => { {/* 操作按钮 */}
- + ))} +
+
+ ))} +
+
+ ); +}; + +export default MyAgentNav; diff --git a/src/web-ui/src/app/scenes/my-agent/MyAgentScene.scss b/src/web-ui/src/app/scenes/my-agent/MyAgentScene.scss new file mode 100644 index 00000000..3c49ff60 --- /dev/null +++ b/src/web-ui/src/app/scenes/my-agent/MyAgentScene.scss @@ -0,0 +1,11 @@ +.bitfun-my-agent-scene { + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + + &__loading { + width: 100%; + height: 100%; + } +} diff --git a/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx b/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx new file mode 100644 index 00000000..c83f206a --- /dev/null +++ b/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx @@ -0,0 +1,27 @@ +import React, { Suspense, lazy } from 'react'; +import { useMyAgentStore } from './myAgentStore'; +import './MyAgentScene.scss'; + +const ProfileScene = lazy(() => import('../profile/ProfileScene')); +const AgentsScene = lazy(() => import('../agents/AgentsScene')); +const SkillsScene = lazy(() => import('../skills/SkillsScene')); + +interface MyAgentSceneProps { + workspacePath?: string; +} + +const MyAgentScene: React.FC = ({ workspacePath }) => { + const activeView = useMyAgentStore((s) => s.activeView); + + return ( +
+ }> + {activeView === 'profile' && } + {activeView === 'agents' && } + {activeView === 'skills' && } + +
+ ); +}; + +export default MyAgentScene; diff --git a/src/web-ui/src/app/scenes/my-agent/myAgentConfig.ts b/src/web-ui/src/app/scenes/my-agent/myAgentConfig.ts new file mode 100644 index 00000000..aadb6307 --- /dev/null +++ b/src/web-ui/src/app/scenes/my-agent/myAgentConfig.ts @@ -0,0 +1,35 @@ +import type { PanelType } from '@/app/types'; + +export type MyAgentView = 'profile' | 'agents' | 'skills'; + +export interface MyAgentNavItem { + id: MyAgentView; + panelTab: PanelType; + labelKey: string; +} + +export interface MyAgentNavCategory { + id: string; + nameKey: string; + items: MyAgentNavItem[]; +} + +export const MY_AGENT_NAV_CATEGORIES: MyAgentNavCategory[] = [ + { + id: 'identity', + nameKey: 'nav.myAgent.categories.identity', + items: [ + { id: 'profile', panelTab: 'profile', labelKey: 'nav.items.persona' }, + ], + }, + { + id: 'collaboration', + nameKey: 'nav.myAgent.categories.collaboration', + items: [ + { id: 'agents', panelTab: 'agents', labelKey: 'nav.items.agents' }, + { id: 'skills', panelTab: 'skills', labelKey: 'nav.items.skills' }, + ], + }, +]; + +export const DEFAULT_MY_AGENT_VIEW: MyAgentView = 'profile'; diff --git a/src/web-ui/src/app/scenes/my-agent/myAgentStore.ts b/src/web-ui/src/app/scenes/my-agent/myAgentStore.ts new file mode 100644 index 00000000..baf5e3dd --- /dev/null +++ b/src/web-ui/src/app/scenes/my-agent/myAgentStore.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; +import type { MyAgentView } from './myAgentConfig'; +import { DEFAULT_MY_AGENT_VIEW } from './myAgentConfig'; + +interface MyAgentState { + activeView: MyAgentView; + setActiveView: (view: MyAgentView) => void; +} + +export const useMyAgentStore = create((set) => ({ + activeView: DEFAULT_MY_AGENT_VIEW, + setActiveView: (view) => set({ activeView: view }), +})); diff --git a/src/web-ui/src/app/scenes/nav-registry.ts b/src/web-ui/src/app/scenes/nav-registry.ts index d8d145a3..4f1a4fc4 100644 --- a/src/web-ui/src/app/scenes/nav-registry.ts +++ b/src/web-ui/src/app/scenes/nav-registry.ts @@ -17,6 +17,8 @@ type LazyNavComponent = ReturnType>; const SCENE_NAV_REGISTRY: Partial> = { settings: lazy(() => import('./settings/SettingsNav')), 'file-viewer': lazy(() => import('./file-viewer/FileViewerNav')), + 'my-agent': lazy(() => import('./my-agent/MyAgentNav')), + shell: lazy(() => import('./shell/ShellNav')), // terminal: lazy(() => import('./terminal/TerminalNav')), }; diff --git a/src/web-ui/src/app/scenes/profile/ProfileScene.scss b/src/web-ui/src/app/scenes/profile/ProfileScene.scss index 1f61302f..66a31df4 100644 --- a/src/web-ui/src/app/scenes/profile/ProfileScene.scss +++ b/src/web-ui/src/app/scenes/profile/ProfileScene.scss @@ -6,6 +6,5 @@ min-width: 0; min-height: 0; height: 100%; - overflow-y: auto; - overflow-x: hidden; + overflow: hidden; } diff --git a/src/web-ui/src/app/scenes/profile/views/PersonaView.scss b/src/web-ui/src/app/scenes/profile/views/PersonaView.scss index 9d98ecd4..9ccd719e 100644 --- a/src/web-ui/src/app/scenes/profile/views/PersonaView.scss +++ b/src/web-ui/src/app/scenes/profile/views/PersonaView.scss @@ -1,15 +1,18 @@ @use '@/component-library/styles/tokens' as *; -// ════════════════════════════════════════════════════════════════════════════ -// Agent Profile · Document / résumé aesthetic -// No card boxes. Structure comes from typography, whitespace, dividers. -// ════════════════════════════════════════════════════════════════════════════ +// ═══════════════════════════════════════════════════════════════ +// PersonaView — clean vertical flow, no nested scroll containers +// Parent (.bitfun-profile-scene) provides the scrollable viewport. +// ═══════════════════════════════════════════════════════════════ -// ── Keyframes ────────────────────────────────────────────────────────────── @keyframes bp-fade-up { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +@keyframes bp-scroll-bounce { + 0%, 100% { transform: translateX(-50%) translateY(0); opacity: 0.45; } + 50% { transform: translateX(-50%) translateY(7px); opacity: 0.9; } +} @keyframes bp-pulse-section { 0% { box-shadow: inset 0 0 0 0 color-mix(in srgb, var(--color-accent-500) 0%, transparent); } 35% { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent-500) 45%, transparent); } @@ -28,58 +31,133 @@ from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } } +@keyframes bp-wip-in { + from { opacity: 0; transform: translateY(-6px) scale(0.9); } + to { opacity: 1; transform: none; } +} +@keyframes bp-wip-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.7); } +} @keyframes bp-overlay-in { from { opacity: 0; } to { opacity: 1; } } @keyframes bp-overlay-out { from { opacity: 1; } to { opacity: 0; } } @keyframes bp-box-in { from { opacity: 0; transform: scale(0.86) translateY(18px); } to { opacity: 1; transform: none; } } @keyframes bp-box-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: scale(0.86) translateY(18px); } } -// ── Root ─────────────────────────────────────────────────────────────────── +$gutter: clamp(40px, 6vw, 80px); + +// ── Root ───────────────────────────────────────────────────── .bp { - display: flex; - flex-direction: column; + position: relative; width: 100%; - max-width: 820px; - margin: 0 auto; - padding: 32px 44px 60px; - box-sizing: border-box; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; animation: bp-fade-up 0.3s $easing-standard both; - // ── Hero ──────────────────────────────────────────────────────────── - &-hero { + // ── Home section ───────────────────────────────────────── + &-home { + position: absolute; + inset: 0; display: flex; + flex-direction: row; align-items: center; - gap: 20px; - margin-bottom: 36px; + justify-content: center; + gap: 0; + border-bottom: 1px solid var(--border-subtle); + padding: 0 max(#{$gutter}, calc((100% - 1080px) / 2)); + will-change: transform, opacity; + transition: transform 0.38s cubic-bezier(0.4, 0, 1, 1), + opacity 0.3s ease; + + &.is-hidden { + transform: translateY(-28px); + opacity: 0; + pointer-events: none; + } + // ── Left column — Avatar ────────────────────────────── &__left { - flex: 1; - min-width: 0; + flex: 0 0 auto; display: flex; align-items: center; - gap: 18px; + justify-content: center; + padding: 48px 56px 80px 0; + align-self: stretch; } - &__avatar { + // ── Full-body panda ─────────────────────────────────── + &__panda { + position: relative; flex-shrink: 0; - width: 80px; - height: 80px; - border-radius: 50%; - background: transparent; + user-select: none; + + &-img { + display: block; + width: auto; + height: 260px; + max-height: 60vh; + object-fit: contain; + object-position: bottom center; + pointer-events: none; + filter: drop-shadow(0 12px 32px rgba(0, 0, 0, 0.22)); + + &--default { + position: relative; + z-index: 1; + transition: opacity $motion-fast $easing-standard; + } + + &--hover { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 2; + opacity: 0; + transition: opacity $motion-fast $easing-standard; + } + } + + &:hover &-img--hover { opacity: 1; } + &:hover &-img--default { opacity: 0; } + } + + // ── Right column — Identity + Radar + Categories ────── + &__right { + flex: 1; display: flex; - align-items: center; - justify-content: center; - color: var(--color-text-muted); - transition: color $motion-fast $easing-standard; + flex-direction: column; + gap: 20px; + padding: 48px 0 80px 56px; + min-width: 0; + } - &:hover { color: var(--color-text-secondary); } + // ── Description block ───────────────────────────────── + &__body-row { + display: block; } - &__info { + &__desc-block { flex: 1; min-width: 0; + max-width: 520px; + min-height: 180px; display: flex; flex-direction: column; - gap: 5px; + justify-content: flex-start; + gap: 0; + padding: 18px 20px; + border-radius: 12px; + border: 1px dashed var(--border-subtle); + background: transparent; + cursor: default; + transition: border-color $motion-fast $easing-standard; + + &:hover { + border-color: var(--border-medium); + } } &__name-row { @@ -91,7 +169,7 @@ &__name { margin: 0; - font-size: 22px; + font-size: 24px; font-weight: $font-weight-semibold; color: var(--color-text-primary); letter-spacing: -0.4px; @@ -100,32 +178,27 @@ align-items: center; gap: 6px; transition: color $motion-fast $easing-standard; - - &:hover { color: var(--color-text-primary); } + &:hover { color: var(--color-accent-400); } } &__name-edit { opacity: 0; color: var(--color-text-disabled); transition: opacity $motion-fast $easing-standard; - flex-shrink: 0; } &__name:hover &__name-edit { opacity: 1; } &__name-input { - min-width: 160px; - .bitfun-input-container { height: 34px; border-radius: 6px; background: var(--element-bg-subtle); border: 1px solid var(--border-base); - padding: 0 8px; + padding: 0 10px; } - .bitfun-input { - font-size: 22px; + font-size: 24px; font-weight: $font-weight-semibold; letter-spacing: -0.4px; color: var(--color-text-primary); @@ -133,26 +206,15 @@ } } - &__badge { - font-size: 10px; - font-weight: $font-weight-medium; - color: var(--color-text-muted); - background: var(--element-bg-soft); - border-radius: 3px; - padding: 2px 7px; - letter-spacing: 0.6px; - text-transform: uppercase; - flex-shrink: 0; - } - &__desc { margin: 0; font-size: $font-size-sm; color: var(--color-text-muted); - line-height: 1.65; + line-height: 1.8; cursor: pointer; + white-space: pre-wrap; + word-break: break-word; transition: color $motion-fast $easing-standard; - display: inline-block; &:hover { color: var(--color-text-secondary); } @@ -163,85 +225,529 @@ opacity: 0; transition: opacity $motion-fast $easing-standard; } - &:hover::after { opacity: 1; } } + // desc-block hint — pinned to bottom + &__desc-block-hint { + font-size: 11px; + color: var(--color-text-disabled); + font-style: italic; + margin: auto 0 0; + padding-top: 10px; + } + &__desc-input { width: 100%; - .bitfun-input-container { - height: 34px; border-radius: 6px; background: var(--element-bg-subtle); border: 1px solid var(--border-base); padding: 0 10px; } - .bitfun-input { font-size: $font-size-sm; - line-height: 1.65; color: var(--color-text-primary); font-family: inherit; } } - &__radar { + // ── Hint text ───────────────────────────────────────── + &__hint { + margin: 0; + font-size: 12px; + color: var(--color-text-disabled); + line-height: 1.5; + } + + // ── Action row (categories + CTA) ──────────────────── + &__action-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + &__cat { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--color-text-muted); + font-size: 12px; + font-family: inherit; + cursor: pointer; + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + &:hover { + color: var(--color-accent-400); + background: color-mix(in srgb, var(--color-accent-500) 8%, transparent); + } + + &:active { opacity: 0.7; } + } + + &__cat-label { + font-weight: $font-weight-medium; + color: inherit; + } + + &__cat-desc { + font-size: 11px; + color: var(--color-text-disabled); + padding-left: 6px; + border-left: 1px solid var(--border-subtle); + } + + // ── WIP badge ──────────────────────────────────────── + &__wip { + display: inline-flex; + align-items: center; + gap: 5px; + height: 20px; + padding: 0 8px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, #f59e0b 35%, transparent); + background: color-mix(in srgb, #f59e0b 8%, transparent); + color: #d97706; + font-size: 11px; + font-weight: $font-weight-medium; + letter-spacing: 0.4px; + cursor: default; + user-select: none; + flex-shrink: 0; + animation: bp-wip-in 0.4s $easing-standard 0.15s both; + } + + &__wip-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: #f59e0b; + flex-shrink: 0; + animation: bp-wip-pulse 2.4s ease-in-out infinite; + } + + // ── Inline enter link ───────────────────────────────── + &__enter { + display: inline; + padding: 0; + margin-left: 4px; + border: none; + background: transparent; + color: var(--color-accent-400); + font-size: inherit; + font-family: inherit; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: color-mix(in srgb, var(--color-accent-400) 40%, transparent); + transition: color $motion-fast $easing-standard, + text-decoration-color $motion-fast $easing-standard; + + &:hover { + color: var(--color-accent-300); + text-decoration-color: var(--color-accent-300); + } + } + } + + // ── Detail section (header + zones) ────────────────────── + &-detail { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + overflow: hidden; + will-change: opacity, transform; + transition: opacity 0.36s ease 0.08s, + transform 0.36s cubic-bezier(0, 0, 0.2, 1) 0.08s; + + &.is-hidden { + opacity: 0; + transform: translateY(16px); + pointer-events: none; + } + } + + // ── Hero header (persistent, compact) ─────────────────── + &-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 8px max(#{$gutter}, calc((100% - 1080px) / 2)); + flex-shrink: 0; + border-bottom: 1px solid var(--border-subtle); + background: var(--color-bg-base); + + &__left { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; + } + + &__panda { flex-shrink: 0; - border-radius: 10px; - padding: 6px; - outline: none; + position: relative; + height: 64px; + user-select: none; + cursor: pointer; + transition: transform 0.18s $easing-standard, filter 0.18s ease; + + &:hover { transform: translateY(-2px); filter: drop-shadow(0 6px 14px rgba(0,0,0,0.25)); } + &:active { transform: translateY(0) scale(0.96); } + + &-default, + &-hover { + display: block; + height: 100%; + width: auto; + object-fit: contain; + object-position: bottom center; + pointer-events: none; + filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.18)); + } + + &-default { + position: relative; + z-index: 1; + transition: opacity $motion-fast $easing-standard; + } + + &-hover { + position: absolute; + inset: 0; + width: 100%; + z-index: 2; + opacity: 0; + transition: opacity $motion-fast $easing-standard; + } + + &:hover &-hover { opacity: 1; } + &:hover &-default { opacity: 0; } + } + + &__info { + min-width: 0; display: flex; flex-direction: column; + gap: 2px; + } + + &__name-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + &__name { + margin: 0; + font-size: 15px; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + letter-spacing: -0.2px; + cursor: pointer; + display: inline-flex; align-items: center; - gap: 3px; + gap: 5px; + transition: color $motion-fast $easing-standard; + &:hover { color: var(--color-accent-400); } + } + + &__name-edit { + opacity: 0; + color: var(--color-text-disabled); + transition: opacity $motion-fast $easing-standard; + } + + &__name:hover &__name-edit { opacity: 1; } + + &__badge { + font-size: 10px; + font-weight: $font-weight-medium; + color: var(--color-accent-500); + background: color-mix(in srgb, var(--color-accent-500) 10%, var(--element-bg-soft)); + border: 1px solid color-mix(in srgb, var(--color-accent-500) 20%, transparent); + border-radius: 999px; + padding: 2px 8px; + letter-spacing: 0.5px; + text-transform: uppercase; + flex-shrink: 0; + } + + &__desc { + margin: 0; + font-size: 12px; + color: var(--color-text-disabled); + line-height: 1.4; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 360px; + transition: color $motion-fast $easing-standard; + + &:hover { color: var(--color-text-muted); } + } + + &__radar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.85; + transition: opacity $motion-fast $easing-standard; + &:hover { opacity: 1; } } } - // ── Radar SVG ──────────────────────────────────────────────────────── + // ── Radar SVG ────────────────────────────────────────────── &-radar { display: block; overflow: visible; } - // ── Section ─────────────────────────────────────────────────────────── - &-section { + // ── Content row (viewport + tab rail) ───────────────────── + &-content { + flex: 1; + display: flex; + flex-direction: row; + overflow: hidden; + min-height: 0; + } + + // ── Zone viewport ────────────────────────────────────────── + &-zone-viewport { + flex: 1; + overflow: hidden; + min-width: 0; + position: relative; + } + + // ── Zone panel (one per section) ─────────────────────────── + &-zone-panel { + display: none; + height: 100%; + + &.is-active { + display: flex; + flex-direction: column; + overflow-y: auto; + overflow-x: hidden; + + &::-webkit-scrollbar { width: 4px; } + &::-webkit-scrollbar-track { background: transparent; } + &::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 2px; + } + } + } + + // ── Zone inner — margin auto achieves vertical centering; + // when content overflows the panel scrolls instead ─────── + &-zone-inner { display: flex; flex-direction: column; - gap: 10px; - margin-bottom: 32px; - border-radius: 10px; + gap: 16px; + padding: 32px max(#{$gutter}, calc((100% - 1080px) / 2)) 40px; + width: 100%; + // push equal free space top & bottom → centers content + // collapses to 0 automatically when content overflows + margin-top: auto; + margin-bottom: auto; + } - &.is-pulse { animation: bp-pulse-section 0.85s $easing-standard; } + @keyframes bp-zone-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } + } - &__title { - margin: 0 0 4px; - font-size: 16px; - font-weight: $font-weight-semibold; - color: var(--color-text-primary); - letter-spacing: -0.15px; + // ── Tab rail — minimal dot indicators ───────────────────── + // ── Tab rail ─────────────────────────────────────────────── + // Width is FIXED at 28px — never affects layout. + // On hover (.is-expanded) an absolutely-positioned overlay + // list appears over the content; nothing is pushed or pulled. + &-tab-rail { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + flex-shrink: 0; + margin-right: 12px; + z-index: 20; + overflow: visible; + + // ── dots column (always in flow) ───────────────────────── + &__dots { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + padding: 10px 0; + transition: opacity 0.15s ease; + } + + // dots fade out when list is visible + &.is-expanded &__dots { + opacity: 0; + } + + // ── overlay list (absolutely positioned, no layout impact) ─ + &__list { + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%) scale(0.88); + transform-origin: right center; + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + background: transparent; + border: none; + white-space: nowrap; + opacity: 0; + pointer-events: none; + z-index: 30; + transition: opacity 0.18s ease, + transform 0.18s cubic-bezier(0.34, 1.3, 0.64, 1); + } + + &.is-expanded &__list { + opacity: 1; + transform: translateY(-50%) scale(1); + pointer-events: auto; + transition: opacity 0.16s ease 0.08s, + transform 0.2s cubic-bezier(0.34, 1.3, 0.64, 1) 0.05s; + } + + // ── list item ───────────────────────────────────────────── + &__item { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 0; + border: none; + background: transparent; + cursor: pointer; + color: var(--color-text-disabled); + font-size: 14px; + font-weight: $font-weight-medium; + text-align: right; + transition: color 0.12s ease; + + // left dot — mirrors the dots column, creates visual continuity + &::before { + content: ''; + display: block; + width: 5px; + height: 5px; + border-radius: 50%; + flex-shrink: 0; + background: var(--color-text-disabled); + opacity: 0.5; + transition: background 0.12s ease, + transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), + opacity 0.12s ease; + // enters with a slight scale-up from the left + transform: scale(0.6); + } + + &.is-active { + color: var(--color-accent-500); + + &::before { + background: var(--color-accent-500); + opacity: 1; + transform: scale(1); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent-500) 20%, transparent); + } + } + + &:hover:not(.is-active) { + color: var(--color-text-primary); + + &::before { + opacity: 0.8; + transform: scale(1); + } + } } } - // ── Card (subtle container for each block) ───────────────────────── + // when list appears, trigger dot scale-in animation + &-tab-rail.is-expanded &-tab-rail__item::before { + transform: scale(1); + } + &-tab-rail.is-expanded &-tab-rail__item.is-active::before { + transform: scale(1); + } + + // ── Individual dot button ─────────────────────────────────── + &-tab-btn { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + + &.is-active .bp-tab-btn__dot { + width: 8px; + height: 8px; + background: var(--color-accent-500); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent-500) 22%, transparent); + } + + &__dot { + display: block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-text-disabled); + flex-shrink: 0; + will-change: transform, height, width, opacity; + transition: width 0.18s $easing-standard, + height 0.18s $easing-standard, + background 0.15s $easing-standard, + box-shadow 0.15s $easing-standard, + opacity 0.15s $easing-standard; + } + } + + // ── Card ─────────────────────────────────────────────────── &-card { display: flex; flex-direction: column; gap: 10px; padding: 14px 16px; - border-radius: 10px; - border: 1px dashed var(--border-subtle); + border-radius: 12px; + border: none; background: transparent; - transition: border-color $motion-fast $easing-standard; min-width: 0; overflow: visible; - &.is-pulse { animation: bp-pulse-border 0.85s $easing-standard; } + &.is-pulse { animation: bp-pulse-section 0.85s $easing-standard; } &__head { display: flex; align-items: center; - gap: 8px; + gap: 10px; min-height: 22px; } @@ -255,7 +761,7 @@ &__kpi { flex: 1; font-size: 11px; - color: var(--color-text-disabled); + color: var(--color-text-muted); letter-spacing: 0.2px; } @@ -267,7 +773,7 @@ } } - // ── Icon button ─────────────────────────────────────────────────────── + // ── Icon button ──────────────────────────────────────────── &-icon-btn { display: inline-flex; align-items: center; @@ -275,20 +781,20 @@ width: 24px; height: 24px; border: none; - border-radius: 5px; + border-radius: 6px; background: transparent; - color: var(--color-text-disabled); + color: var(--color-text-muted); cursor: pointer; transition: color $motion-fast $easing-standard, background $motion-fast $easing-standard; &:hover { color: var(--color-text-secondary); - background: var(--element-bg-soft); + background: var(--element-bg-strong); } } - // ── Link button ─────────────────────────────────────────────────────── + // ── Link button ──────────────────────────────────────────── &-link { display: inline-flex; align-items: center; @@ -297,21 +803,56 @@ border: none; background: transparent; font-size: 11px; - color: var(--color-text-disabled); + color: var(--color-text-muted); cursor: pointer; flex-shrink: 0; transition: color $motion-fast $easing-standard; - &:hover { color: var(--color-accent-500); } + &:hover { color: var(--color-accent-400); } } - // ── Model grid (5 slots, 3-col) ─────────────────────────────────────── + // ── Model grid (two-column with divider) ────────────────── &-model-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px 12px; + display: flex; + align-items: stretch; + gap: 0; min-width: 0; - overflow: visible; + + &__col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 20px; + + &:first-child { + padding-left: 0; + } + + &:last-child { + padding-right: 0; + } + + &--primary { + flex: 0 0 auto; + width: 220px; + } + + &--secondary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; + align-content: start; + } + } + + &__divider { + flex-shrink: 0; + width: 1px; + background: var(--border-subtle); + align-self: stretch; + } } &-model-cell { @@ -372,7 +913,7 @@ box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--color-accent-500) 35%, transparent) inset; } - &.is-empty &__name { color: var(--color-text-disabled); font-style: normal; } + &.is-empty &__name { color: var(--color-text-disabled); } &__name { font-size: 12px; @@ -446,7 +987,7 @@ } } - // ── Chip row ────────────────────────────────────────────────────────── + // ── Chip row ─────────────────────────────────────────────── &-chip-row { display: flex; flex-wrap: wrap; @@ -454,8 +995,235 @@ align-items: center; } - // ── Toggle chip — flat, background-only state ───────────────────────── - // Uses CSS variable --chip-accent (set inline) for per-section tinting. + &-rules-group { + display: flex; + align-items: flex-start; + gap: 10px; + + & + & { + margin-top: 2px; + } + + &__label { + width: 48px; + flex-shrink: 0; + padding-top: 4px; + font-size: 11px; + color: var(--color-text-disabled); + letter-spacing: 0.2px; + } + } + + &-memory-chip { + display: inline-flex; + align-items: center; + gap: 6px; + } + + &-imp-dots { + display: inline-flex; + align-items: center; + gap: 3px; + } + + &-imp-dot { + width: 5px; + height: 5px; + border-radius: 50%; + transition: background $motion-fast $easing-standard, + opacity $motion-fast $easing-standard; + + &.is-on { + background: #c9944d; + opacity: 0.95; + } + + &.is-off { + background: var(--border-medium); + opacity: 0.7; + } + } + + // ── Tool section ─────────────────────────────────────────── + &-tool-search { + max-width: 240px; + + .search__wrapper { + background: var(--element-bg-subtle); + border-color: var(--border-subtle); + } + } + + &-tool-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 10px; + } + + &-tool-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + transition: background $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-medium); + } + + &__meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + &__name { + font-size: 12px; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + line-height: 1.2; + } + + &__desc { + font-size: 11px; + color: var(--color-text-disabled); + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__switch { + flex-shrink: 0; + } + } + + // ── Skill mini cards ─────────────────────────────────────── + &-skill-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 8px; + } + + &-skill-mini { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid transparent; + cursor: pointer; + transition: border-color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + &:hover { + border-color: var(--border-subtle); + background: var(--element-bg-medium); + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-accent-500) 55%, transparent); + outline-offset: 2px; + } + + &__icon { + width: 28px; + height: 28px; + flex-shrink: 0; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-primary); + background: var(--skill-mini-gradient); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); + } + + &__body { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + } + + &__name { + font-size: 12px; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + line-height: 1.2; + } + + &__desc { + font-size: 11px; + color: var(--color-text-disabled); + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__switch { + flex-shrink: 0; + margin-left: auto; + } + } + + // ── Preferences ──────────────────────────────────────────── + &-pref-list { + display: flex; + flex-direction: column; + gap: 0; + border-radius: 8px; + overflow: hidden; + } + + &-pref-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 12px; + transition: background $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-medium); + } + + &:not(:last-child) { + border-bottom: 1px solid var(--border-subtle); + } + + &__info { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + } + + &__title { + font-size: 13px; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + line-height: 1.3; + } + + &__desc { + font-size: 11px; + color: var(--color-text-disabled); + line-height: 1.5; + } + + &__switch { + flex-shrink: 0; + } + } + + // ── Toggle chip ──────────────────────────────────────────── &-chip { display: inline-flex; align-items: center; @@ -475,16 +1243,10 @@ &:active { transform: scale(0.93); } &.is-loading { opacity: 0.45; cursor: wait; pointer-events: none; } - // ─ enabled: accent background via RGBA to avoid color-mix failures on dark surfaces ─ &.is-on { - // accent color is injected via inline CSS variable; decomposed into r/g/b for RGBA - // fallback: use --chip-accent directly as background with reduced opacity - background-color: var(--chip-accent, #60a5fa); - opacity: 1; - - // pseudo-element carries the semi-transparent fill so opacity doesn't bleed into child text position: relative; isolation: isolate; + background-color: transparent; &::before { content: ''; @@ -498,34 +1260,27 @@ transition: opacity $motion-fast $easing-standard; } - background-color: transparent; // disable direct background; pseudo-element carries the color - &:hover::before { opacity: 0.26; } .bp-chip__label { color: var(--chip-accent, #60a5fa); font-weight: $font-weight-medium; } - } - // ─ disabled: unified across all blocks, using theme-aware CSS variables ─ &.is-off { background: transparent; &:hover { border-color: var(--border-medium); - background: transparent; } .bp-chip__label { color: var(--color-text-disabled); font-weight: $font-weight-normal; } - } - // "more / collapse" variant &--more { background: transparent; color: var(--color-text-disabled); @@ -547,23 +1302,22 @@ text-overflow: ellipsis; transition: color $motion-fast $easing-standard; } - } - // ── Empty hint ──────────────────────────────────────────────────────── + // ── Empty hint ───────────────────────────────────────────── &-empty-hint { font-size: 12px; color: var(--color-text-disabled); font-style: italic; } - // ── MCP row ─────────────────────────────────────────────────────────── + // ── MCP row ──────────────────────────────────────────────── &-mcp-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; - padding-top: 6px; + padding-top: 4px; &__label { font-size: 11px; @@ -605,7 +1359,7 @@ } } - // ── Template chips ──────────────────────────────────────────────────── + // ── Template chips ───────────────────────────────────────── &-tpl-chip { display: inline-flex; align-items: center; @@ -628,12 +1382,131 @@ color: color-mix(in srgb, var(--color-warning) 80%, var(--color-text-secondary)); } } +} + +// ── Responsive ─────────────────────────────────────────────── +@media (max-width: 860px) { + .bp { + &-home { + flex-direction: column; + align-items: center; + padding: 48px 20px 80px; + gap: 28px; + + &__left { + padding: 0; + border-right: none; + border-bottom: none; + align-self: auto; + + .bp-home__panda-img--default { + height: 200px; + } + } + + &__right { + padding: 0; + align-items: center; + text-align: center; + } + + &__name-row { justify-content: center; } + + &__desc-block { text-align: center; } + + &__hint { text-align: center; } + + &__action-row { justify-content: center; } + + &__cat-desc { display: none; } + } + + &-hero { + padding: 10px #{$size-gap-4}; + } + + &-zone-inner { + padding-left: #{$size-gap-4}; + padding-right: #{$size-gap-4}; + } + + &-tool-grid, + &-skill-grid { + grid-template-columns: 1fr; + } + + &-model-grid { + flex-direction: column; + + &__col { + padding: 0; + } + + &__col--primary { + width: 100%; + } + + &__col--secondary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + &__divider { + width: 100%; + height: 1px; + align-self: auto; + } + } + + &-rules-group { + flex-direction: column; + gap: 6px; + + &__label { + width: auto; + padding-top: 0; + } + } + } +} + +@media (max-width: 600px) { + .bp { + &-home { + padding: 32px 16px 64px; + + &__panda &-img { + height: 160px; + } + + &__name { font-size: 20px; } + + &__categories { gap: 8px; } + + &__cat { + height: 26px; + padding: 0 8px; + font-size: 12px; + } + } + + &-tab-rail { + width: 20px; + padding: 0 4px; + } + &-model-grid__col--secondary { + grid-template-columns: 1fr; + } + + &-tool-search { + max-width: none; + } + } } -// ════════════════════════════════════════════════════════════════════════════ +// ═══════════════════════════════════════════════════════════════ // Radar modal -// ════════════════════════════════════════════════════════════════════════════ +// ═══════════════════════════════════════════════════════════════ .bp-modal { position: fixed; inset: 0; diff --git a/src/web-ui/src/app/scenes/profile/views/PersonaView.tsx b/src/web-ui/src/app/scenes/profile/views/PersonaView.tsx index 275b5c74..5f329915 100644 --- a/src/web-ui/src/app/scenes/profile/views/PersonaView.tsx +++ b/src/web-ui/src/app/scenes/profile/views/PersonaView.tsx @@ -4,10 +4,11 @@ import React, { import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { - Bot, ChevronRight, Pencil, X, - ListChecks, RotateCcw, + ChevronRight, Pencil, X, + ListChecks, RotateCcw, Puzzle, + Brain, Zap, Sliders, } from 'lucide-react'; -import { Input, Select, type SelectOption } from '@/component-library'; +import { Input, Search, Select, Switch, type SelectOption } from '@/component-library'; import { AIRulesAPI, RuleLevel, type AIRule } from '@/infrastructure/api/service-api/AIRulesAPI'; import { getAllMemories, toggleMemory, type AIMemory } from '@/infrastructure/api/aiMemoryApi'; import { promptTemplateService } from '@/infrastructure/services/PromptTemplateService'; @@ -22,6 +23,7 @@ import type { import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; import type { ConfigTab } from '@/app/scenes/settings/settingsConfig'; import { quickActions } from '@/shared/services/ide-control'; +import { getCardGradient } from '@/shared/utils/cardGradients'; import { PersonaRadar } from './PersonaRadar'; import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; @@ -39,7 +41,26 @@ interface ToolInfo { name: string; description: string; is_readonly: boolean; } const C = 'bp'; const IDENTITY_KEY = 'bf_agent_identity'; const DEFAULT_NAME = 'BitFun Agent'; -const CHIP_LIMIT = 12; +const CHIP_LIMIT = 12; +const TOOL_LIST_LIMIT = 10; +const SKILL_GRID_LIMIT = 4; + +// ── Zone switching drag mechanics ───────────────────────────── +const ZONE_ORDER = ['brain', 'capabilities', 'interaction'] as const; +type ZoneId = typeof ZONE_ORDER[number]; +const DRAG_THRESHOLD = 200; // accumulated deltaY to trigger switch +const MAX_DISPLACE = 22; // max visual translateY in px + +/** Elastic displacement: fast start, asymptotically approaches MAX_DISPLACE */ +function elasticDisplace(accum: number): number { + const t = Math.min(Math.abs(accum) / DRAG_THRESHOLD, 1); + return MAX_DISPLACE * (1 - Math.exp(-t * 3.4)) * Math.sign(accum); +} +/** Ghost opacity: 0 before 50% threshold, ramps to 0.36 at 100% */ +function ghostOpacity(accum: number): number { + const t = Math.min(Math.abs(accum) / DRAG_THRESHOLD, 1); + return t < 0.5 ? 0 : ((t - 0.5) / 0.5) * 0.36; +} // Structural slot keys only — labels/descs are resolved via i18n at render time const MODEL_SLOT_KEYS = ['primary', 'fast', 'compression', 'image', 'voice', 'retrieval'] as const; @@ -55,6 +76,12 @@ const SLOT_PRESET_IDS: Record = { retrieval: [], }; +function getImportanceDotCount(importance: number): number { + if (importance >= 8) return 3; + if (importance >= 4) return 2; + return 1; +} + interface ToggleChipProps { label: string; enabled: boolean; @@ -78,6 +105,110 @@ const ToggleChip: React.FC = ({ ); +interface ToolToggleRowProps { + name: string; + description?: string; + enabled: boolean; + loading?: boolean; + onToggle: () => void; +} +const ToolToggleRow: React.FC = ({ + name, description, enabled, loading, onToggle, +}) => ( +
+
+ {name} + + {description || name} + +
+ onToggle()} + aria-label={name} + className={`${C}-tool-row__switch`} + /> +
+); + +interface SkillMiniCardProps { + name: string; + description?: string; + enabled: boolean; + loading?: boolean; + onToggle: () => void; + onOpen: () => void; +} +const SkillMiniCard: React.FC = ({ + name, description, enabled, loading, onToggle, onOpen, +}) => ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpen(); + } + }} + aria-label={name} + > +
+ +
+
+ {name} + + {description || name} + +
+
e.stopPropagation()} + onKeyDown={e => e.stopPropagation()} + > + onToggle()} + aria-label={name} + /> +
+
+); + +interface PrefToggleRowProps { + label: string; + description: string; + enabled: boolean; + onToggle: () => void; +} +const PrefToggleRow: React.FC = ({ + label, description, enabled, onToggle, +}) => ( +
+
+ {label} + {description} +
+ onToggle()} + aria-label={label} + className={`${C}-pref-row__switch`} + /> +
+); + interface ModelPillProps { slotKey: ModelSlotKey; slotLabel: string; @@ -204,18 +335,39 @@ const PersonaView: React.FC<{ workspacePath: string }> = () => { const [rulesExpanded, setRulesExpanded] = useState(false); const [memoriesExpanded, setMemoriesExpanded] = useState(false); const [skillsExpanded, setSkillsExpanded] = useState(false); + const [toolsExpanded, setToolsExpanded] = useState(false); + const [toolQuery, setToolQuery] = useState(''); + + const [activeZone, setActiveZone] = useState<'brain' | 'capabilities' | 'interaction'>('brain'); + const [railExpanded, setRailExpanded] = useState(false); const [radarOpen, setRadarOpen] = useState(false); const [radarClosing, setRadarClosing] = useState(false); const closingTimer = useRef | null>(null); - // section refs for radar-click navigation + // home ↔ detail view transition + const [detailMode, setDetailMode] = useState(false); + + // section refs for radar-click scroll navigation const rulesRef = useRef(null); const memoryRef = useRef(null); const toolsRef = useRef(null); const skillsRef = useRef(null); const templatesRef = useRef(null); - const interactionRef = useRef(null); + const prefsRef = useRef(null); + + // detail section ref (kept for internal scroll-to section) + const detailRef = useRef(null); + + // panel refs for wheel drag mechanics + const brainPanelRef = useRef(null); + const capabilitiesPanelRef = useRef(null); + const interactionPanelRef = useRef(null); + const dragAccumRef = useRef(0); + const dragTimerRef = useRef | null>(null); + const isSwitchingRef = useRef(false); + // tab-rail dot refs for drag animation + const tabDotsRef = useRef<(HTMLSpanElement | null)[]>([]); useEffect(() => { (async () => { @@ -297,23 +449,43 @@ const PersonaView: React.FC<{ workspacePath: string }> = () => { }, [radarOpen, closeRadar]); useEffect(() => () => { if (closingTimer.current) clearTimeout(closingTimer.current); }, []); + const ZONE_TABS = useMemo(() => [ + { id: 'brain' as const, Icon: Brain, label: t('sections.brain'), shortLabel: t('nav.brain', { defaultValue: '大脑' }) }, + { id: 'capabilities' as const, Icon: Zap, label: t('sections.capabilities'), shortLabel: t('nav.capabilities', { defaultValue: '能力' }) }, + { id: 'interaction' as const, Icon: Sliders, label: t('sections.interaction'), shortLabel: t('nav.interaction', { defaultValue: '交互' }) }, + ], [t]); + + const dimToZone = useMemo>(() => ({ + [t('radar.dims.rigor')]: 'brain', + [t('radar.dims.memory')]: 'brain', + [t('radar.dims.autonomy')]: 'capabilities', + [t('radar.dims.adaptability')]: 'capabilities', + [t('radar.dims.creativity')]: 'interaction', + [t('radar.dims.expression')]: 'interaction', + }), [t]); + const handleRadarDimClick = useCallback((label: string) => { - const map: Record> = { - [t('radar.dims.rigor')]: rulesRef, - [t('radar.dims.memory')]: memoryRef, - [t('radar.dims.autonomy')]: toolsRef, + const zone = dimToZone[label]; + const refMap: Record> = { + [t('radar.dims.rigor')]: rulesRef, + [t('radar.dims.memory')]: memoryRef, + [t('radar.dims.autonomy')]: toolsRef, [t('radar.dims.adaptability')]: skillsRef, - [t('radar.dims.creativity')]: templatesRef, - [t('radar.dims.expression')]: interactionRef, + [t('radar.dims.creativity')]: templatesRef, + [t('radar.dims.expression')]: prefsRef, }; - const target = map[label]; - if (target?.current) { - target.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); - target.current.classList.add('is-pulse'); - setTimeout(() => target.current?.classList.remove('is-pulse'), 900); - } + if (zone) setActiveZone(zone); + // delay to let panel become visible before scrollIntoView + setTimeout(() => { + const target = refMap[label]; + if (target?.current) { + target.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + target.current.classList.add('is-pulse'); + setTimeout(() => target.current?.classList.remove('is-pulse'), 900); + } + }, 60); if (radarOpen) closeRadar(); - }, [radarOpen, closeRadar, t]); + }, [dimToZone, radarOpen, closeRadar, t]); const handleModelChange = useCallback(async (key: string, id: string) => { try { @@ -410,6 +582,201 @@ const PersonaView: React.FC<{ workspacePath: string }> = () => { catch { setAiExp(p => ({ ...p, [key]: cur })); } }, [aiExp]); + const openSkillsScene = useCallback(() => { + window.dispatchEvent(new CustomEvent('scene:open', { detail: { sceneId: 'skills' } })); + }, []); + + const goToDetail = useCallback((zone?: ZoneId) => { + if (zone) setActiveZone(zone); + setDetailMode(true); + }, []); + + const goToHome = useCallback(() => { + setDetailMode(false); + }, []); + + const scrollToZone = useCallback((zone: ZoneId) => { + goToDetail(zone); + }, [goToDetail]); + + // Helper: get panel DOM element by zone id + const getPanel = useCallback((id: ZoneId) => { + if (id === 'brain') return brainPanelRef.current; + if (id === 'capabilities') return capabilitiesPanelRef.current; + return interactionPanelRef.current; + }, []); + + // Tab click — simple crossfade + const handleTabClick = useCallback((id: ZoneId) => { + if (id === activeZone || isSwitchingRef.current) return; + isSwitchingRef.current = true; + const cur = getPanel(activeZone); + if (cur) { + cur.style.transition = 'opacity 0.14s ease'; + cur.style.opacity = '0'; + } + setTimeout(() => { + if (cur) { cur.style.transition = ''; cur.style.opacity = ''; } + setActiveZone(id); + isSwitchingRef.current = false; + }, 140); + }, [activeZone, getPanel]); + + // Wheel — elastic resistance + ghost preview + slide switch + dot merge animation + const handleWheel = useCallback((e: React.WheelEvent) => { + if (isSwitchingRef.current) return; + + const curPanel = getPanel(activeZone); + if (!curPanel) return; + + const goDown = e.deltaY > 0; + const atBottom = curPanel.scrollTop + curPanel.clientHeight >= curPanel.scrollHeight - 2; + const atTop = curPanel.scrollTop <= 0; + if (goDown && !atBottom) return; + if (!goDown && !atTop) return; + + const dir = goDown ? 1 : -1; + const idx = ZONE_ORDER.indexOf(activeZone); + const nextId = ZONE_ORDER[idx + dir] as ZoneId | undefined; + if (!nextId) return; + + if (dragAccumRef.current !== 0 && Math.sign(e.deltaY) !== Math.sign(dragAccumRef.current)) { + dragAccumRef.current = e.deltaY; + } else { + dragAccumRef.current += e.deltaY; + } + const accum = dragAccumRef.current; + const progress = Math.min(Math.abs(accum) / DRAG_THRESHOLD, 1); + + // ── panel visual feedback ────────────────────────── + const displace = elasticDisplace(accum); + const gOpacity = ghostOpacity(accum); + const nextPanel = getPanel(nextId); + + curPanel.style.transform = `translateY(${displace}px)`; + curPanel.style.opacity = String(1 - gOpacity * 0.25); + + if (nextPanel) { + if (gOpacity > 0) { + const t = Math.min(Math.abs(accum) / DRAG_THRESHOLD, 1); + const ghostOffset = dir * 28 * (1 - (t - 0.5) / 0.5); + nextPanel.style.display = 'flex'; + nextPanel.style.flexDirection = 'column'; + nextPanel.style.position = 'absolute'; + nextPanel.style.inset = '0'; + nextPanel.style.overflowY = 'hidden'; + nextPanel.style.pointerEvents = 'none'; + nextPanel.style.zIndex = '0'; + nextPanel.style.transform = `translateY(${ghostOffset}px)`; + nextPanel.style.opacity = String(gOpacity); + } else { + nextPanel.style.display = ''; + nextPanel.style.position = ''; + nextPanel.style.transform = ''; + nextPanel.style.opacity = ''; + } + } + + // ── dot merge animation ──────────────────────────── + const curDot = tabDotsRef.current[idx]; + const nextDot = tabDotsRef.current[idx + dir]; + + if (curDot) { + // active dot stretches into a pill toward the next dot + const stretchH = 8 + progress * 18; // 8px → 26px + const pillMove = dir * (stretchH - 8) / 2; // keep one edge anchored + curDot.style.transition = 'none'; + curDot.style.height = `${stretchH}px`; + curDot.style.borderRadius = progress > 0.08 ? '3px' : '50%'; + curDot.style.transform = `translateY(${pillMove}px)`; + } + if (nextDot) { + // next dot grows and brightens with accent color + const nextScale = 1 + progress * 0.65; + nextDot.style.transition = 'none'; + nextDot.style.transform = `scale(${nextScale})`; + nextDot.style.background = 'var(--color-accent-500)'; + nextDot.style.opacity = String(0.25 + progress * 0.75); + } + + // ── threshold → execute switch ───────────────────── + if (Math.abs(accum) >= DRAG_THRESHOLD) { + if (dragTimerRef.current) clearTimeout(dragTimerRef.current); + isSwitchingRef.current = true; + dragAccumRef.current = 0; + + curPanel.style.transition = 'transform 0.22s cubic-bezier(0.4,0,1,0.6), opacity 0.22s ease'; + curPanel.style.transform = `translateY(${dir * -44}px)`; + curPanel.style.opacity = '0'; + + if (nextPanel) { + nextPanel.style.transition = ''; + nextPanel.style.position = 'absolute'; + nextPanel.style.inset = '0'; + nextPanel.style.display = 'flex'; + nextPanel.style.flexDirection = 'column'; + nextPanel.style.overflowY = 'auto'; + nextPanel.style.zIndex = '1'; + nextPanel.style.pointerEvents = 'none'; + nextPanel.style.transform = `translateY(${dir * 34}px)`; + nextPanel.style.opacity = '0.28'; + requestAnimationFrame(() => requestAnimationFrame(() => { + if (nextPanel) { + nextPanel.style.transition = 'transform 0.28s cubic-bezier(0.2,0,0.2,1), opacity 0.28s ease'; + nextPanel.style.transform = ''; + nextPanel.style.opacity = ''; + } + })); + } + + // commit state — clear all inline styles so CSS class takes over + setTimeout(() => { + curPanel.style.cssText = ''; + if (nextPanel) nextPanel.style.cssText = ''; + if (curDot) curDot.style.cssText = ''; + if (nextDot) nextDot.style.cssText = ''; + setActiveZone(nextId); + isSwitchingRef.current = false; + }, 295); + return; + } + + // ── spring-back timer ────────────────────────────── + if (dragTimerRef.current) clearTimeout(dragTimerRef.current); + dragTimerRef.current = setTimeout(() => { + dragAccumRef.current = 0; + dragTimerRef.current = null; + + // panel spring back with overshoot + curPanel.style.transition = 'transform 0.36s cubic-bezier(0.34,1.56,0.64,1), opacity 0.28s ease'; + curPanel.style.transform = ''; + curPanel.style.opacity = ''; + setTimeout(() => { curPanel.style.transition = ''; }, 360); + + if (nextPanel && parseFloat(nextPanel.style.opacity || '0') > 0) { + nextPanel.style.transition = 'opacity 0.2s ease, transform 0.2s ease'; + nextPanel.style.opacity = '0'; + setTimeout(() => { if (nextPanel) nextPanel.style.cssText = ''; }, 200); + } + + // dot spring back — current dot un-stretches with overshoot + if (curDot) { + curDot.style.transition = 'height 0.36s cubic-bezier(0.34,1.56,0.64,1), transform 0.36s cubic-bezier(0.34,1.56,0.64,1), border-radius 0.2s ease'; + curDot.style.height = ''; + curDot.style.borderRadius = ''; + curDot.style.transform = ''; + setTimeout(() => { if (curDot) curDot.style.transition = ''; }, 360); + } + if (nextDot) { + nextDot.style.transition = 'transform 0.22s ease, opacity 0.22s ease, background 0.22s ease'; + nextDot.style.transform = ''; + nextDot.style.opacity = ''; + nextDot.style.background = ''; + setTimeout(() => { if (nextDot) nextDot.style.transition = ''; }, 220); + } + }, 160); + }, [activeZone, getPanel]); + const sortRules = useMemo(() => [...rules].sort((a, b) => a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1), [rules]); const sortMem = useMemo(() => @@ -428,6 +795,30 @@ const PersonaView: React.FC<{ workspacePath: string }> = () => { const sortTemplates = useMemo(() => [...templates].sort((a, b) => a.isFavorite !== b.isFavorite ? (a.isFavorite ? -1 : 1) : b.usageCount - a.usageCount), [templates]); + const userRulesList = useMemo( + () => sortRules.filter(rule => rule.level === RuleLevel.User), + [sortRules], + ); + const projectRulesList = useMemo( + () => sortRules.filter(rule => rule.level === RuleLevel.Project), + [sortRules], + ); + const filteredTools = useMemo(() => { + const query = toolQuery.trim().toLowerCase(); + if (!query) return sortTools; + return sortTools.filter(tool => + tool.name.toLowerCase().includes(query) + || tool.description.toLowerCase().includes(query), + ); + }, [sortTools, toolQuery]); + const visibleTools = useMemo( + () => (toolsExpanded ? filteredTools : filteredTools.slice(0, TOOL_LIST_LIMIT)), + [filteredTools, toolsExpanded], + ); + const visibleSkills = useMemo( + () => (skillsExpanded ? sortSkills : sortSkills.slice(0, SKILL_GRID_LIMIT)), + [sortSkills, skillsExpanded], + ); const enabledRules = useMemo(() => rules.filter(r => r.enabled).length, [rules]); const userRules = useMemo(() => rules.filter(r => r.level === RuleLevel.User).length, [rules]); @@ -495,276 +886,461 @@ const PersonaView: React.FC<{ workspacePath: string }> = () => { }, ], [t]); + const HOME_ZONES = useMemo(() => [ + { id: 'brain' as ZoneId, Icon: Brain, label: t('sections.brain'), desc: t('home.brainDesc', { defaultValue: '模型 · 规则 · 记忆' }) }, + { id: 'capabilities' as ZoneId, Icon: Zap, label: t('sections.capabilities'), desc: t('home.capabilitiesDesc', { defaultValue: '工具 · 技能 · MCP' }) }, + { id: 'interaction' as ZoneId, Icon: Sliders, label: t('sections.interaction'), desc: t('home.interactionDesc', { defaultValue: '模板 · 偏好' }) }, + ], [t]); + return (
-
-
-
- + {/* ══════════ Home / 首页 ══════════════════════════════ */} +
+ + {/* Left — Full-body panda */} +
+
+ {t('hero.avatarAlt', +
+
-
-
- {editingField === 'name' ? ( - setEditValue(e.target.value)} - onBlur={commitEdit} - onKeyDown={onEditKey} - inputSize="small" - /> - ) : ( -

startEdit('name')} - title={t('hero.editNameTitle')} - > - {identity.name} - -

- )} - Super Agent -
+ {/* Right — Identity + body row + CTA */} +
- {editingField === 'desc' ? ( + {/* Name row */} +
+ {editingField === 'name' ? ( setEditValue(e.target.value)} onBlur={commitEdit} onKeyDown={onEditKey} - placeholder={t('hero.descPlaceholder')} inputSize="small" /> ) : ( -

startEdit('desc')} - title={t('hero.editDescTitle')} +

startEdit('name')} + title={t('hero.editNameTitle')} > - {identity.desc || t('defaultDesc')} -

+ {identity.name} + +

)} +
+ + WIP · 建设中 +
-
-
- -
-
- -
-

{t('sections.brain')}

- -
-
- {t('cards.model')} - -
-
- {MODEL_SLOT_KEYS.map(key => ( - handleModelChange(key, id)} - /> - ))} + {/* Description + Radar side by side */} +
+
!editingField && startEdit('desc')}> + {editingField === 'desc' ? ( + setEditValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={onEditKey} + placeholder={t('hero.descPlaceholder')} + inputSize="small" + /> + ) : ( +

+ {identity.desc || t('defaultDesc')} +

+ )} + {!editingField && ( +

+ {t('home.descHint', { defaultValue: '点击编辑,描述你的大熊猫 Agent 风格与偏好' })} +

+ )} +
-
-
-
- {t('cards.rules')} - - {t('kpi.rules', { user: userRules, project: projRules, enabled: enabledRules })} - - -
-
- {(rules.length > CHIP_LIMIT && !rulesExpanded ? sortRules.slice(0, CHIP_LIMIT) : sortRules).map(r => ( - toggleRule(r)} - accentColor="#60a5fa" - loading={rulesLoading[`${r.level}-${r.name}`]} - /> - ))} - {sortRules.length === 0 && {t('empty.rules')}} - {rules.length > CHIP_LIMIT && ( - - )} + ))}
+
-
-
- {t('cards.memory')} - {t('kpi.memory', { count: enabledMems })} - + {/* ══════════ Detail / 章节 ═════════════════════════════ */} +
+ + {/* ── Persistent header ────────────────────────────── */} +
+
+
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goToHome(); } }} + > + {t('hero.avatarAlt', +
-
- {(memories.length > CHIP_LIMIT && !memoriesExpanded ? sortMem.slice(0, CHIP_LIMIT) : sortMem).map(m => ( - toggleMem(m)} - accentColor="#c9944d" - loading={memoriesLoading[m.id]} - tooltip={m.title} - /> - ))} - {sortMem.length === 0 && {t('empty.memory')}} - {memories.length > CHIP_LIMIT && ( - - )} +
+
+

startEdit('name')} title={t('hero.editNameTitle')}> + {identity.name} + +

+
+

startEdit('desc')} title={t('hero.editDescTitle')}> + {identity.desc || t('defaultDesc')} +

- +
+ +
+
-
-

{t('sections.capabilities')}

+ {/* ── Content: zone viewport + tab rail ───────────── */} +
+
-
-
- {t('cards.toolsMcp')} - {toolKpi} -
- - - + {/* Brain */} +
+
+
+
+ {t('cards.model')} + +
+
+
+ {(['primary', 'fast'] as ModelSlotKey[]).map(key => ( + handleModelChange(key, id)} + /> + ))} +
+
+
+ {(['compression', 'image', 'voice', 'retrieval'] as ModelSlotKey[]).map(key => ( + handleModelChange(key, id)} + /> + ))} +
+
-
-
- {sortTools.map(tool => ( - toggleTool(tool.name)} - accentColor="#6eb88c" - loading={toolsLoading[tool.name]} - tooltip={tool.description || tool.name} - /> - ))} - {availableTools.length === 0 && {t('empty.tools')}} -
+
+
+ {t('cards.rules')} + + {t('kpi.rules', { user: userRules, project: projRules, enabled: enabledRules })} + + +
+ {sortRules.length === 0 && {t('empty.rules')}} + {sortRules.length > 0 && ( + <> + {[ + { label: 'User', items: userRulesList }, + { label: 'Project', items: projectRulesList }, + ].map(group => { + const groupItems = rulesExpanded ? group.items : group.items.slice(0, CHIP_LIMIT); + if (group.items.length === 0) return null; + return ( +
+ {group.label} +
+ {groupItems.map(rule => ( + toggleRule(rule)} + accentColor="#60a5fa" + loading={rulesLoading[`${rule.level}-${rule.name}`]} + /> + ))} +
+
+ ); + })} + {rules.length > CHIP_LIMIT && ( + + )} + + )} +
+
+
+ {t('cards.memory')} + {t('kpi.memory', { count: enabledMems })} + +
+
+ {(memories.length > CHIP_LIMIT && !memoriesExpanded ? sortMem.slice(0, CHIP_LIMIT) : sortMem).map(m => ( +
+ toggleMem(m)} + accentColor="#c9944d" + loading={memoriesLoading[m.id]} + tooltip={m.title} + /> +
+ ))} + {sortMem.length === 0 && {t('empty.memory')}} + {memories.length > CHIP_LIMIT && ( + + )} +
+
+
- {mcpServers.length > 0 && ( -
- MCP - {mcpServers.map(srv => { - const ok = srv.status === 'Healthy' || srv.status === 'Connected'; - return ( - - - {srv.name} + {/* Capabilities */} +
+
+
+
+ {t('cards.toolsMcp')} + {toolKpi} +
+ + + +
+
+ {availableTools.length > 15 && ( + + )} +
+ {visibleTools.map(tool => ( + toggleTool(tool.name)} + /> + ))} +
+ {filteredTools.length === 0 && ( + + {toolQuery.trim() + ? t('profile.toolSearchEmpty', { defaultValue: '没有匹配的工具' }) + : t('empty.tools')} + + )} + {filteredTools.length > TOOL_LIST_LIMIT && ( + + )} + {mcpServers.length > 0 && ( +
+ MCP + {mcpServers.map(srv => { + const ok = srv.status === 'Healthy' || srv.status === 'Connected'; + return ( + + + {srv.name} + + ); + })} + +
+ )} +
+
+
+ {t('cards.skills')} + {t('kpi.skills', { count: enabledSkls })} + +
+
+ {visibleSkills.map(sk => ( + toggleSkill(sk)} + onOpen={openSkillsScene} + /> + ))} +
+ {sortSkills.length === 0 && {t('empty.skills')}} + {skills.length > SKILL_GRID_LIMIT && ( + + )} +
+
+ + {/* Interaction */} +
+
+
+
+ {t('cards.templates')} + {t('kpi.templateCount', { count: templates.length })} + +
+
+ {sortTemplates.slice(0, 14).map(tmpl => ( + + {tmpl.isFavorite && '★ '}{tmpl.name} - ); - })} - + ))} + {templates.length === 0 && {t('empty.templates')}} +
- )} -
+
+
+ {t('cards.preferences')} +
+
+ {prefItems.map(({ key, label, desc }) => ( + togglePref(key)} + /> + ))} +
+
+
-
-
- {t('cards.skills')} - {t('kpi.skills', { count: enabledSkls })} - -
-
- {(skills.length > CHIP_LIMIT && !skillsExpanded ? sortSkills.slice(0, CHIP_LIMIT) : sortSkills).map(sk => ( - toggleSkill(sk)} - accentColor="#8b5cf6" - loading={skillsLoading[sk.name]} - tooltip={sk.description} - /> - ))} - {sortSkills.length === 0 && {t('empty.skills')}} - {skills.length > CHIP_LIMIT && ( - - )} -
-
-
}> -

{t('sections.interaction')}

- -
-
- {t('cards.templates')} - {t('kpi.templateCount', { count: templates.length })} - -
-
- {sortTemplates.slice(0, 14).map(tmpl => ( - - {tmpl.isFavorite && '★ '}{tmpl.name} - + {/* ── Tab Rail ─────────────────────────────────── */} + {/* nav stays 28 px wide in layout; list floats as overlay */} +
-
-
- {t('cards.preferences')} -
-
- {prefItems.map(({ key, label, desc }) => ( - togglePref(key)} - tooltip={`${label}:${desc}`} - accentColor="#7096c4" - /> + {/* overlay list — absolutely positioned, no layout impact */} +
+ {ZONE_TABS.map(({ id, label }) => ( + ))}
-
-
+ +
+ +
{/* ── /detail ── */} {radarOpen && createPortal(
{ + // #region agent log + console.error('[DBG-366fda] ShellNav render v2'); + // #endregion + const { t } = useI18n('common'); + const { t: tTerminal } = useI18n('panels/terminal'); + const { workspaceName } = useCurrentWorkspace(); + const navView = useShellStore((s) => s.navView); + const setNavView = useShellStore((s) => s.setNavView); + const expandedWorktrees = useShellStore((s) => s.expandedWorktrees); + const toggleWorktree = useShellStore((s) => s.toggleWorktree); + const activeSceneId = useSceneStore((s) => s.activeTabId); + const activeTerminalSessionId = useTerminalSceneStore((s) => s.activeSessionId); + const showMenu = useContextMenuStore((s) => s.showMenu); + + const { + mainEntries, + hubMainEntries, + getWorktreeEntries, + editModalOpen, + editingTerminal, + closeEditModal, + refresh: refreshEntries, + createAdHocTerminal, + createHubTerminal, + promoteToHub, + openTerminal, + startTerminal, + stopTerminal, + deleteTerminal, + openEditModal, + saveEdit, + closeWorktreeTerminals, + removeWorktreeConfig, + } = useShellEntries(); + + const { + workspacePath, + isGitRepo, + currentBranch, + worktrees, + nonMainWorktrees, + refresh: refreshWorktrees, + addWorktree, + removeWorktree, + } = useWorktrees(); + + const [menuOpen, setMenuOpen] = useState(false); + const [branchModalOpen, setBranchModalOpen] = useState(false); + const menuRef = useRef(null); + + const visibleMainEntries = navView === 'hub' ? hubMainEntries : mainEntries; + const visibleWorktrees = useMemo( + () => + navView === 'hub' + ? nonMainWorktrees + : nonMainWorktrees.filter((worktree) => getWorktreeEntries(worktree.path).length > 0), + [getWorktreeEntries, navView, nonMainWorktrees], + ); + + const hasVisibleContent = visibleMainEntries.length > 0 || visibleWorktrees.length > 0; + + useEffect(() => { + if (!menuOpen) { + return; + } + + const handleMouseDown = (event: MouseEvent) => { + const target = event.target as Node | null; + if (target && menuRef.current?.contains(target)) { + return; + } + setMenuOpen(false); + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('keydown', handleEscape); + }; + }, [menuOpen]); + + const handleRefresh = useCallback(async () => { + await Promise.all([refreshEntries(), refreshWorktrees()]); + }, [refreshEntries, refreshWorktrees]); + + const handleCreateAdHocTerminal = useCallback(async () => { + setMenuOpen(false); + await createAdHocTerminal(); + }, [createAdHocTerminal]); + + const handleCreateHubTerminal = useCallback(async () => { + setMenuOpen(false); + await createHubTerminal(); + }, [createHubTerminal]); + + const handleOpenBranchModal = useCallback(() => { + setMenuOpen(false); + setBranchModalOpen(true); + }, []); + + const handleBranchSelect = useCallback(async (result: BranchSelectResult) => { + await addWorktree(result.branch, result.isNew); + setNavView('hub'); + }, [addWorktree, setNavView]); + + const handleRemoveWorktree = useCallback(async (worktree: GitWorktreeInfo) => { + const confirmed = window.confirm(tTerminal('dialog.deleteWorktree.message')); + if (!confirmed) { + return; + } + + await closeWorktreeTerminals(worktree.path); + const removed = await removeWorktree(worktree.path); + if (removed) { + removeWorktreeConfig(worktree.path); + } + }, [closeWorktreeTerminals, removeWorktree, removeWorktreeConfig, tTerminal]); + + const openContextMenu = useCallback(( + event: React.MouseEvent, + items: MenuItem[], + data: Record, + ) => { + event.preventDefault(); + event.stopPropagation(); + + showMenu( + { x: event.clientX, y: event.clientY }, + items, + { + type: ContextType.CUSTOM, + customType: 'shell-nav', + data, + event, + targetElement: event.currentTarget, + position: { x: event.clientX, y: event.clientY }, + timestamp: Date.now(), + }, + ); + }, [showMenu]); + + const getEntryMenuItems = useCallback((entry: ShellEntry): MenuItem[] => { + if (entry.isHub) { + return [ + !entry.isRunning + ? { + id: `start-${entry.sessionId}`, + label: t('nav.shell.context.start'), + icon: , + onClick: async () => { + // #region agent log + console.error('[DBG-366fda][H-A] Start menu clicked', {sessionId: entry.sessionId, isHub: entry.isHub, isRunning: entry.isRunning}); + // #endregion + await openTerminal(entry); + }, + } + : { + id: `stop-${entry.sessionId}`, + label: t('nav.shell.context.stop'), + icon: , + onClick: async () => { + await stopTerminal(entry.sessionId); + }, + }, + { + id: `edit-${entry.sessionId}`, + label: t('nav.shell.context.editConfig'), + icon: , + onClick: () => { + openEditModal(entry); + }, + }, + { + id: `delete-${entry.sessionId}`, + label: t('nav.shell.context.delete'), + icon: , + onClick: async () => { + await deleteTerminal(entry); + }, + }, + ]; + } + + return [ + { + id: `rename-${entry.sessionId}`, + label: t('nav.shell.context.rename'), + icon: , + onClick: () => { + openEditModal(entry); + }, + }, + { + id: `promote-${entry.sessionId}`, + label: t('nav.shell.context.promoteToHub'), + icon: , + onClick: () => { + promoteToHub(entry); + }, + }, + { + id: `close-${entry.sessionId}`, + label: t('nav.shell.context.close'), + icon: , + onClick: async () => { + await deleteTerminal(entry); + }, + }, + ]; + }, [deleteTerminal, openEditModal, openTerminal, promoteToHub, stopTerminal, t]); + + const getWorktreeMenuItems = useCallback((worktree: GitWorktreeInfo): MenuItem[] => [ + { + id: `create-${worktree.path}`, + label: t('nav.shell.context.newWorktreeTerminal'), + icon: , + onClick: async () => { + await createHubTerminal(worktree.path); + }, + }, + { + id: `remove-${worktree.path}`, + label: t('nav.shell.context.removeWorktree'), + icon: , + onClick: async () => { + await handleRemoveWorktree(worktree); + }, + }, + ], [createHubTerminal, handleRemoveWorktree, t]); + + const renderTerminalEntry = useCallback((entry: ShellEntry) => { + const isActive = activeSceneId === 'shell' && activeTerminalSessionId === entry.sessionId; + + return ( +
{ void openTerminal(entry); }} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { void openTerminal(entry); } }} + onContextMenu={(event) => openContextMenu(event, getEntryMenuItems(entry), { entry })} + title={entry.name} + > + + {entry.name} + {navView === 'hub' && entry.startupCommand ? ( + {t('nav.shell.badges.startupCommand')} + ) : null} + + +
+ ); + }, [activeSceneId, activeTerminalSessionId, deleteTerminal, getEntryMenuItems, navView, openContextMenu, openTerminal, t]); + + return ( +
+
+
+ {t('nav.shell.title')} + {workspaceName ? ( + + {workspaceName} + + ) : null} +
+
+ + + {menuOpen ? ( +
+ + + {isGitRepo ? ( + + ) : null} + +
+ ) : null} +
+
+ +
+ + +
+ +
+ {hasVisibleContent ? ( + <> + {visibleMainEntries.length > 0 ? ( +
+ {visibleMainEntries.map((entry) => renderTerminalEntry(entry))} +
+ ) : null} + + {visibleWorktrees.map((worktree) => { + const entries = getWorktreeEntries(worktree.path); + const expanded = expandedWorktrees.has(worktree.path); + const branchLabel = worktree.branch || worktree.path.split(/[/\\]/).pop() || worktree.path; + + return ( +
+
toggleWorktree(worktree.path)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { toggleWorktree(worktree.path); } }} + onContextMenu={(event) => openContextMenu(event, getWorktreeMenuItems(worktree), { worktree })} + > + + + {branchLabel} + {entries.length} + +
+ + {expanded ? ( +
+ {entries.length > 0 ? ( + entries.map((entry) => renderTerminalEntry(entry)) + ) : navView === 'hub' ? ( +
+ {t('nav.shell.empty.hub')} +
+ ) : null} +
+ ) : null} +
+ ); + })} + + ) : ( +
+ {navView === 'hub' ? t('nav.shell.empty.hub') : t('nav.shell.empty.all')} +
+ )} +
+ + {workspacePath ? ( + setBranchModalOpen(false)} + onSelect={(result) => { void handleBranchSelect(result); }} + repositoryPath={workspacePath} + currentBranch={currentBranch} + existingWorktreeBranches={worktrees.map((worktree) => worktree.branch).filter(Boolean) as string[]} + title={t('nav.shell.actions.addWorktree')} + /> + ) : null} + + {editingTerminal ? ( + + ) : null} +
+ ); +}; + +export default ShellNav; diff --git a/src/web-ui/src/app/scenes/shell/ShellScene.scss b/src/web-ui/src/app/scenes/shell/ShellScene.scss new file mode 100644 index 00000000..58856703 --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/ShellScene.scss @@ -0,0 +1,13 @@ +@use '../../../component-library/styles/tokens.scss' as *; + +.bitfun-shell-scene { + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + + &__loading { + width: 100%; + height: 100%; + } +} diff --git a/src/web-ui/src/app/scenes/shell/ShellScene.tsx b/src/web-ui/src/app/scenes/shell/ShellScene.tsx new file mode 100644 index 00000000..9bd8b8f5 --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/ShellScene.tsx @@ -0,0 +1,14 @@ +import React, { Suspense, lazy } from 'react'; +import './ShellScene.scss'; + +const TerminalScene = lazy(() => import('../terminal/TerminalScene')); + +const ShellScene: React.FC = () => ( +
+ }> + + +
+); + +export default ShellScene; diff --git a/src/web-ui/src/app/scenes/shell/hooks/index.ts b/src/web-ui/src/app/scenes/shell/hooks/index.ts new file mode 100644 index 00000000..d59d86cd --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useShellEntries'; +export * from './useWorktrees'; diff --git a/src/web-ui/src/app/scenes/shell/hooks/useShellEntries.ts b/src/web-ui/src/app/scenes/shell/hooks/useShellEntries.ts new file mode 100644 index 00000000..2c67a6fd --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/hooks/useShellEntries.ts @@ -0,0 +1,589 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { getTerminalService } from '@/tools/terminal'; +import type { TerminalService } from '@/tools/terminal'; +import type { SessionResponse, TerminalEvent } from '@/tools/terminal/types/session'; +import { useTerminalSceneStore } from '@/app/stores/terminalSceneStore'; +import { useSceneStore } from '@/app/stores/sceneStore'; +import type { SceneTabId } from '@/app/components/SceneBar/types'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import type { TerminalConfig } from '@/infrastructure/config/types'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('useShellEntries'); + +const TERMINAL_HUB_STORAGE_KEY = 'bitfun-terminal-hub-config'; +const HUB_TERMINAL_ID_PREFIX = 'hub_'; + +export interface HubTerminalEntry { + sessionId: string; + name: string; + startupCommand?: string; +} + +export interface HubConfig { + terminals: HubTerminalEntry[]; + worktrees: Record; +} + +export interface ShellEntry { + sessionId: string; + name: string; + isRunning: boolean; + isHub: boolean; + worktreePath?: string; + startupCommand?: string; +} + +interface EditingTerminalState { + terminal: HubTerminalEntry; + isHub: boolean; + worktreePath?: string; +} + +export interface UseShellEntriesReturn { + mainEntries: ShellEntry[]; + hubMainEntries: ShellEntry[]; + adHocEntries: ShellEntry[]; + getWorktreeEntries: (worktreePath: string) => ShellEntry[]; + editModalOpen: boolean; + editingTerminal: EditingTerminalState | null; + closeEditModal: () => void; + refresh: () => Promise; + createAdHocTerminal: () => Promise; + createHubTerminal: (worktreePath?: string) => Promise; + promoteToHub: (entry: ShellEntry) => void; + openTerminal: (entry: ShellEntry) => Promise; + startTerminal: (entry: ShellEntry) => Promise; + stopTerminal: (sessionId: string) => Promise; + deleteTerminal: (entry: ShellEntry) => Promise; + openEditModal: (entry: ShellEntry) => void; + saveEdit: (newName: string, newStartupCommand?: string) => void; + closeWorktreeTerminals: (worktreePath: string) => Promise; + removeWorktreeConfig: (worktreePath: string) => void; +} + +function loadHubConfig(workspacePath: string): HubConfig { + try { + const raw = localStorage.getItem(`${TERMINAL_HUB_STORAGE_KEY}:${workspacePath}`); + if (raw) { + return JSON.parse(raw) as HubConfig; + } + } catch (error) { + log.error('Failed to load hub config', error); + } + + return { terminals: [], worktrees: {} }; +} + +function saveHubConfig(workspacePath: string, config: HubConfig) { + try { + localStorage.setItem(`${TERMINAL_HUB_STORAGE_KEY}:${workspacePath}`, JSON.stringify(config)); + } catch (error) { + log.error('Failed to save hub config', error); + } +} + +function generateHubTerminalId(): string { + return `${HUB_TERMINAL_ID_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} + +async function getDefaultShellType(): Promise { + try { + const config = await configManager.getConfig('terminal'); + return config?.default_shell || undefined; + } catch { + return undefined; + } +} + +function dispatchTerminalDestroyed(sessionId: string) { + if (typeof window === 'undefined') { + return; + } + + window.dispatchEvent(new CustomEvent('terminal-session-destroyed', { detail: { sessionId } })); +} + +function dispatchTerminalRenamed(sessionId: string, newName: string) { + if (typeof window === 'undefined') { + return; + } + + window.dispatchEvent(new CustomEvent('terminal-session-renamed', { detail: { sessionId, newName } })); +} + +export function useShellEntries(): UseShellEntriesReturn { + const setActiveSession = useTerminalSceneStore((s) => s.setActiveSession); + const { workspacePath } = useCurrentWorkspace(); + + const [sessions, setSessions] = useState([]); + const [hubConfig, setHubConfig] = useState({ terminals: [], worktrees: {} }); + const [editModalOpen, setEditModalOpen] = useState(false); + const [editingTerminal, setEditingTerminal] = useState(null); + + const serviceRef = useRef(null); + + const runningIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]); + + const configuredIds = useMemo(() => { + const ids = new Set(); + + hubConfig.terminals.forEach((terminal) => ids.add(terminal.sessionId)); + Object.values(hubConfig.worktrees).forEach((terminals) => { + terminals.forEach((terminal) => ids.add(terminal.sessionId)); + }); + + return ids; + }, [hubConfig]); + + const refreshSessions = useCallback(async () => { + const service = serviceRef.current; + if (!service) { + return; + } + + try { + setSessions(await service.listSessions()); + } catch (error) { + log.error('Failed to list sessions', error); + } + }, []); + + useEffect(() => { + if (!workspacePath) { + setHubConfig({ terminals: [], worktrees: {} }); + return; + } + + setHubConfig(loadHubConfig(workspacePath)); + }, [workspacePath]); + + useEffect(() => { + const service = getTerminalService(); + serviceRef.current = service; + + const init = async () => { + try { + await service.connect(); + await refreshSessions(); + } catch (error) { + log.error('Failed to connect terminal service', error); + } + }; + + void init(); + + const unsubscribe = service.onEvent((event: TerminalEvent) => { + if (event.type === 'ready' || event.type === 'exit') { + void refreshSessions(); + } + }); + + return () => unsubscribe(); + }, [refreshSessions]); + + const mainEntries = useMemo(() => { + const hubEntries = hubConfig.terminals.map((terminal) => ({ + sessionId: terminal.sessionId, + name: terminal.name, + isRunning: runningIds.has(terminal.sessionId), + isHub: true, + startupCommand: terminal.startupCommand, + })); + + const adHocEntries = sessions + .filter((session) => !configuredIds.has(session.id)) + .map((session) => ({ + sessionId: session.id, + name: session.name, + isRunning: true, + isHub: false, + })); + + return [...hubEntries, ...adHocEntries]; + }, [configuredIds, hubConfig.terminals, runningIds, sessions]); + + const hubMainEntries = useMemo( + () => mainEntries.filter((entry) => entry.isHub), + [mainEntries], + ); + + const adHocEntries = useMemo( + () => mainEntries.filter((entry) => !entry.isHub), + [mainEntries], + ); + + const worktreeEntries = useMemo>(() => { + const result: Record = {}; + + Object.entries(hubConfig.worktrees).forEach(([worktreePath, terminals]) => { + result[worktreePath] = terminals.map((terminal) => ({ + sessionId: terminal.sessionId, + name: terminal.name, + isRunning: runningIds.has(terminal.sessionId), + isHub: true, + worktreePath, + startupCommand: terminal.startupCommand, + })); + }); + + return result; + }, [hubConfig.worktrees, runningIds]); + + const updateHubConfig = useCallback((updater: (prev: HubConfig) => HubConfig) => { + if (!workspacePath) { + return; + } + + setHubConfig((prev) => { + const next = updater(prev); + saveHubConfig(workspacePath, next); + return next; + }); + }, [workspacePath]); + + const getWorktreeEntries = useCallback( + (worktreePath: string) => worktreeEntries[worktreePath] ?? [], + [worktreeEntries], + ); + + const refresh = useCallback(async () => { + await refreshSessions(); + + if (workspacePath) { + setHubConfig(loadHubConfig(workspacePath)); + } + }, [refreshSessions, workspacePath]); + + const openInShellScene = useCallback((sessionId: string) => { + const { openScene, openTabs, activeTabId } = useSceneStore.getState(); + // #region agent log + console.error('[DBG-366fda][H-C] openInShellScene called', {sessionId, activeTabId, openTabIds: openTabs.map(t=>t.id)}); + // #endregion + openScene('shell' as SceneTabId); + const afterState = useSceneStore.getState(); + // #region agent log + console.error('[DBG-366fda][H-C] after openScene(shell)', {newActiveTabId: afterState.activeTabId, openTabIds: afterState.openTabs.map(t=>t.id)}); + // #endregion + setActiveSession(sessionId); + }, [setActiveSession]); + + + const startTerminal = useCallback(async (entry: ShellEntry): Promise => { + const service = serviceRef.current; + // #region agent log + console.error('[DBG-366fda][H-B] startTerminal called', {entrySessionId:entry.sessionId,entryName:entry.name,isHub:entry.isHub,isRunning:entry.isRunning,startupCommand:entry.startupCommand,workspacePath,hasService:!!service}); + // #endregion + if (!service) { + return false; + } + + try { + const shellType = await getDefaultShellType(); + + const createdSession = await service.createSession({ + sessionId: entry.sessionId, + workingDirectory: entry.worktreePath ?? workspacePath, + name: entry.name, + shellType, + }); + // #region agent log + console.error('[DBG-366fda][H-B] session created', {createdId:createdSession.id,createdStatus:createdSession.status,requestedId:entry.sessionId,idMatch:createdSession.id===entry.sessionId}); + // #endregion + + if (entry.startupCommand?.trim()) { + await new Promise((resolve) => setTimeout(resolve, 800)); + try { + await service.sendCommand(entry.sessionId, entry.startupCommand); + } catch (error) { + log.error('Failed to run startup command', error); + } + } + + await refreshSessions(); + return true; + } catch (error) { + // #region agent log + console.error('[DBG-366fda][H-B] startTerminal FAILED', {error:String(error),entrySessionId:entry.sessionId}); + // #endregion + log.error('Failed to start terminal', error); + return false; + } + }, [refreshSessions, workspacePath]); + + const openTerminal = useCallback(async (entry: ShellEntry) => { + // #region agent log + console.error('[DBG-366fda][H-A] openTerminal called', {entrySessionId:entry.sessionId,isHub:entry.isHub,isRunning:entry.isRunning,startupCommand:entry.startupCommand}); + // #endregion + if (!entry.isRunning && entry.isHub) { + const started = await startTerminal(entry); + // #region agent log + console.error('[DBG-366fda][H-A] startTerminal result', {started,entrySessionId:entry.sessionId}); + // #endregion + if (!started) { + return; + } + } + + openInShellScene(entry.sessionId); + }, [openInShellScene, startTerminal]); + + const createAdHocTerminal = useCallback(async () => { + const service = serviceRef.current; + if (!service) { + return; + } + + try { + const shellType = await getDefaultShellType(); + const nextIndex = adHocEntries.length + 1; + const session = await service.createSession({ + workingDirectory: workspacePath, + name: `Shell ${nextIndex}`, + shellType, + }); + + setSessions((prev) => [...prev, session]); + openInShellScene(session.id); + } catch (error) { + log.error('Failed to create ad-hoc terminal', error); + } + }, [adHocEntries.length, openInShellScene, workspacePath]); + + const createHubTerminal = useCallback(async (worktreePath?: string) => { + const service = serviceRef.current; + if (!workspacePath || !service) { + return; + } + + const newEntry: HubTerminalEntry = { + sessionId: generateHubTerminalId(), + name: `Terminal ${Date.now() % 1000}`, + }; + + updateHubConfig((prev) => { + if (worktreePath) { + const terminals = prev.worktrees[worktreePath] || []; + return { + ...prev, + worktrees: { + ...prev.worktrees, + [worktreePath]: [...terminals, newEntry], + }, + }; + } + + return { + ...prev, + terminals: [...prev.terminals, newEntry], + }; + }); + + try { + const shellType = await getDefaultShellType(); + await service.createSession({ + sessionId: newEntry.sessionId, + workingDirectory: worktreePath ?? workspacePath, + name: newEntry.name, + shellType, + }); + + await refreshSessions(); + openInShellScene(newEntry.sessionId); + } catch (error) { + log.error('Failed to create hub terminal', error); + } + }, [openInShellScene, refreshSessions, updateHubConfig, workspacePath]); + + const promoteToHub = useCallback((entry: ShellEntry) => { + if (!workspacePath || entry.isHub) { + return; + } + + updateHubConfig((prev) => ({ + ...prev, + terminals: [ + ...prev.terminals, + { + sessionId: entry.sessionId, + name: entry.name, + }, + ], + })); + }, [updateHubConfig, workspacePath]); + + const stopTerminal = useCallback(async (sessionId: string) => { + const service = serviceRef.current; + if (!service || !runningIds.has(sessionId)) { + return; + } + + try { + await service.closeSession(sessionId); + dispatchTerminalDestroyed(sessionId); + await refreshSessions(); + } catch (error) { + log.error('Failed to stop terminal', error); + } + }, [refreshSessions, runningIds]); + + const deleteTerminal = useCallback(async (entry: ShellEntry) => { + const service = serviceRef.current; + + if (entry.isRunning && service) { + try { + await service.closeSession(entry.sessionId); + } catch (error) { + log.error('Failed to close terminal session', error); + } + } + + dispatchTerminalDestroyed(entry.sessionId); + + if (entry.isHub) { + updateHubConfig((prev) => { + if (entry.worktreePath) { + const terminals = (prev.worktrees[entry.worktreePath] || []).filter( + (terminal) => terminal.sessionId !== entry.sessionId, + ); + + return { + ...prev, + worktrees: { + ...prev.worktrees, + [entry.worktreePath]: terminals, + }, + }; + } + + return { + ...prev, + terminals: prev.terminals.filter((terminal) => terminal.sessionId !== entry.sessionId), + }; + }); + } + + await refreshSessions(); + }, [refreshSessions, updateHubConfig]); + + const openEditModal = useCallback((entry: ShellEntry) => { + setEditingTerminal({ + terminal: { + sessionId: entry.sessionId, + name: entry.name, + startupCommand: entry.startupCommand, + }, + isHub: entry.isHub, + worktreePath: entry.worktreePath, + }); + setEditModalOpen(true); + }, []); + + const closeEditModal = useCallback(() => { + setEditModalOpen(false); + setEditingTerminal(null); + }, []); + + const saveEdit = useCallback((newName: string, newStartupCommand?: string) => { + if (!editingTerminal) { + return; + } + + const { terminal, worktreePath, isHub } = editingTerminal; + + if (isHub) { + updateHubConfig((prev) => { + if (worktreePath) { + return { + ...prev, + worktrees: { + ...prev.worktrees, + [worktreePath]: (prev.worktrees[worktreePath] || []).map((item) => + item.sessionId === terminal.sessionId + ? { ...item, name: newName, startupCommand: newStartupCommand } + : item, + ), + }, + }; + } + + return { + ...prev, + terminals: prev.terminals.map((item) => + item.sessionId === terminal.sessionId + ? { ...item, name: newName, startupCommand: newStartupCommand } + : item, + ), + }; + }); + } + + if (runningIds.has(terminal.sessionId)) { + setSessions((prev) => + prev.map((session) => + session.id === terminal.sessionId ? { ...session, name: newName } : session, + ), + ); + dispatchTerminalRenamed(terminal.sessionId, newName); + } + + closeEditModal(); + }, [closeEditModal, editingTerminal, runningIds, updateHubConfig]); + + const closeWorktreeTerminals = useCallback(async (worktreePath: string) => { + const service = serviceRef.current; + if (!service) { + return; + } + + const terminals = hubConfig.worktrees[worktreePath] || []; + for (const terminal of terminals) { + if (!runningIds.has(terminal.sessionId)) { + continue; + } + + try { + await service.closeSession(terminal.sessionId); + dispatchTerminalDestroyed(terminal.sessionId); + } catch (error) { + log.error('Failed to close worktree terminal', error); + } + } + + await refreshSessions(); + }, [hubConfig.worktrees, refreshSessions, runningIds]); + + const removeWorktreeConfig = useCallback((worktreePath: string) => { + updateHubConfig((prev) => { + const nextWorktrees = { ...prev.worktrees }; + delete nextWorktrees[worktreePath]; + return { + ...prev, + worktrees: nextWorktrees, + }; + }); + }, [updateHubConfig]); + + return { + mainEntries, + hubMainEntries, + adHocEntries, + getWorktreeEntries, + editModalOpen, + editingTerminal, + closeEditModal, + refresh, + createAdHocTerminal, + createHubTerminal, + promoteToHub, + openTerminal, + startTerminal, + stopTerminal, + deleteTerminal, + openEditModal, + saveEdit, + closeWorktreeTerminals, + removeWorktreeConfig, + }; +} diff --git a/src/web-ui/src/app/scenes/shell/hooks/useWorktrees.ts b/src/web-ui/src/app/scenes/shell/hooks/useWorktrees.ts new file mode 100644 index 00000000..36a3433b --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/hooks/useWorktrees.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { gitAPI, type GitWorktreeInfo } from '@/infrastructure/api/service-api/GitAPI'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('useWorktrees'); + +export interface UseWorktreesReturn { + workspacePath?: string; + worktrees: GitWorktreeInfo[]; + nonMainWorktrees: GitWorktreeInfo[]; + isGitRepo: boolean; + currentBranch?: string; + refresh: () => Promise; + addWorktree: (branch: string, isNew: boolean) => Promise; + removeWorktree: (worktreePath: string) => Promise; +} + +export function useWorktrees(): UseWorktreesReturn { + const { workspacePath } = useCurrentWorkspace(); + + const [worktrees, setWorktrees] = useState([]); + const [isGitRepo, setIsGitRepo] = useState(false); + const [currentBranch, setCurrentBranch] = useState(); + + const refresh = useCallback(async () => { + if (!workspacePath) { + setWorktrees([]); + setIsGitRepo(false); + setCurrentBranch(undefined); + return; + } + + try { + const repository = await gitAPI.isGitRepository(workspacePath); + setIsGitRepo(repository); + + if (!repository) { + setWorktrees([]); + setCurrentBranch(undefined); + return; + } + + const [worktreeList, branches] = await Promise.all([ + gitAPI.listWorktrees(workspacePath), + gitAPI.getBranches(workspacePath, false), + ]); + + setWorktrees(worktreeList); + setCurrentBranch(branches.find((branch) => branch.current)?.name); + } catch (error) { + log.error('Failed to load worktrees', error); + setWorktrees([]); + setIsGitRepo(false); + setCurrentBranch(undefined); + } + }, [workspacePath]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const addWorktree = useCallback(async (branch: string, isNew: boolean) => { + if (!workspacePath) { + return; + } + + try { + await gitAPI.addWorktree(workspacePath, branch, isNew); + await refresh(); + } catch (error) { + log.error('Failed to add worktree', error); + throw error; + } + }, [refresh, workspacePath]); + + const removeWorktree = useCallback(async (worktreePath: string): Promise => { + if (!workspacePath) { + return false; + } + + try { + await gitAPI.removeWorktree(workspacePath, worktreePath); + await refresh(); + return true; + } catch (error) { + log.error('Failed to remove worktree', error); + return false; + } + }, [refresh, workspacePath]); + + const nonMainWorktrees = useMemo( + () => worktrees.filter((worktree) => !worktree.isMain), + [worktrees], + ); + + return { + workspacePath, + worktrees, + nonMainWorktrees, + isGitRepo, + currentBranch, + refresh, + addWorktree, + removeWorktree, + }; +} diff --git a/src/web-ui/src/app/scenes/shell/shellConfig.ts b/src/web-ui/src/app/scenes/shell/shellConfig.ts new file mode 100644 index 00000000..a20a1502 --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/shellConfig.ts @@ -0,0 +1,3 @@ +export type ShellNavView = 'all' | 'hub'; + +export const DEFAULT_SHELL_NAV_VIEW: ShellNavView = 'all'; diff --git a/src/web-ui/src/app/scenes/shell/shellStore.ts b/src/web-ui/src/app/scenes/shell/shellStore.ts new file mode 100644 index 00000000..360b193e --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/shellStore.ts @@ -0,0 +1,26 @@ +import { create } from 'zustand'; +import type { ShellNavView } from './shellConfig'; +import { DEFAULT_SHELL_NAV_VIEW } from './shellConfig'; + +interface ShellState { + navView: ShellNavView; + setNavView: (view: ShellNavView) => void; + expandedWorktrees: Set; + toggleWorktree: (path: string) => void; +} + +export const useShellStore = create((set) => ({ + navView: DEFAULT_SHELL_NAV_VIEW, + setNavView: (view) => set({ navView: view }), + expandedWorktrees: new Set(), + toggleWorktree: (path) => + set((state) => { + const next = new Set(state.expandedWorktrees); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return { expandedWorktrees: next }; + }), +})); diff --git a/src/web-ui/src/app/scenes/skills/SkillsNav.tsx b/src/web-ui/src/app/scenes/skills/SkillsNav.tsx deleted file mode 100644 index f586ffdc..00000000 --- a/src/web-ui/src/app/scenes/skills/SkillsNav.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/** - * SkillsNav — scene-specific left-side navigation for the Skills scene. - * - * Layout: - * ┌──────────────────────┐ - * │ 技能(Skills) │ header: title - * ├──────────────────────┤ - * │ DISCOVER │ - * │ › 市场 │ scrollable nav list - * │ INSTALLED │ - * │ › 全部 │ - * │ › 用户级 │ - * │ › 项目级 │ - * └──────────────────────┘ - */ - -import React, { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useSkillsSceneStore } from './skillsSceneStore'; -import { SKILLS_NAV_CATEGORIES } from './skillsConfig'; -import type { SkillsView } from './skillsSceneStore'; -import './SkillsNav.scss'; - -const SkillsNav: React.FC = () => { - const { t } = useTranslation('scenes/skills'); - const { activeView, setActiveView } = useSkillsSceneStore(); - - const handleItemClick = useCallback( - (view: SkillsView) => { - setActiveView(view); - }, - [setActiveView] - ); - - return ( -
-
- {t('nav.title')} -
- -
- {SKILLS_NAV_CATEGORIES.map((category) => ( -
-
- - {t(category.nameKey)} - -
-
- {category.items.map((item) => ( - - ))} -
-
- ))} -
-
- ); -}; - -export default SkillsNav; diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.scss b/src/web-ui/src/app/scenes/skills/SkillsScene.scss index 28ecd013..6dc7b62f 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.scss +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.scss @@ -1,111 +1,18 @@ -/** - * SkillsScene styles. - */ - @use '../../../component-library/styles/tokens' as *; -// ── Scene shell ─────────────────────────────────────────────── - .bitfun-skills-scene { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - overflow: hidden; - - &__loading { - flex: 1; - } - - // ── View layout ────────────────────────────────────────── - - &__view { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - overflow: hidden; - - // Hero mode: center everything - &--hero { - align-items: center; - justify-content: center; - } - } - - &__view-header { - flex-shrink: 0; - padding: $size-gap-5 clamp(16px, 2.2vw, 28px) $size-gap-4; - } - - // Inner centering wrapper for installed view header content (600px max-width, matches settings scene) - &__view-header-inner { - width: min(100%, 600px); - margin-inline: auto; - } - - &__view-title-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: $size-gap-3; - margin-bottom: $size-gap-3; - } - - &__view-title { - font-size: $font-size-xl; - font-weight: $font-weight-semibold; - color: var(--color-text-primary); - margin: 0 0 4px; - line-height: $line-height-tight; - } - - &__view-subtitle { - font-size: $font-size-sm; - color: var(--color-text-muted); - margin: 0; - line-height: $line-height-relaxed; - } - - &__view-content { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding: $size-gap-5 clamp(16px, 2.2vw, 28px); - - &::-webkit-scrollbar { width: 4px; } - &::-webkit-scrollbar-track { background: transparent; } - &::-webkit-scrollbar-thumb { - background: var(--border-subtle); - border-radius: 2px; + .gallery-page-header__actions { + > :first-child { + width: 260px; } } - // Inner centering wrapper for installed view content (600px max-width, matches settings scene) - &__view-content-inner { - width: min(100%, 600px); - margin-inline: auto; - display: flex; - flex-direction: column; - gap: $size-gap-6; - } - - // ── Market toolbar ─────────────────────────────────────── - - &__market-toolbar { - .search__wrapper { - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.06) 0%, - rgba(255, 255, 255, 0.03) 100% - ); - border: 1px solid var(--border-subtle); - border-radius: $size-radius-lg; - } + &__form-card { + border: 1px solid var(--border-subtle); + border-radius: $size-radius-lg; + background: color-mix(in srgb, var(--element-bg-soft) 88%, transparent); } - // ── Installed form ─────────────────────────────────────── - &__path-input { display: flex; align-items: flex-end; @@ -117,20 +24,11 @@ } } - &__path-hint { - font-size: $font-size-xs; - color: var(--color-text-muted); - } - - &__form-hint { - font-size: $font-size-xs; - color: var(--color-text-muted); - } - + &__path-hint, + &__form-hint, &__validating { font-size: $font-size-xs; color: var(--color-text-muted); - font-style: italic; } &__validation { @@ -156,600 +54,133 @@ } &__validation-desc { - font-size: $font-size-xs; - opacity: 0.8; margin-top: 2px; + font-size: $font-size-xs; + opacity: 0.85; } &__validation-error { font-weight: $font-weight-medium; } - &__path-value { - font-family: $font-family-mono; - font-size: $font-size-xs; - color: var(--color-text-secondary); - word-break: break-all; - } -} - -// ── Market list ─────────────────────────────────────────────── - -.bitfun-market { - - // ── Hero (pre-search) ───────────────────────────────────── - - &__hero { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: $size-gap-4; - padding: $size-gap-8 $size-gap-6; - width: min(100%, 480px); - animation: bitfun-market-item-in 0.3s $easing-decelerate both; - } - - &__hero-title { - font-size: $font-size-3xl; - font-weight: $font-weight-semibold; - color: var(--color-text-primary); - margin: 0; - line-height: $line-height-tight; - } - - &__hero-search { - width: 100%; - margin-top: $size-gap-2; - - .search__wrapper { - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.06) 0%, - rgba(255, 255, 255, 0.03) 100% - ); - border: 1px solid var(--border-subtle); - border-radius: $size-radius-lg; - } - } - - // ── Source attribution ───────────────────────────────────── - - &__source-note { - font-size: $font-size-xs; - color: var(--color-text-muted); - opacity: 0.6; - margin: $size-gap-2 0 0; - text-align: center; - } - - &__source-link { - color: inherit; - text-decoration: underline; - text-underline-offset: 2px; + &__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: $size-gap-2; + align-content: start; - &:hover { - opacity: 0.9; - color: var(--color-primary); + &--skeleton { + --skeleton-shimmer-0: rgba(255, 255, 255, 0); + --skeleton-shimmer-peak: rgba(255, 255, 255, 0.1); } } - // ── List container ──────────────────────────────────────── - - &__list { + &__skeleton-card { + height: 140px; + border-radius: $size-radius-lg; + background: var(--element-bg-subtle); border: 1px solid var(--border-subtle); - border-radius: $size-radius-base; - overflow: hidden; - // Shimmer vars must live on an actual DOM element so they inherit to children. - --skeleton-bg: var(--element-bg-medium); - --skeleton-shimmer-0: rgba(255, 255, 255, 0); - --skeleton-shimmer-peak: rgba(255, 255, 255, 0.13); - } - - // ── List item ───────────────────────────────────────────── - - &__list-item { position: relative; - border-bottom: 1px solid var(--border-subtle); - animation: bitfun-market-item-in 0.28s $easing-decelerate both; - animation-delay: calc(var(--item-index, 0) * 40ms); - - &:last-child { - border-bottom: none; - } - - &--skeleton { - animation: none; - overflow: hidden; - pointer-events: none; - - // Full-row shimmer sweep, staggered per row index for a top-down wave. - &::before { - content: ''; - position: absolute; - inset: 0; - transform: translateX(-100%); - background: linear-gradient( - 90deg, - var(--skeleton-shimmer-0) 0%, - var(--skeleton-shimmer-peak) 50%, - var(--skeleton-shimmer-0) 100% - ); - animation: bitfun-market-row-shimmer 1.6s ease-in-out - calc(var(--item-index, 0) * 0.1s) infinite; - z-index: 1; - pointer-events: none; - } + overflow: hidden; + animation: bitfun-skills-scene-item-in 0.28s $easing-decelerate both; + animation-delay: calc(var(--card-index, 0) * 60ms); + + &::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + var(--skeleton-shimmer-0) 0%, + var(--skeleton-shimmer-peak) 50%, + var(--skeleton-shimmer-0) 100% + ); + animation: bitfun-skills-scene-shimmer 1.6s ease-in-out calc(var(--card-index, 0) * 0.12s) infinite; } } - &__list-item-row { + &__empty { + min-height: 180px; display: flex; + flex-direction: column; align-items: center; - gap: $size-gap-4; - padding: $size-gap-3 $size-gap-4; - cursor: pointer; - transition: background $motion-fast $easing-standard; - outline: none; - - &:hover { - background: var(--element-bg-soft); - } + justify-content: center; + gap: $size-gap-3; + padding: $size-gap-8 $size-gap-6; + font-size: $font-size-sm; + color: var(--color-text-muted); + text-align: center; - &:focus-visible { - outline: 2px solid var(--color-accent-500); - outline-offset: -2px; + &--error { + color: var(--color-error); } } - &__list-item-info { - flex: 1; - min-width: 0; - } - - &__list-item-desc { - font-size: $font-size-xs; - color: var(--color-text-secondary); - margin: 3px 0 0; - line-height: $line-height-base; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - &__list-item-meta { - display: flex; + &__market-meta { + display: inline-flex; align-items: center; - gap: $size-gap-2; - flex-shrink: 0; - } - - &__list-item-action { - flex-shrink: 0; - } - - // ── Expanded details ────────────────────────────────────── - - &__list-item-details { - padding: $size-gap-3 $size-gap-4 $size-gap-4; - border-top: 1px solid var(--border-subtle); - background: var(--element-bg-subtle); - display: flex; - flex-direction: column; - gap: $size-gap-2; - } - - &__detail-desc { - font-size: $font-size-sm; - color: var(--color-text-secondary); - line-height: $line-height-relaxed; - margin: 0; + gap: 4px; } &__detail-row { display: flex; align-items: baseline; gap: $size-gap-2; - font-size: $font-size-xs; - color: var(--color-text-muted); } &__detail-label { flex-shrink: 0; - font-weight: $font-weight-medium; color: var(--color-text-secondary); + font-weight: $font-weight-medium; } - &__detail-value { + &__detail-value, + &__detail-link { + color: var(--color-text-muted); font-family: $font-family-mono; font-size: 11px; - color: var(--color-text-muted); word-break: break-all; } - // ── Name row (shared) ───────────────────────────────────── - - &__card-name-row { - display: flex; - align-items: center; - gap: $size-gap-2; - flex-wrap: nowrap; - min-width: 0; - } - - &__card-name { - font-size: $font-size-sm; - font-weight: $font-weight-semibold; - color: var(--color-text-primary); - line-height: $line-height-tight; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - // ── Badge ───────────────────────────────────────────────── - - &__badge { - display: inline-flex; - align-items: center; - gap: 3px; - padding: $badge-padding-y $badge-padding-x; - border-radius: $size-radius-full; - font-size: $badge-font-size; - font-weight: $badge-font-weight; - line-height: 1; - border: none; - white-space: nowrap; - flex-shrink: 0; - - &--installed { - background: var(--color-success-bg); - color: var(--color-success); - } - - &--user { - background: var(--color-info-bg); - color: var(--color-info); - } - - &--project { - background: var(--color-purple-200); - color: var(--color-purple-500); - } - - &--disabled { - background: var(--element-bg-base); - color: var(--color-text-secondary); - } - } - - // ── Installs count ──────────────────────────────────────── - - &__installs { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: $font-size-xs; - color: var(--color-text-muted); - white-space: nowrap; - flex-shrink: 0; - } - - // ── Source chip ─────────────────────────────────────────── - - &__chip { - display: inline-flex; - align-items: center; - max-width: 120px; - padding: 2px 8px; - border-radius: $size-radius-full; - font-size: $font-size-xs; - border: 1px solid var(--border-subtle); - background: var(--element-bg-soft); - color: var(--color-text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - cursor: default; + &__detail-link { + text-decoration: underline; + text-underline-offset: 2px; } - // ── Pagination ──────────────────────────────────────────── - &__pagination { display: flex; align-items: center; justify-content: center; gap: $size-gap-3; - padding: $size-gap-4 0 $size-gap-2; + padding-top: $size-gap-3; } &__pagination-info { - font-size: $font-size-sm; - color: var(--color-text-secondary); - min-width: 64px; + min-width: 52px; text-align: center; - user-select: none; - } - - // ── Search icon button (inside search suffix) ──────────── - - &__search-icon-btn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border: none; - border-radius: $size-radius-sm; - background: transparent; - color: var(--color-text-muted); - cursor: pointer; - transition: color $motion-fast $easing-standard, - background $motion-fast $easing-standard; - - &:hover { - color: var(--color-primary); - background: var(--element-bg-soft); - } - - &:active { - background: var(--element-bg-medium); - } - - &:focus-visible { - outline: 2px solid var(--color-accent-500); - outline-offset: -1px; - } - } - - // ── Empty / error states ───────────────────────────────── - - &__empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: $size-gap-3; - padding: $size-gap-10 $size-gap-6; - color: var(--color-text-muted); font-size: $font-size-sm; - text-align: center; - opacity: 0.6; - - svg { - opacity: 0.5; - } - - &--error { - color: var(--color-error); - opacity: 1; - - svg { - opacity: 0.7; - } - } - } - - // ── Skeleton ───────────────────────────────────────────── - - &__skeleton-line, - &__skeleton-chip, - &__skeleton-btn { - background: var(--skeleton-bg); - border-radius: $size-radius-sm; - border: none; - } - - &__skeleton-line { - height: 12px; - - &--title { - height: 14px; - width: 40%; - margin-bottom: 6px; - } - - &--body { - height: 11px; - width: 75%; - } - } - - &__skeleton-chip { - width: 80px; - height: 20px; - border-radius: $size-radius-full; - - &--sm { - width: 40px; - } - } - - &__skeleton-btn { - width: 96px; - height: 28px; - border-radius: $size-radius-base; + color: var(--color-text-secondary); } } -// ── Responsive ─────────────────────────────────────────────── - -@container (max-width: 460px) { +@media (max-width: 720px) { .bitfun-skills-scene { - &__view-header { - padding: $size-gap-4; - } + .gallery-page-header__actions { + width: 100%; - &__view-content { - padding: $size-gap-4; + > :first-child { + flex: 1; + width: auto; + } } } } -@keyframes bitfun-market-spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -.bitfun-market__spin { - animation: bitfun-market-spin 0.8s linear infinite; -} - -@keyframes bitfun-market-row-shimmer { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(200%); } -} - -@keyframes bitfun-market-item-in { - from { - opacity: 0; - transform: translateY(6px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -// ── Light theme: invert shimmer to dark ────────────────────── - -:root[data-theme-type="light"] .bitfun-market__list { - --skeleton-shimmer-0: rgba(0, 0, 0, 0); - --skeleton-shimmer-peak: rgba(0, 0, 0, 0.07); -} - @media (prefers-reduced-motion: reduce) { - .bitfun-market__list-item { - animation: none; - } -} - -// ── Installed view: filter bar & item actions ───────────── - -.bitfun-installed { - - &__header-actions { - display: flex; - align-items: center; - gap: $size-gap-1; - } - - // ── Filter chip bar ─────────────────────────────────── - - &__filter-bar { - display: flex; - align-items: center; - gap: $size-gap-2; - margin-top: $size-gap-3; - flex-wrap: wrap; - } - - &__filter-chip { - display: inline-flex; - align-items: center; - gap: $size-gap-2; - height: 26px; - padding: 0 $size-gap-3; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-full; - background: transparent; - color: var(--color-text-secondary); - font-size: $font-size-xs; - font-weight: $font-weight-medium; - cursor: pointer; - transition: color $motion-fast $easing-standard, - background $motion-fast $easing-standard, - border-color $motion-fast $easing-standard; - - &:hover { - color: var(--color-text-primary); - background: var(--element-bg-soft); - } - - &.is-active { - color: var(--color-primary); - background: rgba(var(--color-primary-rgb, 99, 102, 241), 0.1); - border-color: rgba(var(--color-primary-rgb, 99, 102, 241), 0.4); - } - - &:focus-visible { - outline: 2px solid var(--color-accent-500); - outline-offset: -1px; - } - } - - &__filter-count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 16px; - height: 16px; - padding: 0 4px; - border-radius: $size-radius-full; - background: var(--element-bg-medium); - font-size: 10px; - font-weight: $font-weight-semibold; - color: var(--color-text-muted); - line-height: 1; - - .bitfun-installed__filter-chip.is-active & { - background: rgba(var(--color-primary-rgb, 99, 102, 241), 0.18); - color: var(--color-primary); - } - } - - // ── Item actions (switch + delete) ──────────────────── - - &__item-actions { - display: flex; - align-items: center; - gap: $size-gap-2; - } - - &__delete-btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: none; - border-radius: $size-radius-sm; - background: transparent; - color: var(--color-text-muted); - cursor: pointer; - transition: color $motion-fast $easing-standard, - background $motion-fast $easing-standard; - - &:hover { - color: var(--color-error); - background: rgba(var(--color-error-rgb, 239, 68, 68), 0.1); - } - - &:active { - background: rgba(var(--color-error-rgb, 239, 68, 68), 0.18); - } - - &:focus-visible { - outline: 2px solid var(--color-error); - outline-offset: -1px; + .bitfun-skills-scene { + .bitfun-skills-scene__form-card { + transition: none; } } - - // ── Skeleton: actions placeholder ──────────────────── - - &__skeleton-actions { - width: 64px; - height: 24px; - border-radius: $size-radius-sm; - background: var(--skeleton-bg); - } -} - -// ── Installed list item: disabled opacity ───────────────── - -.bitfun-market__list-item.is-disabled { - .bitfun-market__card-name, - .bitfun-market__list-item-desc { - opacity: 0.45; - } } diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx index 99675077..b80cee19 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx @@ -1,37 +1,573 @@ -/** - * SkillsScene — Skills scene content. - * Renders view by activeView from skillsSceneStore. - * Nav is handled inline via NavPanel SkillsSection (Market / Installed). - */ - -import React, { Suspense, lazy } from 'react'; -import { useSkillsSceneStore } from './skillsSceneStore'; +import React, { useMemo, useState } from 'react'; +import { + CheckCircle2, + ChevronLeft, + ChevronRight, + Download, + FolderOpen, + Loader2, + Package, + Plus, + Puzzle, + RefreshCw, + Search as SearchIcon, + Sparkles, + Store, + Trash2, + TrendingUp, + X, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Badge, Button, ConfirmDialog, Input, Search, Select } from '@/component-library'; +import { + GalleryDetailModal, + GalleryEmpty, + GalleryGrid, + GalleryLayout, + GalleryPageHeader, + GallerySkeleton, + GalleryZone, +} from '@/app/components'; +import type { SkillInfo, SkillLevel, SkillMarketItem } from '@/infrastructure/config/types'; +import { getCardGradient } from '@/shared/utils/cardGradients'; +import { useInstalledSkills } from './hooks/useInstalledSkills'; +import { useSkillMarket } from './hooks/useSkillMarket'; +import SkillCard from './components/SkillCard'; import './SkillsScene.scss'; +import { useSkillsSceneStore } from './skillsSceneStore'; -const MarketView = lazy(() => import('./views/MarketView')); -const InstalledView = lazy(() => import('./views/InstalledView')); +const SKILLS_SOURCE_URL = 'https://skills.sh'; const SkillsScene: React.FC = () => { - const activeView = useSkillsSceneStore((s) => s.activeView); - - const renderView = () => { - switch (activeView) { - case 'market': - return ; - case 'installed-all': - case 'installed-user': - case 'installed-project': - default: - return ; + const { t } = useTranslation('scenes/skills'); + const { + searchDraft, + marketQuery, + installedFilter, + isAddFormOpen, + setSearchDraft, + submitMarketQuery, + setInstalledFilter, + setAddFormOpen, + toggleAddForm, + } = useSkillsSceneStore(); + + const [deleteTarget, setDeleteTarget] = useState(null); + const [selectedDetail, setSelectedDetail] = useState< + | { type: 'installed'; skill: SkillInfo } + | { type: 'market'; skill: SkillMarketItem } + | null + >(null); + + const installed = useInstalledSkills({ + searchQuery: searchDraft, + activeFilter: installedFilter, + }); + + const installedSkillNames = useMemo( + () => new Set(installed.skills.map((skill) => skill.name)), + [installed.skills], + ); + + const market = useSkillMarket({ + searchQuery: marketQuery, + installedSkillNames, + onInstalledChanged: async () => { + await installed.loadSkills(true); + }, + }); + + const isRefreshing = installed.loading || market.marketLoading || market.loadingMore; + + const handleRefreshAll = async () => { + await Promise.all([ + installed.loadSkills(true), + market.refresh(), + ]); + }; + + const handleAddSkill = async () => { + const added = await installed.handleAdd(); + if (added) { + setAddFormOpen(false); + await market.refresh(); } }; + const renderInstalledCard = (skill: SkillInfo, index: number) => ( + + {skill.level === 'user' ? t('list.item.user') : t('list.item.project')} + + )} + actions={[ + { + id: 'delete', + icon: , + ariaLabel: t('list.item.deleteTooltip'), + title: t('list.item.deleteTooltip'), + tone: 'danger', + onClick: () => setDeleteTarget(skill), + }, + ]} + onOpenDetails={() => setSelectedDetail({ type: 'installed', skill })} + /> + ); + + const renderMarketCard = (skill: SkillMarketItem, index: number) => { + const isInstalled = installedSkillNames.has(skill.name); + const isDownloading = market.downloadingPackage === skill.installId; + + return ( + + + {t('market.item.installed')} + + ) : null} + meta={( + + + {skill.installs ?? 0} + + )} + actions={[ + { + id: 'download', + icon: isInstalled ? : , + ariaLabel: isInstalled ? t('market.item.installed') : t('market.item.downloadProject'), + title: isDownloading + ? t('market.item.downloading') + : (isInstalled ? t('market.item.installedTooltip') : t('market.item.downloadProject')), + disabled: isDownloading || !market.hasWorkspace || isInstalled, + tone: isInstalled ? 'success' : 'primary', + onClick: () => market.handleDownload(skill), + }, + ]} + onOpenDetails={() => setSelectedDetail({ type: 'market', skill })} + /> + ); + }; + + const selectedInstalledSkill = selectedDetail?.type === 'installed' ? selectedDetail.skill : null; + const selectedMarketSkill = selectedDetail?.type === 'market' ? selectedDetail.skill : null; + return ( -
- }> - {renderView()} - -
+ + + } + suffixContent={( + + )} + /> + + + + )} + /> + +
+ +
+ {([ + ['all', installed.counts.all], + ['user', installed.counts.user], + ['project', installed.counts.project], + ] as const).map(([filter, count]) => ( + + ))} +
+ {installed.filteredSkills.length} + + )} + > + {isAddFormOpen ? ( +
+
+

{t('form.title')}

+ +
+
+ installed.setFormPath(e.target.value)} + variant="outlined" + /> + +
+
+ {t('form.path.hint')} +
+ + {installed.isValidating ? ( +
{t('form.validating')}
+ ) : null} + + {installed.validationResult ? ( +
+ {installed.validationResult.valid ? ( + <> +
+ {installed.validationResult.name} +
+
+ {installed.validationResult.description} +
+ + ) : ( +
+ {installed.validationResult.error} +
+ )} +
+ ) : null} +
+
+ + +
+
+ ) : null} + + {installed.loading ? : null} + + {!installed.loading && installed.error ? ( + } + message={installed.error} + isError + /> + ) : null} + + {!installed.loading && !installed.error && installed.filteredSkills.length === 0 ? ( + } + message={installed.skills.length === 0 ? t('list.empty.noSkills') : t('list.empty.noMatch')} + /> + ) : null} + + {!installed.loading && !installed.error && installed.filteredSkills.length > 0 ? ( + + {installed.filteredSkills.map(renderInstalledCard)} + + ) : null} + + + + {t('market.subtitlePrefix')} + {' '} + + skills.sh + + {t('market.subtitleSuffix')} + + )} + tools={{market.totalLoaded}} + > + {market.marketLoading ? : null} + + {!market.marketLoading && market.loadingMore && market.marketSkills.length === 0 + ? + : null} + + {!market.marketLoading && market.marketError ? ( + } + message={market.marketError} + isError + /> + ) : null} + + {!market.marketLoading && !market.loadingMore && !market.marketError && market.marketSkills.length === 0 ? ( + } + message={marketQuery ? t('market.empty.noMatch') : t('market.empty.noSkills')} + /> + ) : null} + + {!market.marketLoading && !market.marketError && market.marketSkills.length > 0 ? ( + + {market.marketSkills.map(renderMarketCard)} + + ) : null} + + {!market.marketLoading && !market.marketError && (market.totalPages > 1 || market.hasMore) ? ( +
+ + + {market.hasMore + ? t('market.pagination.infoMore', { current: market.currentPage + 1 }) + : t('market.pagination.info', { + current: market.currentPage + 1, + total: market.totalPages, + })} + + +
+ ) : null} +
+
+ + setSelectedDetail(null)} + icon={selectedMarketSkill ? : } + iconGradient={getCardGradient( + selectedInstalledSkill?.name + ?? selectedMarketSkill?.installId + ?? selectedMarketSkill?.name + ?? 'skill' + )} + title={selectedInstalledSkill?.name ?? selectedMarketSkill?.name ?? ''} + badges={selectedInstalledSkill ? ( + + {selectedInstalledSkill.level === 'user' ? t('list.item.user') : t('list.item.project')} + + ) : selectedMarketSkill && installedSkillNames.has(selectedMarketSkill.name) ? ( + + + {t('market.item.installed')} + + ) : null} + description={selectedInstalledSkill?.description ?? selectedMarketSkill?.description} + meta={selectedMarketSkill ? ( + + + {selectedMarketSkill.installs ?? 0} + + ) : null} + actions={selectedInstalledSkill ? ( + + ) : selectedMarketSkill ? ( + + ) : null} + > + {selectedInstalledSkill ? ( +
+ {t('list.item.pathLabel')} + {selectedInstalledSkill.path} +
+ ) : null} + + {selectedMarketSkill?.source ? ( +
+ {t('market.item.sourceLabel')} + {selectedMarketSkill.source} +
+ ) : null} + + {selectedMarketSkill ? ( +
+ {t('market.detail.installsLabel')} + {selectedMarketSkill.installs ?? 0} +
+ ) : null} + + {selectedMarketSkill?.url ? ( +
+ {t('market.detail.linkLabel')} + + {selectedMarketSkill.url} + +
+ ) : null} +
+ + setDeleteTarget(null)} + onConfirm={async () => { + if (!deleteTarget) { + return; + } + const deleted = await installed.handleDelete(deleteTarget); + if (deleted) { + setDeleteTarget(null); + } + }} + title={t('deleteModal.title')} + message={t('deleteModal.message', { name: deleteTarget?.name ?? '' })} + type="warning" + confirmDanger + confirmText={t('deleteModal.delete')} + cancelText={t('deleteModal.cancel')} + /> + ); }; diff --git a/src/web-ui/src/app/scenes/skills/components/SkillCard.scss b/src/web-ui/src/app/scenes/skills/components/SkillCard.scss new file mode 100644 index 00000000..c1d4919b --- /dev/null +++ b/src/web-ui/src/app/scenes/skills/components/SkillCard.scss @@ -0,0 +1,210 @@ +@use '../../../../component-library/styles/tokens' as *; + +/* ─────────────────────────────────────────────────── + SkillCard — horizontal card with icon column + body + DOM: + .skill-card + .skill-card__icon-area + .skill-card__body + .skill-card__header (name + badges + actions) + .skill-card__desc + .skill-card__meta + .skill-card__detail-wrap + ─────────────────────────────────────────────────── */ + +.skill-card { + display: flex; + flex-direction: row; + border-radius: $size-radius-lg; + background: var(--element-bg-soft); + border: 1px solid transparent; + cursor: pointer; + position: relative; + animation: skill-card-in 0.22s $easing-decelerate both; + animation-delay: calc(var(--card-index, 0) * 35ms); + transition: + background $motion-fast $easing-standard, + border-color $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard, + transform $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-medium); + border-color: var(--border-subtle); + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: 2px; + } + + // ── Icon column ── + &__icon-area { + width: 56px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--skill-card-gradient); + border-radius: $size-radius-lg 0 0 $size-radius-lg; + } + + &__icon { + color: var(--color-text-primary); + animation: skill-card-icon-pop 0.46s $easing-decelerate both; + animation-delay: calc(var(--card-index, 0) * 35ms + 50ms); + } + + // ── Body column (vertical flow) ── + &__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + padding: $size-gap-3; + gap: 4px; + overflow: hidden; + } + + // ── Header: name + badges + action buttons ── + &__header { + display: flex; + align-items: flex-start; + gap: $size-gap-2; + } + + &__header-text { + flex: 1; + min-width: 0; + display: flex; + align-items: baseline; + gap: $size-gap-2; + flex-wrap: wrap; + } + + &__name { + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + line-height: $line-height-base; + word-break: break-word; + } + + // ── Description ── + &__desc { + margin: 0; + font-size: $font-size-xs; + color: var(--color-text-secondary); + line-height: $line-height-relaxed; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + } + + // ── Meta row ── + &__meta { + display: flex; + align-items: center; + gap: $size-gap-1; + flex-wrap: wrap; + margin-top: auto; + padding-top: $size-gap-2; + color: var(--color-text-muted); + font-size: $font-size-xs; + line-height: $line-height-base; + } + + // ── Action buttons ── + &__actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + } + + &__action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: none; + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: + background $motion-fast $easing-standard, + color $motion-fast $easing-standard; + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: var(--element-bg-strong); + color: var(--color-text-primary); + } + + &--primary:not(:disabled):hover { + color: var(--color-accent-400); + } + + &--success:not(:disabled):hover { + color: var(--color-success); + } + + &--danger:not(:disabled):hover { + color: var(--color-error); + } + } +} + +// ── Responsive ── +@media (max-width: 720px) { + .skill-card { + &__icon-area { + width: 48px; + } + } +} + +// ── Animations ── +@keyframes skill-card-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes skill-card-icon-pop { + 0% { + opacity: 0; + transform: scale(0.55) rotate(-10deg); + } + 60% { + opacity: 1; + transform: scale(1.15) rotate(3deg); + } + 100% { + transform: scale(1) rotate(0deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .skill-card, + .skill-card__icon { + animation: none; + transition: none; + } +} diff --git a/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx b/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx new file mode 100644 index 00000000..afdebe6d --- /dev/null +++ b/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { Package, Puzzle } from 'lucide-react'; +import { getCardGradient } from '@/shared/utils/cardGradients'; +import './SkillCard.scss'; + +type SkillCardActionTone = 'primary' | 'danger' | 'success' | 'muted'; + +export interface SkillCardAction { + id: string; + icon: React.ReactNode; + ariaLabel: string; + title?: string; + disabled?: boolean; + tone?: SkillCardActionTone; + onClick: () => void; +} + +interface SkillCardProps { + name: string; + description?: string; + index?: number; + accentSeed?: string; + iconKind?: 'skill' | 'market'; + badges?: React.ReactNode; + meta?: React.ReactNode; + actions?: SkillCardAction[]; + onOpenDetails?: () => void; +} + +const SkillCard: React.FC = ({ + name, + description, + index = 0, + accentSeed, + iconKind = 'skill', + badges, + meta, + actions = [], + onOpenDetails, +}) => { + const Icon = iconKind === 'market' ? Package : Puzzle; + const openDetails = () => onOpenDetails?.(); + + return ( +
e.key === 'Enter' && openDetails()} + aria-label={name} + > +
+
+ +
+
+ +
+
+
+ {name} + {badges} +
+ {actions.length > 0 && ( +
e.stopPropagation()}> + {actions.map((action) => ( + + ))} +
+ )} +
+ + {description?.trim() && ( +

{description.trim()}

+ )} + + {meta &&
{meta}
} +
+
+ ); +}; + +export default SkillCard; diff --git a/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts b/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts new file mode 100644 index 00000000..f4bab964 --- /dev/null +++ b/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { open } from '@tauri-apps/plugin-dialog'; +import { useTranslation } from 'react-i18next'; +import { configAPI } from '@/infrastructure/api'; +import type { SkillInfo, SkillLevel, SkillValidationResult } from '@/infrastructure/config/types'; +import { useCurrentWorkspace } from '@/infrastructure/hooks/useWorkspace'; +import { useNotification } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import type { InstalledFilter } from '../skillsSceneStore'; + +const log = createLogger('SkillsScene:useInstalledSkills'); + +interface UseInstalledSkillsOptions { + searchQuery: string; + activeFilter: InstalledFilter; +} + +export function useInstalledSkills({ searchQuery, activeFilter }: UseInstalledSkillsOptions) { + const { t } = useTranslation('scenes/skills'); + const notification = useNotification(); + const { workspacePath, hasWorkspace } = useCurrentWorkspace(); + + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [formLevel, setFormLevel] = useState('user'); + const [formPath, setFormPath] = useState(''); + const [validationResult, setValidationResult] = useState(null); + const [isValidating, setIsValidating] = useState(false); + const [isAdding, setIsAdding] = useState(false); + + const loadSkills = useCallback(async (forceRefresh?: boolean) => { + try { + setLoading(true); + setError(null); + const list = await configAPI.getSkillConfigs(forceRefresh); + setSkills(list); + } catch (err) { + log.error('Failed to load skills', err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadSkills(); + }, [loadSkills]); + + const validatePath = useCallback(async (path: string) => { + if (!path.trim()) { + setValidationResult(null); + return; + } + try { + setIsValidating(true); + const result = await configAPI.validateSkillPath(path); + setValidationResult(result); + } catch (err) { + setValidationResult({ + valid: false, + error: err instanceof Error ? err.message : String(err), + }); + } finally { + setIsValidating(false); + } + }, []); + + useEffect(() => { + const timer = window.setTimeout(() => { + validatePath(formPath); + }, 300); + return () => window.clearTimeout(timer); + }, [formPath, validatePath]); + + const handleBrowse = useCallback(async () => { + try { + const selected = await open({ + directory: true, + multiple: false, + title: t('form.path.label'), + }); + if (selected) { + setFormPath(selected as string); + } + } catch (err) { + log.error('Failed to open file dialog', err); + } + }, [t]); + + const resetForm = useCallback(() => { + setFormPath(''); + setFormLevel('user'); + setValidationResult(null); + }, []); + + const handleAdd = useCallback(async () => { + if (!validationResult?.valid || !formPath.trim()) { + notification.warning(t('messages.invalidPath')); + return false; + } + if (formLevel === 'project' && !hasWorkspace) { + notification.warning(t('messages.noWorkspace')); + return false; + } + try { + setIsAdding(true); + await configAPI.addSkill(formPath, formLevel); + notification.success(t('messages.addSuccess', { name: validationResult.name })); + resetForm(); + await loadSkills(true); + return true; + } catch (err) { + notification.error( + t('messages.addFailed', { + error: err instanceof Error ? err.message : String(err), + }), + ); + return false; + } finally { + setIsAdding(false); + } + }, [formLevel, formPath, hasWorkspace, loadSkills, notification, resetForm, t, validationResult]); + + const handleDelete = useCallback(async (skill: SkillInfo) => { + try { + await configAPI.deleteSkill(skill.name); + notification.success(t('messages.deleteSuccess', { name: skill.name })); + await loadSkills(true); + return true; + } catch (err) { + notification.error( + t('messages.deleteFailed', { + error: err instanceof Error ? err.message : String(err), + }), + ); + return false; + } + }, [loadSkills, notification, t]); + + const normalizedQuery = searchQuery.trim().toLowerCase(); + + const filteredSkills = useMemo(() => { + return skills.filter((skill) => { + const matchesFilter = activeFilter === 'all' || skill.level === activeFilter; + const matchesQuery = !normalizedQuery || [ + skill.name, + skill.description, + skill.path, + ].some((field) => field?.toLowerCase().includes(normalizedQuery)); + return matchesFilter && matchesQuery; + }); + }, [activeFilter, normalizedQuery, skills]); + + const counts = useMemo(() => ({ + all: skills.length, + user: skills.filter((skill) => skill.level === 'user').length, + project: skills.filter((skill) => skill.level === 'project').length, + }), [skills]); + + return { + skills, + filteredSkills, + counts, + loading, + error, + loadSkills, + handleDelete, + formLevel, + setFormLevel, + formPath, + setFormPath, + validationResult, + isValidating, + isAdding, + handleBrowse, + handleAdd, + resetForm, + workspacePath, + hasWorkspace, + }; +} diff --git a/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts b/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts new file mode 100644 index 00000000..3a2cc14a --- /dev/null +++ b/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { configAPI } from '@/infrastructure/api'; +import type { SkillMarketItem } from '@/infrastructure/config/types'; +import { useCurrentWorkspace } from '@/infrastructure/hooks/useWorkspace'; +import { useNotification } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('SkillsScene:useSkillMarket'); + +const PAGE_SIZE = 10; +const MAX_TOTAL_SKILLS = 500; + +interface UseSkillMarketOptions { + searchQuery: string; + installedSkillNames: Set; + onInstalledChanged?: () => Promise | void; +} + +export function useSkillMarket({ + searchQuery, + installedSkillNames, + onInstalledChanged, +}: UseSkillMarketOptions) { + const { t } = useTranslation('scenes/skills'); + const notification = useNotification(); + const { hasWorkspace } = useCurrentWorkspace(); + + const [marketSkills, setMarketSkills] = useState([]); + const [marketLoading, setMarketLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [marketError, setMarketError] = useState(null); + const [downloadingPackage, setDownloadingPackage] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [hasMore, setHasMore] = useState(true); + + const fetchSkills = useCallback(async (query: string | undefined, limit: number) => { + const normalized = query?.trim(); + return normalized + ? await configAPI.searchSkillMarket(normalized, limit) + : await configAPI.listSkillMarket(undefined, limit); + }, []); + + const loadFirstPage = useCallback(async (query?: string) => { + setMarketLoading(true); + setMarketError(null); + setCurrentPage(0); + try { + const skillList = await fetchSkills(query, PAGE_SIZE); + setMarketSkills(skillList); + setHasMore(skillList.length >= PAGE_SIZE); + } catch (err) { + log.error('Failed to load skill market', err); + setMarketError(err instanceof Error ? err.message : String(err)); + } finally { + setMarketLoading(false); + } + }, [fetchSkills]); + + useEffect(() => { + loadFirstPage(searchQuery || undefined); + }, [loadFirstPage, searchQuery]); + + const refresh = useCallback(async () => { + await loadFirstPage(searchQuery || undefined); + }, [loadFirstPage, searchQuery]); + + const displayMarketSkills = useMemo(() => { + const entries = marketSkills.map((skill, index) => ({ + skill, + index, + installed: installedSkillNames.has(skill.name), + })); + + entries.sort((a, b) => { + if (a.installed !== b.installed) { + return a.installed ? -1 : 1; + } + const installDelta = (b.skill.installs ?? 0) - (a.skill.installs ?? 0); + if (installDelta !== 0) { + return installDelta; + } + return a.index - b.index; + }); + + return entries.map((entry) => entry.skill); + }, [installedSkillNames, marketSkills]); + + const loadedPages = Math.ceil(displayMarketSkills.length / PAGE_SIZE); + const totalPages = hasMore ? loadedPages + 1 : Math.max(1, loadedPages); + + const paginatedSkills = useMemo(() => displayMarketSkills.slice( + currentPage * PAGE_SIZE, + (currentPage + 1) * PAGE_SIZE, + ), [currentPage, displayMarketSkills]); + + const goToPrevPage = useCallback(() => { + setCurrentPage((page) => Math.max(0, page - 1)); + }, []); + + const goToNextPage = useCallback(async () => { + const nextPage = currentPage + 1; + const neededCount = Math.min((nextPage + 1) * PAGE_SIZE, MAX_TOTAL_SKILLS); + + if (displayMarketSkills.length >= neededCount) { + setCurrentPage(nextPage); + return; + } + + if (!hasMore) { + return; + } + + setCurrentPage(nextPage); + + try { + setLoadingMore(true); + const skillList = await fetchSkills(searchQuery || undefined, neededCount); + setMarketSkills(skillList); + const hitCap = neededCount >= MAX_TOTAL_SKILLS; + setHasMore(!hitCap && skillList.length >= neededCount); + } catch (err) { + log.error('Failed to load more skills', err); + setCurrentPage(currentPage); + } finally { + setLoadingMore(false); + } + }, [currentPage, displayMarketSkills.length, fetchSkills, hasMore, searchQuery]); + + const handleDownload = useCallback(async (skill: SkillMarketItem) => { + if (!hasWorkspace) { + notification.warning(t('messages.noWorkspace')); + return; + } + try { + setDownloadingPackage(skill.installId); + const result = await configAPI.downloadSkillMarket(skill.installId, 'project'); + const installedName = result.installedSkills[0] ?? skill.name; + notification.success(t('messages.marketDownloadSuccess', { name: installedName })); + await onInstalledChanged?.(); + } catch (err) { + notification.error( + t('messages.marketDownloadFailed', { + error: err instanceof Error ? err.message : String(err), + }), + ); + } finally { + setDownloadingPackage(null); + } + }, [hasWorkspace, notification, onInstalledChanged, t]); + + return { + marketSkills: paginatedSkills, + marketLoading, + loadingMore, + marketError, + downloadingPackage, + hasMore, + currentPage, + totalPages, + refresh, + goToPrevPage, + goToNextPage, + handleDownload, + hasWorkspace, + totalLoaded: displayMarketSkills.length, + }; +} diff --git a/src/web-ui/src/app/scenes/skills/skillsConfig.ts b/src/web-ui/src/app/scenes/skills/skillsConfig.ts deleted file mode 100644 index abec3deb..00000000 --- a/src/web-ui/src/app/scenes/skills/skillsConfig.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * skillsConfig — static shape of skills scene navigation views. - * - * Shared by SkillsNav (left sidebar) and SkillsScene (content renderer). - */ - -import type { SkillsView } from './skillsSceneStore'; - -export interface SkillsNavItem { - id: SkillsView; - labelKey: string; -} - -export interface SkillsNavCategory { - id: string; - nameKey: string; - items: SkillsNavItem[]; -} - -export const SKILLS_NAV_CATEGORIES: SkillsNavCategory[] = [ - { - id: 'discover', - nameKey: 'nav.categories.discover', - items: [ - { id: 'market', labelKey: 'nav.items.market' }, - ], - }, - { - id: 'installed', - nameKey: 'nav.categories.installed', - items: [ - { id: 'installed-all', labelKey: 'nav.items.installedAll' }, - { id: 'installed-user', labelKey: 'nav.items.installedUser' }, - { id: 'installed-project', labelKey: 'nav.items.installedProject' }, - ], - }, -]; - -export const DEFAULT_SKILLS_VIEW: SkillsView = 'market'; diff --git a/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts b/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts index 96a83f8d..c25b4498 100644 --- a/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts +++ b/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts @@ -1,20 +1,27 @@ -/** - * skillsSceneStore — Zustand store for the Skills scene. - * - * Shared between SkillsNav (left sidebar) and SkillsScene (content area) - * so both reflect the same active view. - */ - import { create } from 'zustand'; -export type SkillsView = 'market' | 'installed-all' | 'installed-user' | 'installed-project'; +export type InstalledFilter = 'all' | 'user' | 'project'; interface SkillsSceneState { - activeView: SkillsView; - setActiveView: (view: SkillsView) => void; + searchDraft: string; + marketQuery: string; + installedFilter: InstalledFilter; + isAddFormOpen: boolean; + setSearchDraft: (value: string) => void; + submitMarketQuery: () => void; + setInstalledFilter: (filter: InstalledFilter) => void; + setAddFormOpen: (open: boolean) => void; + toggleAddForm: () => void; } export const useSkillsSceneStore = create((set) => ({ - activeView: 'market', - setActiveView: (view) => set({ activeView: view }), + searchDraft: '', + marketQuery: '', + installedFilter: 'all', + isAddFormOpen: false, + setSearchDraft: (value) => set({ searchDraft: value }), + submitMarketQuery: () => set((state) => ({ marketQuery: state.searchDraft.trim() })), + setInstalledFilter: (filter) => set({ installedFilter: filter }), + setAddFormOpen: (open) => set({ isAddFormOpen: open }), + toggleAddForm: () => set((state) => ({ isAddFormOpen: !state.isAddFormOpen })), })); diff --git a/src/web-ui/src/app/scenes/skills/views/InstalledView.tsx b/src/web-ui/src/app/scenes/skills/views/InstalledView.tsx deleted file mode 100644 index 04f49a35..00000000 --- a/src/web-ui/src/app/scenes/skills/views/InstalledView.tsx +++ /dev/null @@ -1,418 +0,0 @@ -/** - * InstalledView — installed skills list with filter chips. - * List layout mirrors MarketView for visual consistency. - * Inline "Add Skill" form toggled by the header "+" button. - */ - -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Plus, Trash2, RefreshCw, FolderOpen, X, Package } from 'lucide-react'; -import { Select, Input, Button, IconButton, ConfirmDialog, Badge } from '@/component-library'; -import { useCurrentWorkspace } from '@/infrastructure/hooks/useWorkspace'; -import { useNotification } from '@/shared/notification-system'; -import { configAPI } from '@/infrastructure/api'; -import type { SkillInfo, SkillLevel, SkillValidationResult } from '@/infrastructure/config/types'; -import { open } from '@tauri-apps/plugin-dialog'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('SkillsScene:InstalledView'); - -type FilterType = 'all' | 'user' | 'project'; - -const FILTERS: { id: FilterType; labelKey: string }[] = [ - { id: 'all', labelKey: 'filters.all' }, - { id: 'user', labelKey: 'filters.user' }, - { id: 'project', labelKey: 'filters.project' }, -]; - -const InstalledView: React.FC = () => { - const { t } = useTranslation('scenes/skills'); - - const [activeFilter, setActiveFilter] = useState('all'); - const [showAddForm, setShowAddForm] = useState(false); - const [expandedSkillIds, setExpandedSkillIds] = useState>(new Set()); - const [skills, setSkills] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const [formLevel, setFormLevel] = useState('user'); - const [formPath, setFormPath] = useState(''); - const [validationResult, setValidationResult] = useState(null); - const [isValidating, setIsValidating] = useState(false); - const [isAdding, setIsAdding] = useState(false); - - const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; skill: SkillInfo | null }>({ - show: false, - skill: null, - }); - - const { workspacePath, hasWorkspace } = useCurrentWorkspace(); - const notification = useNotification(); - - const loadSkills = useCallback(async (forceRefresh?: boolean) => { - try { - setLoading(true); - setError(null); - const list = await configAPI.getSkillConfigs(forceRefresh); - setSkills(list); - } catch (err) { - log.error('Failed to load skills', err); - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { loadSkills(); }, [loadSkills]); - - const validatePath = useCallback(async (path: string) => { - if (!path.trim()) { setValidationResult(null); return; } - try { - setIsValidating(true); - const result = await configAPI.validateSkillPath(path); - setValidationResult(result); - } catch (err) { - setValidationResult({ valid: false, error: err instanceof Error ? err.message : String(err) }); - } finally { - setIsValidating(false); - } - }, []); - - useEffect(() => { - const timer = setTimeout(() => { validatePath(formPath); }, 300); - return () => clearTimeout(timer); - }, [formPath, validatePath]); - - const handleAdd = async () => { - if (!validationResult?.valid || !formPath.trim()) { - notification.warning(t('messages.invalidPath')); - return; - } - if (formLevel === 'project' && !hasWorkspace) { - notification.warning(t('messages.noWorkspace')); - return; - } - try { - setIsAdding(true); - await configAPI.addSkill(formPath, formLevel); - notification.success(t('messages.addSuccess', { name: validationResult.name })); - resetForm(); - loadSkills(); - } catch (err) { - notification.error(t('messages.addFailed', { error: err instanceof Error ? err.message : String(err) })); - } finally { - setIsAdding(false); - } - }; - - const confirmDelete = async () => { - const skill = deleteConfirm.skill; - if (!skill) return; - try { - await configAPI.deleteSkill(skill.name); - notification.success(t('messages.deleteSuccess', { name: skill.name })); - loadSkills(); - } catch (err) { - notification.error(t('messages.deleteFailed', { error: err instanceof Error ? err.message : String(err) })); - } finally { - setDeleteConfirm({ show: false, skill: null }); - } - }; - - const handleBrowse = async () => { - try { - const selected = await open({ directory: true, multiple: false, title: t('form.path.label') }); - if (selected) setFormPath(selected as string); - } catch (err) { - log.error('Failed to open file dialog', err); - } - }; - - const resetForm = () => { - setFormPath(''); - setFormLevel('user'); - setValidationResult(null); - setShowAddForm(false); - }; - - const toggleSkillExpanded = useCallback((skillId: string) => { - setExpandedSkillIds(prev => { - const next = new Set(prev); - next.has(skillId) ? next.delete(skillId) : next.add(skillId); - return next; - }); - }, []); - - const filteredSkills = useMemo(() => { - if (activeFilter === 'all') return skills; - return skills.filter(s => s.level === activeFilter); - }, [skills, activeFilter]); - - const renderSkeletonList = () => ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))} -
- ); - - const renderList = () => { - if (loading) return renderSkeletonList(); - - if (error) { - return ( -
- - {t('list.errorPrefix')}{error} -
- ); - } - - if (filteredSkills.length === 0) { - return ( -
- - {skills.length === 0 ? t('list.empty.noSkills') : t('list.empty.noMatch')} - {skills.length === 0 && ( - - )} -
- ); - } - - return ( -
- {filteredSkills.map((skill, index) => { - const isExpanded = expandedSkillIds.has(skill.name); - return ( -
-
toggleSkillExpanded(skill.name)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && toggleSkillExpanded(skill.name)} - > -
-
- {skill.name} - - {skill.level === 'user' ? t('list.item.user') : t('list.item.project')} - -
-

- {skill.description?.trim() || '—'} -

-
- -
- -
e.stopPropagation()} - > - -
-
- - {isExpanded && ( -
-
- {t('list.item.pathLabel')} - {skill.path} -
-
- )} -
- ); - })} -
- ); - }; - - return ( -
-
-
-
-
-

{t('installed.titleAll')}

-

{t('installed.subtitleAll')}

-
-
- loadSkills(true)} - tooltip={t('toolbar.refreshTooltip')} - > - - - setShowAddForm(v => !v)} - tooltip={t('toolbar.addTooltip')} - > - - -
-
- -
- {FILTERS.map(({ id, labelKey }) => ( - - ))} -
-
-
- -
-
- {showAddForm && ( -
-
-

{t('form.title')}

- - - -
-
- setFormPath(e.target.value)} - variant="outlined" - /> - - - -
-
{t('form.path.hint')}
- {isValidating && ( -
{t('form.validating')}
- )} - {validationResult && ( -
- {validationResult.valid ? ( - <> -
✓ {validationResult.name}
-
{validationResult.description}
- - ) : ( -
✗ {validationResult.error}
- )} -
- )} -
-
- - -
-
- )} - - {renderList()} -
-
- - setDeleteConfirm({ show: false, skill: null })} - onConfirm={confirmDelete} - title={t('deleteModal.title')} - message={ - <> -

{t('deleteModal.message', { name: deleteConfirm.skill?.name })}

-

{t('deleteModal.warning')}

- - } - type="warning" - confirmDanger - confirmText={t('deleteModal.delete')} - cancelText={t('deleteModal.cancel')} - /> -
- ); -}; - -export default InstalledView; diff --git a/src/web-ui/src/app/scenes/skills/views/MarketView.tsx b/src/web-ui/src/app/scenes/skills/views/MarketView.tsx deleted file mode 100644 index fa88a3d2..00000000 --- a/src/web-ui/src/app/scenes/skills/views/MarketView.tsx +++ /dev/null @@ -1,493 +0,0 @@ -/** - * MarketView — skill marketplace browser. - * Hero-centered search on first open; results appear after search. - * Loads one page at a time on demand. - */ - -import React, { useState, useCallback, useMemo, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - RefreshCw, Download, CheckCircle2, TrendingUp, Store, - ChevronLeft, ChevronRight, Search as SearchIcon, Loader2, -} from 'lucide-react'; -import { Search, Button, IconButton, Tooltip, Badge } from '@/component-library'; -import { configAPI } from '@/infrastructure/api'; -import { useCurrentWorkspace } from '@/infrastructure/hooks/useWorkspace'; -import { useNotification } from '@/shared/notification-system'; -import type { SkillMarketItem, SkillInfo } from '@/infrastructure/config/types'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('SkillsScene:MarketView'); - -const PAGE_SIZE = 10; -const MAX_TOTAL_SKILLS = 500; -const SKILLS_SOURCE_URL = 'https://skills.sh'; - -const MarketView: React.FC = () => { - const { t } = useTranslation('scenes/skills'); - - const [marketKeyword, setMarketKeyword] = useState(''); - const [marketSkills, setMarketSkills] = useState([]); - const [marketLoading, setMarketLoading] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [marketError, setMarketError] = useState(null); - const [downloadingPackage, setDownloadingPackage] = useState(null); - const [installedSkills, setInstalledSkills] = useState([]); - const [currentPage, setCurrentPage] = useState(0); - const [hasMore, setHasMore] = useState(true); - const [hasSearched, setHasSearched] = useState(false); - const [expandedItems, setExpandedItems] = useState>(new Set()); - - // Always holds the last submitted query so pagination uses the same query - const activeQueryRef = useRef(undefined); - - const { hasWorkspace } = useCurrentWorkspace(); - const notification = useNotification(); - - const toggleExpand = useCallback((installId: string) => { - setExpandedItems((prev) => { - const next = new Set(prev); - if (next.has(installId)) next.delete(installId); - else next.add(installId); - return next; - }); - }, []); - - /** Fetch up to `limit` items from the API for the given query. */ - const fetchSkills = useCallback( - async (query: string | undefined, limit: number): Promise => { - const normalized = query?.trim(); - return normalized - ? await configAPI.searchSkillMarket(normalized, limit) - : await configAPI.listSkillMarket(undefined, limit); - }, - [], - ); - - const loadInstalledSkills = useCallback(async () => { - try { - const list = await configAPI.getSkillConfigs(); - setInstalledSkills(list); - } catch { - // silent - } - }, []); - - /** Submit a search — resets all results state and loads the first page. */ - const doSearch = useCallback( - async (query?: string) => { - setHasSearched(true); - setMarketLoading(true); - setMarketError(null); - setCurrentPage(0); - setExpandedItems(new Set()); - activeQueryRef.current = query; - try { - const skillList = await fetchSkills(query, PAGE_SIZE); - setMarketSkills(skillList); - setHasMore(skillList.length >= PAGE_SIZE); - } catch (err) { - log.error('Failed to load skill market', err); - setMarketError(err instanceof Error ? err.message : String(err)); - } finally { - setMarketLoading(false); - } - }, - [fetchSkills], - ); - - const handleMarketSearch = useCallback(() => { - doSearch(marketKeyword || undefined); - loadInstalledSkills(); - }, [doSearch, loadInstalledSkills, marketKeyword]); - - const handleRefresh = useCallback(() => { - doSearch(activeQueryRef.current); - loadInstalledSkills(); - }, [doSearch, loadInstalledSkills]); - - const handleDownload = async (skill: SkillMarketItem) => { - if (!hasWorkspace) { - notification.warning(t('messages.noWorkspace')); - return; - } - try { - setDownloadingPackage(skill.installId); - const result = await configAPI.downloadSkillMarket(skill.installId, 'project'); - const installedName = result.installedSkills[0] ?? skill.name; - notification.success(t('messages.marketDownloadSuccess', { name: installedName })); - await loadInstalledSkills(); - } catch (err) { - notification.error( - t('messages.marketDownloadFailed', { - error: err instanceof Error ? err.message : String(err), - }), - ); - } finally { - setDownloadingPackage(null); - } - }; - - const installedSkillNames = useMemo( - () => new Set(installedSkills.map((s) => s.name)), - [installedSkills], - ); - - const displayMarketSkills = useMemo(() => { - const entries = marketSkills.map((skill, index) => ({ - skill, - index, - installed: installedSkillNames.has(skill.name), - })); - entries.sort((a, b) => { - if (a.installed !== b.installed) return a.installed ? -1 : 1; - const installDelta = (b.skill.installs ?? 0) - (a.skill.installs ?? 0); - if (installDelta !== 0) return installDelta; - return a.index - b.index; - }); - return entries.map((e) => e.skill); - }, [marketSkills, installedSkillNames]); - - const loadedPages = Math.ceil(displayMarketSkills.length / PAGE_SIZE); - const totalPages = hasMore ? loadedPages + 1 : Math.max(1, loadedPages); - const paginatedSkills = displayMarketSkills.slice( - currentPage * PAGE_SIZE, - (currentPage + 1) * PAGE_SIZE, - ); - - /** Navigate to next page. Immediately jumps so the skeleton appears on the target page. */ - const goToNextPage = useCallback(async () => { - const nextPage = currentPage + 1; - const neededCount = Math.min((nextPage + 1) * PAGE_SIZE, MAX_TOTAL_SKILLS); - - if (displayMarketSkills.length >= neededCount) { - setCurrentPage(nextPage); - return; - } - - if (!hasMore) return; - - setCurrentPage(nextPage); - - try { - setLoadingMore(true); - const skillList = await fetchSkills(activeQueryRef.current, neededCount); - setMarketSkills(skillList); - const hitCap = neededCount >= MAX_TOTAL_SKILLS; - setHasMore(!hitCap && skillList.length >= neededCount); - } catch (err) { - log.error('Failed to load more skills', err); - setCurrentPage(currentPage); - } finally { - setLoadingMore(false); - } - }, [currentPage, displayMarketSkills.length, hasMore, fetchSkills]); - - const goToPrevPage = useCallback(() => { - setCurrentPage((p) => Math.max(0, p - 1)); - }, []); - - // ── Source attribution note ──────────────────────────────── - - const sourceNote = ( -

- {t('market.sourceNote.prefix')} - {' '} - - skills.sh - - {t('market.sourceNote.suffix', { max: MAX_TOTAL_SKILLS })} -

- ); - - // ── Hero (pre-search) view ───────────────────────────────── - - if (!hasSearched) { - return ( -
-
-

{t('market.title')}

-
- setMarketKeyword(value)} - onSearch={handleMarketSearch} - clearable - size="medium" - prefixIcon={<>} - suffixContent={ - - } - /> -
- {sourceNote} -
-
- ); - } - - // ── Results view ─────────────────────────────────────────── - - const renderSkeletonList = () => ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))} -
- ); - - const showPagination = totalPages > 1 || hasMore; - - const paginationBar = showPagination ? ( -
- - - - - {hasMore - ? t('market.pagination.infoMore', { current: currentPage + 1 }) - : t('market.pagination.info', { current: currentPage + 1, total: totalPages })} - - = totalPages - 1) || loadingMore} - tooltip={loadingMore ? t('market.pagination.loading') : t('market.pagination.next')} - > - {loadingMore - ? - : } - -
- ) : null; - - const renderContent = () => { - if (marketLoading) { - return renderSkeletonList(); - } - - if (marketError) { - return ( -
- - {t('market.errorPrefix')}{marketError} -
- ); - } - - if (displayMarketSkills.length === 0) { - return ( -
- - {marketKeyword.trim() ? t('market.empty.noMatch') : t('market.empty.noSkills')} -
- ); - } - - if (loadingMore && paginatedSkills.length === 0) { - return ( - <> - {renderSkeletonList()} - {paginationBar} - - ); - } - - return ( - <> -
- {paginatedSkills.map((skill, index) => { - const isDownloading = downloadingPackage === skill.installId; - const isInstalled = installedSkillNames.has(skill.name); - const isExpanded = expandedItems.has(skill.installId); - const tooltipText = !hasWorkspace - ? t('messages.noWorkspace') - : isInstalled - ? t('market.item.installedTooltip') - : t('market.item.downloadProject'); - - return ( -
-
toggleExpand(skill.installId)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && toggleExpand(skill.installId)} - > -
-
- {skill.name} - {isInstalled && ( - - - {t('market.item.installed')} - - )} -
-

- {skill.description?.trim() || t('market.item.noDescription')} -

-
- -
- - - {skill.installs ?? 0} - -
- -
e.stopPropagation()} - > - - - - - -
-
- - {isExpanded && ( -
- {skill.description?.trim() && ( -

- {skill.description.trim()} -

- )} - {skill.source && ( -
- - {t('market.item.sourceLabel')} - - {skill.source} -
- )} -
- - {t('market.item.installs', { count: skill.installs ?? 0 })} - -
-
- )} -
- ); - })} -
- - {paginationBar} - - ); - }; - - return ( -
-
-
-
-
-

{t('market.title')}

-

{t('market.subtitle')}

-
- - - -
-
- setMarketKeyword(value)} - onSearch={handleMarketSearch} - clearable - size="small" - prefixIcon={<>} - suffixContent={ - - } - /> -
-
-
-
-
- {renderContent()} - {sourceNote} -
-
-
- ); -}; - -export default MarketView; diff --git a/src/web-ui/src/app/scenes/skills/views/index.ts b/src/web-ui/src/app/scenes/skills/views/index.ts deleted file mode 100644 index 3777b1da..00000000 --- a/src/web-ui/src/app/scenes/skills/views/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as MarketView } from './MarketView'; -export { default as InstalledView } from './InstalledView'; diff --git a/src/web-ui/src/app/scenes/team/TeamScene.scss b/src/web-ui/src/app/scenes/team/TeamScene.scss deleted file mode 100644 index cca3137e..00000000 --- a/src/web-ui/src/app/scenes/team/TeamScene.scss +++ /dev/null @@ -1,12 +0,0 @@ -.bitfun-team-scene { - display: flex; - flex-direction: column; - flex: 1; - width: 100%; - min-width: 0; - min-height: 0; - height: 100%; - overflow-y: auto; - overflow-x: hidden; - box-sizing: border-box; -} diff --git a/src/web-ui/src/app/scenes/team/TeamScene.tsx b/src/web-ui/src/app/scenes/team/TeamScene.tsx deleted file mode 100644 index 2336d1e0..00000000 --- a/src/web-ui/src/app/scenes/team/TeamScene.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import TeamView from './TeamView'; -import './TeamScene.scss'; - -const TeamScene: React.FC = () => ( -
- -
-); - -export default TeamScene; diff --git a/src/web-ui/src/app/scenes/team/TeamView.tsx b/src/web-ui/src/app/scenes/team/TeamView.tsx deleted file mode 100644 index b84a0908..00000000 --- a/src/web-ui/src/app/scenes/team/TeamView.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import { ArrowLeft } from 'lucide-react'; -import { useTeamStore } from './teamStore'; -import AgentsOverviewPage from './components/AgentsOverviewPage'; -import CreateAgentPage from './components/CreateAgentPage'; -import ExpertTeamsPage from './components/ExpertTeamsPage'; -import TeamTabBar from './components/TeamTabBar'; -import AgentGallery from './components/AgentGallery'; -import TeamComposer from './components/TeamComposer'; -import CapabilityBar from './components/CapabilityBar'; -import './TeamView.scss'; - -const TeamEditorView: React.FC = () => { - const { openExpertTeamsOverview } = useTeamStore(); - - return ( -
-
- -
- - - -
- - -
- -
-
- - -
- ); -}; - -const TeamView: React.FC = () => { - const { page } = useTeamStore(); - - if (page === 'editor') return ; - if (page === 'expertTeamsOverview') return ; - if (page === 'createAgent') return ; - - return ; -}; - -export default TeamView; diff --git a/src/web-ui/src/app/scenes/team/components/AgentsOverviewPage.tsx b/src/web-ui/src/app/scenes/team/components/AgentsOverviewPage.tsx deleted file mode 100644 index 01d571c7..00000000 --- a/src/web-ui/src/app/scenes/team/components/AgentsOverviewPage.tsx +++ /dev/null @@ -1,606 +0,0 @@ -import React, { useState, useCallback, useEffect } from 'react'; -import { Bot, SlidersHorizontal, Wrench, RotateCcw, Pencil, X, Plus, Puzzle } from 'lucide-react'; - -import { useTranslation } from 'react-i18next'; -import { Search, Switch, IconButton, Badge } from '@/component-library'; -import { - useTeamStore, - type AgentWithCapabilities, - type AgentKind, -} from '../teamStore'; -import { CAPABILITY_ACCENT } from '../teamIcons'; -import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; -import { SubagentAPI } from '@/infrastructure/api/service-api/SubagentAPI'; -import type { SubagentSource } from '@/infrastructure/api/service-api/SubagentAPI'; -import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; -import type { ModeConfigItem, SkillInfo } from '@/infrastructure/config/types'; -import { useNotification } from '@/shared/notification-system'; -import './TeamHomePage.scss'; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -interface ToolInfo { - name: string; - description: string; - is_readonly: boolean; -} - -// ─── Agent badge ────────────────────────────────────────────────────────────── - -interface AgentBadgeConfig { - variant: 'accent' | 'info' | 'success' | 'purple' | 'neutral'; - label: string; -} - -function getAgentBadge(agentKind?: AgentKind, source?: SubagentSource): AgentBadgeConfig { - if (agentKind === 'mode') { - return { variant: 'accent', label: 'Agent' }; - } - switch (source) { - case 'user': return { variant: 'success', label: '用户 Sub-Agent' }; - case 'project': return { variant: 'purple', label: '项目 Sub-Agent' }; - default: return { variant: 'info', label: 'Sub-Agent' }; - } -} - -// ─── Enrich capabilities ────────────────────────────────────────────────────── - -function enrichCapabilities(agent: AgentWithCapabilities): AgentWithCapabilities { - if (agent.capabilities?.length) return agent; - const id = agent.id.toLowerCase(); - const name = agent.name.toLowerCase(); - - if (agent.agentKind === 'mode') { - if (id === 'agentic') return { ...agent, capabilities: [{ category: '编码', level: 5 }, { category: '分析', level: 4 }] }; - if (id === 'plan') return { ...agent, capabilities: [{ category: '分析', level: 5 }, { category: '文档', level: 3 }] }; - if (id === 'debug') return { ...agent, capabilities: [{ category: '编码', level: 5 }, { category: '分析', level: 3 }] }; - if (id === 'cowork') return { ...agent, capabilities: [{ category: '分析', level: 4 }, { category: '创意', level: 3 }] }; - } - - if (id === 'explore') return { ...agent, capabilities: [{ category: '分析', level: 4 }, { category: '编码', level: 3 }] }; - if (id === 'file_finder') return { ...agent, capabilities: [{ category: '分析', level: 3 }, { category: '编码', level: 2 }] }; - - if (name.includes('code') || name.includes('debug') || name.includes('test')) { - return { ...agent, capabilities: [{ category: '编码', level: 4 }] }; - } - if (name.includes('doc') || name.includes('write')) { - return { ...agent, capabilities: [{ category: '文档', level: 4 }] }; - } - return { ...agent, capabilities: [{ category: '分析', level: 3 }] }; -} - -// ─── Agent list item ────────────────────────────────────────────────────────── - -const AgentListItem: React.FC<{ - agent: AgentWithCapabilities; - soloEnabled: boolean; - onToggleSolo: (agentId: string, enabled: boolean) => void; - index: number; - availableTools: ToolInfo[]; - modeConfig: ModeConfigItem | null; - onToggleTool: (agentId: string, toolName: string) => Promise; - onResetTools: (agentId: string) => Promise; - availableSkills: SkillInfo[]; - onToggleSkill: (agentId: string, skillName: string) => Promise; -}> = ({ agent, soloEnabled, onToggleSolo, index, availableTools, modeConfig, onToggleTool, onResetTools, availableSkills, onToggleSkill }) => { - const { t } = useTranslation('scenes/team'); - const [expanded, setExpanded] = useState(false); - const [toolsEditing, setToolsEditing] = useState(false); - const [skillsEditing, setSkillsEditing] = useState(false); - - const toggleExpand = useCallback(() => setExpanded((v) => !v), []); - - const badge = getAgentBadge(agent.agentKind, agent.subagentSource); - - const isMode = agent.agentKind === 'mode'; - const enabledTools = modeConfig?.available_tools ?? agent.defaultTools ?? []; - const totalTools = isMode ? availableTools.length : (agent.defaultTools?.length ?? 0); - const enabledSkills = modeConfig?.available_skills ?? []; - const enabledSkillsDisplay = availableSkills.filter((s) => enabledSkills.includes(s.name)); - - return ( -
-
e.key === 'Enter' && toggleExpand()} - > -
-
- {agent.name} - {badge.label} - {agent.model && ( - {agent.model} - )} -
-

{agent.description}

-
- -
- {agent.capabilities.slice(0, 3).map((cap) => ( - - {cap.category} - - ))} -
- -
e.stopPropagation()}> - onToggleSolo(agent.id, !soloEnabled)} - size="small" - /> - - - -
-
- - {expanded && ( -
-

{agent.description}

- -
- {agent.capabilities.map((cap) => ( -
- - {cap.category} - -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- {cap.level}/5 -
- ))} -
- - {totalTools > 0 && ( -
-
- - {t('agentsOverview.tools', '工具')} - - {isMode ? `${enabledTools.length}/${totalTools}` : totalTools} - - - {isMode && ( -
e.stopPropagation()}> - {toolsEditing ? ( - <> - onResetTools(agent.id)} - > - - - setToolsEditing(false)} - > - - - - ) : ( - setToolsEditing(true)} - > - - - )} -
- )} -
- - {isMode && toolsEditing && ( -
e.stopPropagation()}> - {[...availableTools] - .sort((a, b) => { - const aOn = enabledTools.includes(a.name); - const bOn = enabledTools.includes(b.name); - if (aOn && !bOn) return -1; - if (!aOn && bOn) return 1; - return 0; - }) - .map((tool) => { - const isOn = enabledTools.includes(tool.name); - return ( - - ); - })} -
- )} - - {isMode && !toolsEditing && ( -
- {enabledTools.map((tool) => ( - - {tool.replace(/_/g, ' ')} - - ))} -
- )} - - {!isMode && ( -
- {(agent.defaultTools ?? []).map((tool) => ( - - {tool.replace(/_/g, ' ')} - - ))} -
- )} -
- )} - - {isMode && availableSkills.length > 0 && ( -
-
- - {t('agentsOverview.skills', 'Skills')} - - {`${enabledSkills.length}/${availableSkills.length}`} - -
e.stopPropagation()}> - {skillsEditing ? ( - setSkillsEditing(false)} - > - - - ) : ( - setSkillsEditing(true)} - > - - - )} -
-
- - {skillsEditing && ( -
e.stopPropagation()}> - {[...availableSkills] - .sort((a, b) => { - const aOn = enabledSkills.includes(a.name); - const bOn = enabledSkills.includes(b.name); - if (aOn && !bOn) return -1; - if (!aOn && bOn) return 1; - return 0; - }) - .map((skill) => { - const isOn = enabledSkills.includes(skill.name); - return ( - - ); - })} -
- )} - - {!skillsEditing && ( -
- {enabledSkillsDisplay.length === 0 ? ( - {t('agentsOverview.noSkills', '未启用任何 Skill')} - ) : ( - enabledSkillsDisplay.map((skill) => ( - - {skill.name} - - )) - )} -
- )} -
- )} -
- )} -
- ); -}; - -// ─── Filter types ───────────────────────────────────────────────────────────── - -type FilterLevel = 'all' | 'builtin' | 'user' | 'project'; -type FilterType = 'all' | 'mode' | 'subagent'; - -// ─── Page ───────────────────────────────────────────────────────────────────── - -const AgentsOverviewPage: React.FC = () => { - const { t } = useTranslation('scenes/team'); - const { agentSoloEnabled, setAgentSoloEnabled, openCreateAgent } = useTeamStore(); - const notification = useNotification(); - const [query, setQuery] = useState(''); - const [filterLevel, setFilterLevel] = useState('all'); - const [filterType, setFilterType] = useState('all'); - const [allAgents, setAllAgents] = useState([]); - const [loading, setLoading] = useState(true); - const [availableTools, setAvailableTools] = useState([]); - const [availableSkills, setAvailableSkills] = useState([]); - const [modeConfigs, setModeConfigs] = useState>({}); - - const loadAgents = useCallback(async () => { - setLoading(true); - const fetchTools = async (): Promise => { - try { - const { invoke } = await import('@tauri-apps/api/core'); - return await invoke('get_all_tools_info'); - } catch { - return []; - } - }; - try { - const [modes, subagents, tools, configs, skills] = await Promise.all([ - agentAPI.getAvailableModes().catch(() => []), - SubagentAPI.listSubagents().catch(() => []), - fetchTools(), - configAPI.getModeConfigs().catch(() => ({})), - configAPI.getSkillConfigs().catch(() => []), - ]); - const modeAgents: AgentWithCapabilities[] = modes.map((m) => - enrichCapabilities({ - id: m.id, name: m.name, description: m.description, - isReadonly: m.isReadonly, toolCount: m.toolCount, - defaultTools: m.defaultTools ?? [], enabled: m.enabled, - capabilities: [], agentKind: 'mode', - }) - ); - const subAgents: AgentWithCapabilities[] = subagents.map((s) => - enrichCapabilities({ ...s, capabilities: [], agentKind: 'subagent' }) - ); - setAllAgents([...modeAgents, ...subAgents]); - setAvailableTools(tools); - setAvailableSkills(skills.filter((s: SkillInfo) => s.enabled)); - setModeConfigs(configs as Record); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { loadAgents(); }, [loadAgents]); - - const getModeConfig = useCallback((agentId: string): ModeConfigItem | null => { - const agent = allAgents.find((a) => a.id === agentId && a.agentKind === 'mode'); - if (!agent) return null; - const userConfig = modeConfigs[agentId]; - const defaultTools = agent.defaultTools ?? []; - if (!userConfig) { - return { mode_id: agentId, available_tools: defaultTools, enabled: true, default_tools: defaultTools }; - } - if (!userConfig.available_tools || userConfig.available_tools.length === 0) { - return { ...userConfig, available_tools: defaultTools, default_tools: defaultTools }; - } - return { ...userConfig, default_tools: userConfig.default_tools ?? defaultTools }; - }, [allAgents, modeConfigs]); - - const saveModeConfig = useCallback(async (agentId: string, updates: Partial) => { - const config = getModeConfig(agentId); - if (!config) return; - const updated = { ...config, ...updates }; - await configAPI.setModeConfig(agentId, updated); - setModeConfigs((prev) => ({ ...prev, [agentId]: updated })); - try { - const { globalEventBus } = await import('@/infrastructure/event-bus'); - globalEventBus.emit('mode:config:updated'); - } catch { /* ignore */ } - }, [getModeConfig]); - - const handleToggleTool = useCallback(async (agentId: string, toolName: string) => { - const config = getModeConfig(agentId); - if (!config) return; - const tools = config.available_tools ?? []; - const isEnabling = !tools.includes(toolName); - const newTools = isEnabling ? [...tools, toolName] : tools.filter((t) => t !== toolName); - try { - await saveModeConfig(agentId, { available_tools: newTools }); - } catch { - notification.error(t('agentsOverview.toolToggleFailed', '工具切换失败')); - } - }, [getModeConfig, saveModeConfig, notification, t]); - - const handleResetTools = useCallback(async (agentId: string) => { - try { - await configAPI.resetModeConfig(agentId); - const updated = await configAPI.getModeConfigs(); - setModeConfigs(updated as Record); - notification.success(t('agentsOverview.toolsResetSuccess', '已重置为默认工具')); - try { - const { globalEventBus } = await import('@/infrastructure/event-bus'); - globalEventBus.emit('mode:config:updated'); - } catch { /* ignore */ } - } catch { - notification.error(t('agentsOverview.toolToggleFailed', '重置失败')); - } - }, [notification, t]); - - const handleToggleSkill = useCallback(async (agentId: string, skillName: string) => { - const config = getModeConfig(agentId); - if (!config) return; - const skills = config.available_skills ?? []; - const isEnabling = !skills.includes(skillName); - const newSkills = isEnabling ? [...skills, skillName] : skills.filter((s) => s !== skillName); - try { - await saveModeConfig(agentId, { available_skills: newSkills }); - } catch { - notification.error(t('agentsOverview.skillToggleFailed', 'Skill 切换失败')); - } - }, [getModeConfig, saveModeConfig, notification, t]); - - const filteredAgents = allAgents.filter((a) => { - if (query) { - const q = query.toLowerCase(); - if (!a.name.toLowerCase().includes(q) && !a.description.toLowerCase().includes(q)) return false; - } - if (filterType !== 'all') { - if (filterType === 'mode' && a.agentKind !== 'mode') return false; - if (filterType === 'subagent' && a.agentKind !== 'subagent') return false; - } - // mode agents always map to 'builtin' level - if (filterLevel !== 'all') { - const level = a.agentKind === 'mode' ? 'builtin' : (a.subagentSource ?? 'builtin'); - if (level !== filterLevel) return false; - } - return true; - }); - - const LEVEL_FILTERS: { key: FilterLevel; label: string }[] = [ - { key: 'all', label: t('agentsOverview.filterAll', '全部') }, - { key: 'builtin', label: t('agentsOverview.filterBuiltin', '内置') }, - { key: 'user', label: t('agentsOverview.filterUser', '用户') }, - { key: 'project', label: t('agentsOverview.filterProject', '项目') }, - ]; - - const TYPE_FILTERS: { key: FilterType; label: string }[] = [ - { key: 'all', label: t('agentsOverview.filterAll', '全部') }, - { key: 'mode', label: 'Agent' }, - { key: 'subagent', label: 'Sub-Agent' }, - ]; - - return ( -
-
-
-
-
-

{t('agentsOverview.title')}

-

{t('agentsOverview.subtitle')}

-
- -
-
- -
-
-
- -
-
-
- {t('agentsOverview.sectionTitle')} - {filteredAgents.length} -
-
- {LEVEL_FILTERS.map(({ key, label }) => ( - - ))} -
-
-
- {TYPE_FILTERS.map(({ key, label }) => ( - - ))} -
-
-
- {loading ? ( -
- - {t('loading', '加载中…')} -
- ) : filteredAgents.length === 0 ? ( -
- - {t('empty')} -
- ) : ( -
- {filteredAgents.map((a, i) => ( - - ))} -
- )} -
-
-
- ); -}; - -export default AgentsOverviewPage; diff --git a/src/web-ui/src/app/scenes/team/components/ExpertTeamsPage.tsx b/src/web-ui/src/app/scenes/team/components/ExpertTeamsPage.tsx deleted file mode 100644 index bc13fcfc..00000000 --- a/src/web-ui/src/app/scenes/team/components/ExpertTeamsPage.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React, { useState } from 'react'; -import { Bot, Users, Plus, Pencil } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { Search, IconButton, Badge } from '@/component-library'; -import { - useTeamStore, - MOCK_AGENTS, - MOCK_TEAMS, - CAPABILITY_CATEGORIES, - computeTeamCapabilities, - type AgentWithCapabilities, - type Team, -} from '../teamStore'; - -const EXAMPLE_TEAM_IDS = new Set(MOCK_TEAMS.map((t) => t.id)); -import { AGENT_ICON_MAP } from '../teamIcons'; -import './TeamHomePage.scss'; - -// ─── Team list item ─────────────────────────────────────────────────────────── - -const TeamListItem: React.FC<{ team: Team; index: number; isExample: boolean }> = ({ team, index, isExample }) => { - const { t } = useTranslation('scenes/team'); - const { openTeamEditor } = useTeamStore(); - const [expanded, setExpanded] = useState(false); - - const caps = computeTeamCapabilities(team, MOCK_AGENTS); - const topCaps = CAPABILITY_CATEGORIES - .filter((c) => caps[c] > 0) - .sort((a, b) => caps[b] - caps[a]) - .slice(0, 3); - - const memberAgents = team.members - .map((m) => MOCK_AGENTS.find((a) => a.id === m.agentId)) - .filter(Boolean) as AgentWithCapabilities[]; - - const strategyLabel = - team.strategy === 'collaborative' - ? t('home.strategyCollab') - : team.strategy === 'sequential' - ? t('home.strategySeq') - : t('home.strategyFree'); - - return ( -
-
setExpanded((v) => !v)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && setExpanded((v) => !v)} - > -
-
- {team.name} - {isExample && 示例} - {strategyLabel} -
-

{team.description || '—'}

-
- -
-
- {memberAgents.slice(0, 4).map((a) => { - const ik = (a.iconKey ?? 'bot') as keyof typeof AGENT_ICON_MAP; - const IC = AGENT_ICON_MAP[ik] ?? Bot; - return ( - - - - ); - })} - {team.members.length > 4 && ( - - +{team.members.length - 4} - - )} -
- - {t('home.members', { count: team.members.length })} - -
- -
e.stopPropagation()}> - openTeamEditor(team.id)} - > - - -
-
- - {expanded && ( -
- {team.description && ( -

{team.description}

- )} - {memberAgents.length > 0 && ( -
- 成员 -
- {memberAgents.map((a) => { - const ik = (a.iconKey ?? 'bot') as keyof typeof AGENT_ICON_MAP; - const IC = AGENT_ICON_MAP[ik] ?? Bot; - const role = team.members.find((m) => m.agentId === a.id)?.role ?? 'member'; - const roleLabel = - role === 'leader' - ? t('composer.role.leader') - : role === 'reviewer' - ? t('composer.role.reviewer') - : t('composer.role.member'); - return ( - - - {a.name} - {roleLabel} - - ); - })} -
-
- )} - {topCaps.length > 0 && ( -
- 能力 -
- {topCaps.map((c) => ( - - {c} - - ))} -
-
- )} -
- )} -
- ); -}; - -// ─── Page ───────────────────────────────────────────────────────────────────── - -const ExpertTeamsPage: React.FC = () => { - const { t } = useTranslation('scenes/team'); - const { teams, addTeam, openTeamEditor } = useTeamStore(); - const [query, setQuery] = useState(''); - - const filteredTeams = teams.filter((team) => { - if (!query) return true; - const q = query.toLowerCase(); - return team.name.toLowerCase().includes(q) || (team.description?.toLowerCase().includes(q)); - }); - - const handleCreateTeam = () => { - const id = `team-${Date.now()}`; - addTeam({ id, name: '新团队', icon: 'users', description: '', strategy: 'collaborative', shareContext: true }); - openTeamEditor(id); - }; - - return ( -
-
-
-
-
-
-

{t('expertTeams.title')}

- WIP -
-

{t('expertTeams.subtitle')}

-
- -
-
- -
-
-
- -
-
-
- {t('expertTeams.sectionTitle')} - {filteredTeams.length} -
- {filteredTeams.length === 0 ? ( -
- - {t('empty')} -
- ) : ( -
- {filteredTeams.map((team, i) => ( - - ))} -
- )} -
-
-
- ); -}; - -export default ExpertTeamsPage; diff --git a/src/web-ui/src/app/scenes/team/components/TeamHomePage.scss b/src/web-ui/src/app/scenes/team/components/TeamHomePage.scss deleted file mode 100644 index f4d3d7b6..00000000 --- a/src/web-ui/src/app/scenes/team/components/TeamHomePage.scss +++ /dev/null @@ -1,879 +0,0 @@ -@use '../../../../component-library/styles/tokens' as *; - -// ─── Page enter animation ───────────────────────────────────────────────────── - -@keyframes th-page-reveal { - from { opacity: 0; } - to { opacity: 1; } -} - -@media (prefers-reduced-motion: reduce) { - .th { animation: none !important; } -} - -// ─── Team overview page shell ───────────────────────────────────────────────── - -.th { - --th-bg-page: var(--color-bg-scene); - --th-bg-subtle: var(--element-bg-subtle); - --th-bg-soft: var(--element-bg-soft); - --th-bg-card: var(--element-bg-base); - --th-text-main: var(--color-text-primary); - --th-text-secondary: var(--color-text-secondary); - --th-text-muted: var(--color-text-muted); - --th-text-disabled: var(--color-text-disabled); - --th-border: var(--border-subtle); - --th-border-strong: var(--border-medium); - --th-accent-bg: var(--color-accent-100); - --th-accent-bg-strong: var(--color-accent-200); - --th-accent-text: var(--color-accent-700); - --th-accent-border: var(--border-accent-subtle); - --th-accent-border-strong: var(--border-accent); - - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; - animation: th-page-reveal 0.32s $easing-decelerate both; -} - -// ── Header (mirrors bitfun-skills-scene__view-header) ──────────────────────── - -.th__header { - flex-shrink: 0; - padding: $size-gap-5 clamp(16px, 2.2vw, 28px) $size-gap-4; -} - -// Centered content wrapper matching bitfun-skills-scene__view-header-inner -.th__header-inner { - width: min(100%, 600px); - margin-inline: auto; -} - -// Title row: title+subtitle left, action button right -// Mirrors bitfun-skills-scene__view-title-row -.th__title-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: $size-gap-3; - margin-bottom: $size-gap-3; -} - -.th__title-with-badge { - display: flex; - align-items: center; - gap: $size-gap-2; - margin-bottom: 4px; -} - -.th__title { - font-size: $font-size-xl; - font-weight: $font-weight-semibold; - color: var(--th-text-main); - margin: 0; - line-height: $line-height-tight; -} - -.th__title-sub { - font-size: $font-size-sm; - color: var(--th-text-muted); - margin: 0; - line-height: $line-height-relaxed; -} - -// ── Filter bar (inside section-head) ───────────────────────────────────────── - -.th__filters { - display: flex; - align-items: center; - gap: $size-gap-2; - margin-left: auto; - flex-wrap: wrap; -} - -.th__filter-group { - display: flex; - align-items: center; - gap: 2px; - padding: 2px; - background: var(--element-bg-subtle); - border: 1px solid var(--border-subtle); - border-radius: $size-radius-base; -} - -.th__filter-sep { - width: 1px; - height: 18px; - background: var(--border-subtle); - flex-shrink: 0; -} - -.th__filter-chip { - display: inline-flex; - align-items: center; - height: 22px; - padding: 0 $size-gap-3; - border: none; - border-radius: calc(#{$size-radius-base} - 2px); - background: transparent; - font-size: $font-size-xs; - font-weight: $font-weight-medium; - color: var(--color-text-secondary); - cursor: pointer; - white-space: nowrap; - transition: background $motion-fast $easing-standard, - color $motion-fast $easing-standard; - - &:hover { - background: var(--element-bg-medium); - color: var(--color-text-primary); - } - - &.is-active { - background: var(--element-bg-base); - color: var(--color-text-primary); - font-weight: $font-weight-semibold; - box-shadow: 0 1px 2px rgba(0 0 0 / 0.08); - } -} - -// Toolbar row (search bar) below title -// Mirrors bitfun-skills-scene__market-toolbar exactly -.th__toolbar { - .search__wrapper { - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.06) 0%, - rgba(255, 255, 255, 0.03) 100% - ); - border: 1px solid var(--border-subtle); - border-radius: $size-radius-lg; - } -} - -// Create team button (in title-row right side) -.th__create-btn { - display: inline-flex; - align-items: center; - gap: $size-gap-2; - height: 30px; - padding: 0 $size-gap-3; - border: 1px solid var(--th-accent-border-strong); - border-radius: $size-radius-sm; - background: rgba(var(--color-primary-rgb, 99, 102, 241), 0.08); - color: var(--th-accent-text); - font-size: $font-size-xs; - font-family: $font-family-sans; - font-weight: $font-weight-medium; - cursor: pointer; - white-space: nowrap; - flex-shrink: 0; - transition: background $motion-fast $easing-standard, - border-color $motion-fast $easing-standard; - - &:hover { - background: rgba(var(--color-primary-rgb, 99, 102, 241), 0.14); - border-color: var(--color-primary); - } - - &:focus-visible { - outline: 2px solid var(--color-accent-500); - outline-offset: -1px; - } -} - -// ── Scrollable list body ─────────────────────────────────────────────────────── - -.th__list-body { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding: $size-gap-5 clamp(16px, 2.2vw, 28px); - - &::-webkit-scrollbar { width: 4px; } - &::-webkit-scrollbar-track { background: transparent; } - &::-webkit-scrollbar-thumb { - background: var(--border-subtle); - border-radius: 2px; - } -} - -// Centered content wrapper (mirrors bitfun-skills-scene__view-content-inner) -.th__list-inner { - width: min(100%, 600px); - margin-inline: auto; - display: flex; - flex-direction: column; - gap: $size-gap-4; -} - -// ── Section header ──────────────────────────────────────────────────────────── - -.th-list__section-head { - display: flex; - align-items: center; - gap: $size-gap-3; - margin-bottom: $size-gap-2; -} - -.th-list__section-title { - font-size: $font-size-xs; - color: var(--color-text-secondary); - font-weight: $font-weight-semibold; - letter-spacing: 0.3px; - text-transform: uppercase; -} - -.th-list__section-count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 5px; - border-radius: $size-radius-full; - background: var(--element-bg-medium); - border: 1px solid var(--border-subtle); - font-size: 10px; - font-weight: $font-weight-semibold; - color: var(--color-text-muted); - line-height: 1; -} - -// ── List container (mirrors .bitfun-market__list) ───────────────────────────── - -.th-list { - border: 1px solid var(--border-subtle); - border-radius: $size-radius-base; - overflow: hidden; -} - -// ── List item (mirrors .bitfun-market__list-item) ───────────────────────────── - -.th-list__item { - position: relative; - border-bottom: 1px solid var(--border-subtle); - animation: th-list-item-in 0.28s $easing-decelerate both; - animation-delay: calc(var(--item-index, 0) * 40ms); - - &:last-child { - border-bottom: none; - } -} - -// ── Row (mirrors .bitfun-market__list-item-row) ─────────────────────────────── - -.th-list__item-row { - display: flex; - align-items: center; - gap: $size-gap-3; - padding: $size-gap-3 $size-gap-4; - cursor: pointer; - transition: background $motion-fast $easing-standard; - outline: none; - - &:hover { - background: var(--element-bg-soft); - } - - &:focus-visible { - outline: 2px solid var(--color-accent-500); - outline-offset: -2px; - } -} - -// ── Info column (mirrors .bitfun-market__list-item-info) ────────────────────── - -.th-list__item-info { - flex: 1; - min-width: 0; -} - -.th-list__item-name-row { - display: flex; - align-items: center; - gap: $size-gap-2; - flex-wrap: nowrap; - min-width: 0; -} - -.th-list__item-name { - font-size: $font-size-sm; - font-weight: $font-weight-semibold; - color: var(--color-text-primary); - line-height: $line-height-tight; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.th-list__item-desc { - font-size: $font-size-xs; - color: var(--color-text-secondary); - margin: 3px 0 0; - line-height: $line-height-base; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -// ── Meta column ─────────────────────────────────────────────────────────────── - -.th-list__item-meta { - display: flex; - align-items: center; - gap: $size-gap-2; - flex-shrink: 0; -} - -// ── Action column ───────────────────────────────────────────────────────────── - -.th-list__item-action { - display: flex; - align-items: center; - gap: $size-gap-2; - flex-shrink: 0; -} - -// ── Badges (mirrors .bitfun-market__badge) ──────────────────────────────────── - -.th-list__badge { - display: inline-flex; - align-items: center; - gap: 3px; - padding: $badge-padding-y $badge-padding-x; - border-radius: $size-radius-full; - font-size: $badge-font-size; - font-weight: $badge-font-weight; - line-height: 1; - border: none; - white-space: nowrap; - flex-shrink: 0; - - &--type { - background: var(--element-bg-base); - color: var(--color-text-secondary); - } - - &--model { - background: var(--element-bg-base); - color: var(--color-text-secondary); - font-size: 10px; - } - - &--team { - background: var(--color-accent-200); - color: var(--color-accent-500); - } - - &--strategy { - background: var(--element-bg-base); - color: var(--color-text-secondary); - font-size: 10px; - } -} - -// ── Capability chips ────────────────────────────────────────────────────────── - -.th-list__cap-chip { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: $size-radius-full; - font-size: 10px; - font-weight: $font-weight-medium; - white-space: nowrap; - border: none; - background: var(--element-bg-medium); -} - -// ── Member avatars ──────────────────────────────────────────────────────────── - -.th-list__avatars { - display: flex; - - > * + * { margin-left: -4px; } -} - -.th-list__avatar { - width: 18px; - height: 18px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 50%; - border: 1px solid var(--border-subtle); - background: var(--element-bg-base); - flex-shrink: 0; - color: var(--color-text-muted); - - &--more { - font-size: 9px; - font-weight: $font-weight-semibold; - color: var(--color-text-muted); - } -} - -.th-list__member-count { - font-size: 10px; - color: var(--color-text-muted); - white-space: nowrap; - flex-shrink: 0; -} - -// ── Expanded details (mirrors .bitfun-market__list-item-details) ────────────── - -.th-list__item-details { - padding: $size-gap-3 $size-gap-4 $size-gap-4; - border-top: 1px solid var(--border-subtle); - background: var(--element-bg-subtle); - display: flex; - flex-direction: column; - gap: 0; - - & > * + * { - margin-top: $size-gap-3; - border-top: 1px dashed var(--border-subtle); - padding-top: $size-gap-3; - } -} - -.th-list__detail-desc { - font-size: $font-size-sm; - color: var(--color-text-secondary); - line-height: $line-height-relaxed; - margin: 0; -} - -.th-list__detail-row { - display: flex; - align-items: flex-start; - gap: $size-gap-3; -} - -.th-list__detail-label { - flex-shrink: 0; - font-size: $font-size-xs; - font-weight: $font-weight-medium; - color: var(--color-text-secondary); - min-width: 32px; - padding-top: 2px; -} - -// ── Capability grid (in agent expanded view) ────────────────────────────────── - -.th-list__cap-grid { - display: flex; - flex-direction: column; - gap: $size-gap-2; -} - -.th-list__cap-row { - display: flex; - align-items: center; - gap: $size-gap-3; -} - -.th-list__cap-label { - font-size: $font-size-xs; - font-weight: $font-weight-medium; - min-width: 28px; -} - -.th-list__cap-bar { - display: flex; - gap: 3px; -} - -.th-list__cap-pip { - width: 8px; - height: 8px; - border-radius: 2px; - background: var(--element-bg-medium); -} - -.th-list__cap-level { - font-size: 10px; - color: var(--color-text-muted); - min-width: 24px; -} - -// ── Member list (in team expanded view) ─────────────────────────────────────── - -.th-list__member-list { - display: flex; - flex-wrap: wrap; - gap: $size-gap-2; - flex: 1; -} - -.th-list__member-chip { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 3px 8px 3px 6px; - border: none; - border-radius: $size-radius-sm; - background: var(--element-bg-medium); - font-size: $font-size-xs; - color: var(--color-text-primary); -} - -.th-list__member-role { - font-size: 10px; - color: var(--color-text-muted); - padding: 0 4px; - border-radius: 2px; - background: var(--element-bg-medium); - margin-left: 2px; -} - -.th-list__cap-chips { - display: flex; - flex-wrap: wrap; - gap: $size-gap-2; - flex: 1; -} - -// ── Tools section (in agent expanded view) ──────────────────────────────────── - -.th-list__tools-section { - display: flex; - flex-direction: column; - gap: $size-gap-2; -} - -.th-list__tools-header { - display: flex; - align-items: center; - gap: $size-gap-2; - color: var(--color-text-secondary); -} - -.th-list__tools-label { - font-size: $font-size-xs; - font-weight: $font-weight-semibold; - letter-spacing: 0.2px; - color: var(--color-text-secondary); -} - -.th-list__tools-count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 16px; - height: 16px; - padding: 0 4px; - border-radius: $size-radius-full; - background: var(--element-bg-medium); - border: 1px solid var(--border-subtle); - font-size: 10px; - font-weight: $font-weight-semibold; - color: var(--color-text-muted); - line-height: 1; -} - -.th-list__tools-actions { - display: inline-flex; - align-items: center; - gap: 2px; - margin-left: auto; -} - -.th-list__tools-grid { - display: flex; - flex-wrap: wrap; - gap: $size-gap-2; -} - -.th-list__tools-empty { - font-size: 10px; - color: var(--color-text-tertiary); - font-style: italic; - opacity: 0.6; -} - -.th-list__tool-chip { - display: inline-flex; - align-items: center; - padding: 2px 7px; - border-radius: $size-radius-sm; - background: var(--element-bg-base); - border: 1px solid var(--border-subtle); - font-size: 10px; - font-weight: $font-weight-medium; - color: var(--color-text-secondary); - font-family: $font-family-mono; - white-space: nowrap; - max-width: 140px; - overflow: hidden; - text-overflow: ellipsis; - -} - -.th-list__tools-panel { - display: flex; - flex-wrap: wrap; - gap: $size-gap-1; - padding: $size-gap-2; - background: var(--element-bg-subtle); - border-radius: $size-radius-base; - border: 1px solid var(--border-subtle); - max-height: 200px; - overflow-y: auto; - - &::-webkit-scrollbar { width: 4px; } - &::-webkit-scrollbar-track { background: transparent; } - &::-webkit-scrollbar-thumb { - background: var(--border-subtle); - border-radius: 2px; - } -} - -.th-list__tool-item { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 3px 8px; - border-radius: $size-radius-sm; - border: 1px solid var(--border-subtle); - background: var(--element-bg-base); - font-size: $font-size-xs; - color: var(--color-text-secondary); - cursor: pointer; - transition: background $motion-fast $easing-standard, - border-color $motion-fast $easing-standard, - color $motion-fast $easing-standard; - - &:hover { - background: var(--element-bg-medium); - border-color: var(--border-medium); - color: var(--color-text-primary); - } - - &.is-on { - border-color: var(--color-primary); - background: rgba(var(--color-primary-rgb, 99 102 241) / 0.08); - color: var(--color-text-primary); - } -} - -.th-list__tool-item-name { - font-family: $font-family-mono; - font-size: 11px; -} - -.th-list__tool-item-badge { - font-size: 9px; - font-weight: $font-weight-semibold; - color: var(--color-primary); - letter-spacing: 0.3px; -} - -// ── Create agent page ───────────────────────────────────────────────────────── - -.th-create-page__head { - margin-bottom: $size-gap-5; -} - -.th-create-page__form { - display: flex; - flex-direction: column; - gap: $size-gap-4; - width: min(100%, 560px); -} - - -.th-create-page__actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: $size-gap-2; - padding-top: $size-gap-2; - border-top: 1px solid var(--border-subtle); - margin-top: $size-gap-2; -} - -// ── Create agent panel ──────────────────────────────────────────────────────── - -.th-create-panel { - border: 1px solid var(--border-accent-subtle); - border-radius: $size-radius-base; - background: var(--element-bg-subtle); - overflow: hidden; - animation: th-list-item-in 0.2s $easing-decelerate both; -} - -.th-create-panel__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: $size-gap-3 $size-gap-4; - border-bottom: 1px solid var(--border-subtle); - background: var(--element-bg-base); -} - -.th-create-panel__title { - font-size: $font-size-sm; - font-weight: $font-weight-semibold; - color: var(--color-text-primary); -} - -.th-create-panel__body { - display: flex; - flex-direction: column; - gap: $size-gap-3; - padding: $size-gap-4; -} - -.th-create-panel__field { - display: flex; - flex-direction: column; - gap: $size-gap-1; - - &--row { - flex-direction: row; - align-items: center; - justify-content: space-between; - } -} - -.th-create-panel__label { - font-size: $font-size-xs; - font-weight: $font-weight-medium; - color: var(--color-text-secondary); -} - -.th-create-panel__label-hint { - font-weight: $font-weight-normal; - color: var(--color-text-muted); - margin-left: 4px; -} - -.th-create-panel__error { - font-size: $font-size-xs; - color: var(--color-error); -} - -.th-create-panel__hint { - font-size: $font-size-xs; - color: var(--color-text-muted); - margin: 0; - margin-top: -$size-gap-1; -} - -.th-create-panel__readonly-row { - display: flex; - align-items: center; - gap: $size-gap-2; - margin-left: auto; - flex-shrink: 0; -} - - - -.th-create-panel__level-group { - display: inline-flex; - gap: $size-gap-2; -} - -.th-create-panel__level-btn { - display: inline-flex; - align-items: center; - height: 26px; - padding: 0 $size-gap-3; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-full; - background: transparent; - font-size: $font-size-xs; - font-weight: $font-weight-medium; - color: var(--color-text-secondary); - cursor: pointer; - transition: border-color $motion-fast $easing-standard, - background $motion-fast $easing-standard, - color $motion-fast $easing-standard; - - &:hover:not(:disabled) { - border-color: var(--border-medium); - color: var(--color-text-primary); - } - - &.is-active { - border-color: var(--color-primary); - background: rgba(var(--color-primary-rgb, 99 102 241) / 0.08); - color: var(--color-text-primary); - font-weight: $font-weight-semibold; - } - - &:disabled { - opacity: 0.4; - cursor: not-allowed; - } -} - -.th-create-panel__tools { - display: flex; - flex-wrap: wrap; - gap: $size-gap-1; -} - -.th-create-panel__footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: $size-gap-2; - padding: $size-gap-3 $size-gap-4; - border-top: 1px solid var(--border-subtle); - background: var(--element-bg-base); -} - -// ── Empty state ─────────────────────────────────────────────────────────────── - -.th-list__empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: $size-gap-3; - padding: $size-gap-10 $size-gap-6; - color: var(--color-text-muted); - font-size: $font-size-sm; - text-align: center; - opacity: 0.6; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-base; - - svg { opacity: 0.5; } -} - -// ── Animations ──────────────────────────────────────────────────────────────── - -@keyframes th-list-item-in { - from { - opacity: 0; - transform: translateY(6px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (prefers-reduced-motion: reduce) { - .th-list__item { - animation: none; - } -} - -// ── Responsive ──────────────────────────────────────────────────────────────── - -@container (max-width: 480px) { - .th__header { - padding: $size-gap-4; - } - - .th__list-body { - padding: $size-gap-4; - } -} diff --git a/src/web-ui/src/app/scenes/team/components/TeamHomePage.tsx b/src/web-ui/src/app/scenes/team/components/TeamHomePage.tsx deleted file mode 100644 index 0badfa68..00000000 --- a/src/web-ui/src/app/scenes/team/components/TeamHomePage.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import React, { useState } from 'react'; -import { Search, Bot, Users, Plus, ArrowRight, User } from 'lucide-react'; -import { - useTeamStore, - MOCK_AGENTS, - CAPABILITY_CATEGORIES, - computeTeamCapabilities, - type AgentWithCapabilities, - type Team, -} from '../teamStore'; -import { AGENT_ICON_MAP, TEAM_ICON_MAP } from '../teamIcons'; -import './TeamHomePage.scss'; - -// ─── Agent card ─────────────────────────────────────────────────────────────── - -const AgentCard: React.FC<{ - agent: AgentWithCapabilities; - soloEnabled: boolean; - onToggleSolo: (agentId: string, enabled: boolean) => void; -}> = ({ agent, soloEnabled, onToggleSolo }) => { - const iconKey = (agent.iconKey ?? 'bot') as keyof typeof AGENT_ICON_MAP; - const IconComp = AGENT_ICON_MAP[iconKey] ?? Bot; - - return ( -
- {/* Icon + type badge */} -
-
- -
- - - Agent - -
- - {/* Name */} -
{agent.name}
- - {/* Description */} -
{agent.description}
- - {/* Capabilities */} -
- {agent.capabilities.slice(0, 3).map((c) => ( - - {c.category} - - ))} -
- - {/* Footer */} -
- {agent.model ?? 'primary'} -
- -
-
-
- ); -}; - -// ─── Team card ──────────────────────────────────────────────────────────────── - -const TeamCard: React.FC<{ team: Team }> = ({ team }) => { - const { openTeamEditor } = useTeamStore(); - const iconKey = team.icon as keyof typeof TEAM_ICON_MAP; - const IconComp = TEAM_ICON_MAP[iconKey] ?? Users; - - const caps = computeTeamCapabilities(team, MOCK_AGENTS); - const topCaps = CAPABILITY_CATEGORIES - .filter((c) => caps[c] > 0) - .sort((a, b) => caps[b] - caps[a]) - .slice(0, 3); - - // Member avatar stack - const memberAgents = team.members - .map((m) => MOCK_AGENTS.find((a) => a.id === m.agentId)) - .filter(Boolean) as AgentWithCapabilities[]; - - return ( -
openTeamEditor(team.id)} - > - {/* Icon + type badge */} -
-
- -
- - - 团队 - -
- - {/* Name */} -
{team.name}
- - {/* Description */} -
{team.description || '暂无描述'}
- - {/* Member stack */} -
-
- {memberAgents.slice(0, 4).map((a) => { - const ik = (a.iconKey ?? 'bot') as keyof typeof AGENT_ICON_MAP; - const IC = AGENT_ICON_MAP[ik] ?? Bot; - return ( - - - - ); - })} - {team.members.length > 4 && ( - - +{team.members.length - 4} - - )} -
- {team.members.length} 名成员 -
- - {/* Capability coverage */} - {topCaps.length > 0 && ( -
- {topCaps.map((c) => ( - - {c} - - ))} -
- )} - - {/* Footer */} -
- - {team.strategy === 'collaborative' ? '协作' : team.strategy === 'sequential' ? '顺序' : '自由'} - - - 编辑 - -
-
- ); -}; - -// ─── Page ───────────────────────────────────────────────────────────────────── - -const TeamHomePage: React.FC = () => { - const { - teams, - addTeam, - openTeamEditor, - agentSoloEnabled, - setAgentSoloEnabled, - } = useTeamStore(); - const [query, setQuery] = useState(''); - - const agents = MOCK_AGENTS; - const filteredAgents = agents.filter((a) => { - if (!query) return true; - const q = query.toLowerCase(); - return a.name.toLowerCase().includes(q) || a.description.toLowerCase().includes(q); - }); - - const filteredTeams = teams.filter((t) => { - if (!query) return true; - const q = query.toLowerCase(); - return t.name.toLowerCase().includes(q) || (t.description?.toLowerCase().includes(q)); - }); - - const handleCreateTeam = () => { - const id = `team-${Date.now()}`; - addTeam({ id, name: '新团队', icon: 'users', description: '', strategy: 'collaborative', shareContext: true }); - openTeamEditor(id); - }; - - return ( -
- {/* ── Top bar ── */} -
-
-

资源总览

- 左侧 Agent,右侧团队 -
- -
- - setQuery(e.target.value)} - /> -
-
- - {/* ── Split layout ── */} -
-
-
-
- Agents - {filteredAgents.length} -
-
- {filteredAgents.map((a) => ( - - ))} -
-
- -
-
- 工作团队 - {filteredTeams.length} -
-
- {filteredTeams.map((t) => ( - - ))} - -
-
-
-
- -
- ); -}; - -export default TeamHomePage; diff --git a/src/web-ui/src/app/scenes/terminal/TerminalScene.tsx b/src/web-ui/src/app/scenes/terminal/TerminalScene.tsx index c89149e9..7e5cc1e7 100644 --- a/src/web-ui/src/app/scenes/terminal/TerminalScene.tsx +++ b/src/web-ui/src/app/scenes/terminal/TerminalScene.tsx @@ -16,6 +16,11 @@ import './TerminalScene.scss'; const TerminalScene: React.FC = () => { const { activeSessionId, setActiveSession } = useTerminalSceneStore(); const { t } = useTranslation('panels/terminal'); + // #region agent log + React.useEffect(() => { + console.error('[DBG-366fda][H-D] TerminalScene activeSessionId changed', {activeSessionId}); + }, [activeSessionId]); + // #endregion const handleExit = useCallback(() => { setActiveSession(null); diff --git a/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.scss b/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.scss index 9193a702..415e3626 100644 --- a/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.scss +++ b/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.scss @@ -2,15 +2,16 @@ .miniapp-card { display: flex; - flex-direction: column; + flex-direction: row; + align-items: stretch; border-radius: $size-radius-lg; background: var(--element-bg-soft); border: none; cursor: pointer; position: relative; overflow: hidden; - animation: miniapp-card-in 0.28s $easing-decelerate both; - animation-delay: calc(var(--card-index, 0) * 40ms); + animation: miniapp-card-in 0.22s $easing-decelerate both; + animation-delay: calc(var(--card-index, 0) * 35ms); transition: background $motion-fast $easing-standard, box-shadow $motion-fast $easing-standard, @@ -18,14 +19,8 @@ &:hover { background: var(--element-bg-medium); - box-shadow: - 0 6px 20px rgba(0, 0, 0, 0.2), - 0 2px 6px rgba(59, 130, 246, 0.07); - transform: translateY(-2px); - - .miniapp-card__overlay { - opacity: 1; - } + transform: translateY(-1px); + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.14); } &:active { @@ -38,198 +33,187 @@ outline-offset: 2px; } + // Running: green border, no background &--running { - background: color-mix(in srgb, #34d399 7%, var(--element-bg-soft)); - box-shadow: - 0 10px 28px rgba(0, 0, 0, 0.22), - 0 0 0 1px rgba(52, 211, 153, 0.22); + box-shadow: 0 0 0 1.5px rgba(52, 211, 153, 0.5); &:hover { - background: color-mix(in srgb, #34d399 10%, var(--element-bg-medium)); + box-shadow: + 0 0 0 1.5px rgba(52, 211, 153, 0.65), + 0 3px 12px rgba(0, 0, 0, 0.14); } } + // ── Left: icon block ────────────────────────────────────── + &__icon-area { - position: relative; - height: 90px; + width: 64px; + flex-shrink: 0; display: flex; align-items: center; justify-content: center; + position: relative; + } + + &__icon { + color: var(--color-text-secondary); + animation: miniapp-icon-pop 0.5s $easing-decelerate both; + animation-delay: calc(var(--card-index, 0) * 35ms + 60ms); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + } + + &:hover &__icon { + transform: scale(1.18); + } + + &__run-dot { + display: inline-block; flex-shrink: 0; - overflow: hidden; + width: 7px; + height: 7px; + border-radius: 50%; + background: #34d399; + box-shadow: 0 0 5px rgba(52, 211, 153, 0.65); + } - &::before { - content: ''; - position: absolute; - inset: 0; - background: radial-gradient( - ellipse at 25% 15%, - rgba(255, 255, 255, 0.1) 0%, - transparent 65% - ); - pointer-events: none; - z-index: 1; - } + &__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + padding: 0; } - &__icon { - color: rgba(255, 255, 255, 0.88); - position: relative; - z-index: 2; - filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.35)); + &__row { + display: flex; + align-items: center; + gap: $size-gap-2; + min-width: 0; + padding: $size-gap-2 $size-gap-3; } - &__running-badge { - position: absolute; - top: 8px; - right: 8px; - z-index: 3; - height: 20px; - padding: 0 8px; - border-radius: $size-radius-full; - font-size: 10px; + &__name { + font-size: $font-size-sm; font-weight: $font-weight-semibold; - color: #0e7490; - background: rgba(255, 255, 255, 0.9); - border: 1px solid rgba(52, 211, 153, 0.36); - display: inline-flex; - align-items: center; - text-transform: uppercase; - letter-spacing: 0.04em; - } - - &__overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 90px; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(3px); - -webkit-backdrop-filter: blur(3px); + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; + } + + &__version { + font-size: 10px; + color: var(--color-text-muted); + font-family: $font-family-mono; + letter-spacing: 0.02em; + white-space: nowrap; + flex-shrink: 0; + } + + &__actions { display: flex; align-items: center; - justify-content: center; - gap: $size-gap-2; - opacity: 0; - transition: opacity $motion-fast $easing-standard; - z-index: 4; + gap: 2px; + flex-shrink: 0; } - &__overlay-btn { + &__action-btn { display: flex; align-items: center; justify-content: center; - width: 34px; - height: 34px; - border-radius: $size-radius-full; + width: 26px; + height: 26px; + border-radius: $size-radius-sm; border: none; + background: transparent; cursor: pointer; transition: background $motion-fast $easing-standard, - transform $motion-fast $easing-standard; + color $motion-fast $easing-standard; &--primary { - background: $color-accent-500; - color: #fff; + color: var(--color-text-muted); &:hover { - background: $color-accent-600; - transform: scale(1.1); + background: var(--element-bg-strong); + color: var(--color-accent-400); } } &--danger { - background: rgba($color-error, 0.82); - color: #fff; - border: none; + color: var(--color-text-muted); &:hover { - background: $color-error; - transform: scale(1.1); + background: var(--element-bg-strong); + color: var(--color-error); } } - } - &__info { - flex: 1; - padding: $size-gap-2 $size-gap-3; - min-width: 0; - border-top: none; - padding-bottom: $size-gap-4; - } + &--stop { + color: var(--color-text-muted); - &__name { - font-size: $font-size-sm; - font-weight: $font-weight-semibold; - color: var(--color-text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: $line-height-tight; - margin-bottom: 3px; + &:hover { + background: var(--element-bg-strong); + color: #34d399; + } + } } &__desc { - font-size: $font-size-xs; - color: var(--color-text-muted); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: $line-height-base; + padding: $size-gap-1 $size-gap-3 $size-gap-3; + + &-inner { + font-size: $font-size-xs; + color: var(--color-text-secondary); + line-height: $line-height-relaxed; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } } &__tags { display: flex; flex-wrap: wrap; gap: $size-gap-1; - margin-top: $size-gap-1; + padding: $size-gap-2 $size-gap-3 $size-gap-3; } - &__version { - position: absolute; - bottom: $size-gap-2; - right: $size-gap-2; - font-size: 10px; - padding: 1px 5px; - border-radius: $size-radius-sm; + &__tag { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 $size-gap-2; + border-radius: $size-radius-full; + font-size: $font-size-xs; + color: var(--color-text-secondary); background: var(--element-bg-medium); - color: var(--color-text-muted); - font-family: $font-family-mono; - pointer-events: none; - letter-spacing: 0.02em; - line-height: 1.4; + border: 1px solid var(--border-default); + white-space: nowrap; } } @keyframes miniapp-card-in { - from { - opacity: 0; - transform: translateY(10px) scale(0.97); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } + 0% { opacity: 0; transform: translateY(10px) scale(0.96); } + 55% { opacity: 1; transform: translateY(-3px) scale(1.01); } + 75% { transform: translateY(1px) scale(0.995); } + 100% { transform: translateY(0) scale(1); } } -:root[data-theme-type='light'] { - .miniapp-card { - &__version { - background: var(--element-bg-base); - } - - &__running-badge { - color: #065f46; - background: rgba(255, 255, 255, 0.95); - border-color: rgba(16, 185, 129, 0.34); - } - } +@keyframes miniapp-icon-pop { + 0% { opacity: 0; transform: scale(0.5) rotate(-12deg); } + 55% { opacity: 1; transform: scale(1.25) rotate(4deg); } + 75% { transform: scale(0.92) rotate(-2deg); } + 90% { transform: scale(1.06) rotate(1deg); } + 100% { transform: scale(1) rotate(0deg); } } @media (prefers-reduced-motion: reduce) { - .miniapp-card { + .miniapp-card, + .miniapp-card__icon { animation: none; transition: none; } diff --git a/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.tsx b/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.tsx index 2a5ce3e5..adf913b3 100644 --- a/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.tsx +++ b/src/web-ui/src/app/scenes/toolbox/components/MiniAppCard.tsx @@ -1,110 +1,118 @@ import React from 'react'; -import { Play, Trash2 } from 'lucide-react'; -import * as LucideIcons from 'lucide-react'; +import { Play, Square, Trash2 } from 'lucide-react'; import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; -import { Tag } from '@/component-library'; +import { renderMiniAppIcon } from '../utils/miniAppIcons'; import './MiniAppCard.scss'; interface MiniAppCardProps { app: MiniAppMeta; index?: number; isRunning?: boolean; + onOpenDetails: (app: MiniAppMeta) => void; onOpen: (id: string) => void; onDelete: (id: string) => void; -} - -function getIcon(name: string): React.ReactNode { - const key = name - .split('-') - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') as keyof typeof LucideIcons; - const Icon = LucideIcons[key] as React.ElementType | undefined; - return Icon ? : ; -} - -function getIconGradient(icon: string): string { - const gradients = [ - 'linear-gradient(135deg, rgba(59,130,246,0.35) 0%, rgba(139,92,246,0.25) 100%)', - 'linear-gradient(135deg, rgba(16,185,129,0.3) 0%, rgba(59,130,246,0.25) 100%)', - 'linear-gradient(135deg, rgba(245,158,11,0.3) 0%, rgba(239,68,68,0.2) 100%)', - 'linear-gradient(135deg, rgba(139,92,246,0.35) 0%, rgba(236,72,153,0.2) 100%)', - 'linear-gradient(135deg, rgba(6,182,212,0.3) 0%, rgba(59,130,246,0.25) 100%)', - 'linear-gradient(135deg, rgba(239,68,68,0.25) 0%, rgba(245,158,11,0.2) 100%)', - ]; - const idx = (icon.charCodeAt(0) || 0) % gradients.length; - return gradients[idx]; + onStop?: (id: string) => void; } const MiniAppCard: React.FC = ({ app, index = 0, isRunning = false, + onOpenDetails, onOpen, onDelete, + onStop, }) => { const handleDeleteClick = (e: React.MouseEvent) => { e.stopPropagation(); onDelete(app.id); }; + const handleStopClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onStop?.(app.id); + }; + const handleOpenClick = (e: React.MouseEvent) => { e.stopPropagation(); onOpen(app.id); }; + const handleOpenDetails = () => { + onOpenDetails(app); + }; + return (
onOpen(app.id)} + onClick={handleOpenDetails} role="button" tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && onOpen(app.id)} - aria-label={`Open ${app.name}`} + onKeyDown={(e) => e.key === 'Enter' && handleOpenDetails()} + aria-label={app.name} > -
-
{getIcon(app.icon || 'box')}
- {isRunning && 运行中} -
- -
- - +
+
+ {renderMiniAppIcon(app.icon || 'box', 28)} +
-
-
{app.name}
- {app.description &&
{app.description}
} - {app.tags.length > 0 && ( +
+
+ {app.name} + {isRunning && } + v{app.version} +
+ + {isRunning && onStop ? ( + + ) : ( + + )} +
+
+ {app.description ? ( +
+ {app.description} +
+ ) : null} + {app.tags.length > 0 ? (
- {app.tags.slice(0, 2).map((tag) => ( - - {tag} - + {app.tags.slice(0, 3).map((tag) => ( + {tag} ))}
- )} + ) : null}
- -
v{app.version}
); }; export default MiniAppCard; - diff --git a/src/web-ui/src/app/scenes/toolbox/utils/miniAppIcons.tsx b/src/web-ui/src/app/scenes/toolbox/utils/miniAppIcons.tsx new file mode 100644 index 00000000..4f2e5b15 --- /dev/null +++ b/src/web-ui/src/app/scenes/toolbox/utils/miniAppIcons.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import * as LucideIcons from 'lucide-react'; + +const ICON_GRADIENTS = [ + 'linear-gradient(135deg, rgba(59,130,246,0.35) 0%, rgba(139,92,246,0.25) 100%)', + 'linear-gradient(135deg, rgba(16,185,129,0.3) 0%, rgba(59,130,246,0.25) 100%)', + 'linear-gradient(135deg, rgba(245,158,11,0.3) 0%, rgba(239,68,68,0.2) 100%)', + 'linear-gradient(135deg, rgba(139,92,246,0.35) 0%, rgba(236,72,153,0.2) 100%)', + 'linear-gradient(135deg, rgba(6,182,212,0.3) 0%, rgba(59,130,246,0.25) 100%)', + 'linear-gradient(135deg, rgba(239,68,68,0.25) 0%, rgba(245,158,11,0.2) 100%)', +]; + +export function renderMiniAppIcon(name: string, size = 28): React.ReactNode { + const key = name + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') as keyof typeof LucideIcons; + const Icon = LucideIcons[key] as React.ElementType | undefined; + + return Icon + ? + : ; +} + +export function getMiniAppIconGradient(icon: string): string { + const idx = (icon.charCodeAt(0) || 0) % ICON_GRADIENTS.length; + return ICON_GRADIENTS[idx]; +} diff --git a/src/web-ui/src/app/scenes/toolbox/views/GalleryView.scss b/src/web-ui/src/app/scenes/toolbox/views/GalleryView.scss index 30a7d287..77f49b9f 100644 --- a/src/web-ui/src/app/scenes/toolbox/views/GalleryView.scss +++ b/src/web-ui/src/app/scenes/toolbox/views/GalleryView.scss @@ -1,493 +1,37 @@ @use '../../../../component-library/styles/tokens' as *; -$gutter: clamp(16px, 2.2vw, 28px); - .toolbox-gallery { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - overflow: hidden; - - &__action-btn { - width: 32px; - height: 32px; - border-radius: $size-radius-base; - border: none; - background: var(--element-bg-soft); - color: var(--color-text-secondary); - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - transition: - background $motion-fast $easing-standard, - color $motion-fast $easing-standard; - - &:hover:not(:disabled) { - background: var(--element-bg-medium); - color: var(--color-text-primary); - } - - &:focus-visible { - outline: 2px solid var(--color-accent-500); - outline-offset: -1px; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - &--primary { - color: var(--color-accent-500); - background: color-mix(in srgb, var(--color-accent-500) 10%, var(--element-bg-soft)); - - &:hover:not(:disabled) { - background: color-mix(in srgb, var(--color-accent-500) 16%, var(--element-bg-medium)); - color: var(--color-accent-400); - } - } - } - - // ── Scrollable body ─────────────────────────────────────── - - &__body { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - - &::-webkit-scrollbar { - width: 4px; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: var(--border-subtle); - border-radius: 2px; - } - } - - // Inner wrapper — centers content vertically when it fits in viewport - &__body-inner { - min-height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: stretch; - } - - // Hero — big centered title block - &__hero { - text-align: center; - padding: $size-gap-8 $gutter $size-gap-4; - } - - &__hero-title { - margin: 0 0 $size-gap-2; - font-size: clamp(22px, 3vw, 30px); - font-weight: $font-weight-semibold; - color: var(--color-text-primary); - letter-spacing: -0.02em; - line-height: $line-height-tight; - } - - &__hero-sub { - margin: 0; - font-size: $font-size-sm; - color: var(--color-text-muted); - line-height: $line-height-relaxed; - } - - // Search zone — centered, sticks to top when scrolling - &__search-zone { - position: sticky; - top: 0; - z-index: 10; - padding: $size-gap-3 $gutter $size-gap-4; - background: linear-gradient( - to bottom, - var(--color-bg-scene) 0%, - var(--color-bg-scene) 68%, - transparent 100% - ); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - } - - // Inner centering wrapper — search input + action buttons in one row - &__search-inner { - max-width: 560px; - width: 100%; - margin: 0 auto; - display: flex; - align-items: center; - gap: $size-gap-2; - - // Search component takes remaining space + .gallery-page-header__actions { > :first-child { - flex: 1; - min-width: 0; - } - } - - // Zones container — centered with max-width - &__zones { - max-width: 880px; - width: 100%; - margin: 0 auto; - padding: 0 $gutter $size-gap-8; - display: flex; - flex-direction: column; - gap: $size-gap-5; - } - - // ── Zone (section block) ────────────────────────────────── - - &__zone { - display: flex; - flex-direction: column; - gap: $size-gap-3; - } - - &__zone-header { - display: flex; - align-items: center; - gap: $size-gap-2; - min-height: 28px; - - &--clickable { - cursor: pointer; - user-select: none; - border-radius: $size-radius-sm; - padding: 2px 4px; - margin: -2px -4px; - transition: background $motion-fast $easing-standard; - - &:hover { - background: var(--element-bg-soft); - } - - &:focus-visible { - outline: 2px solid var(--color-accent-500); - outline-offset: 1px; - } - } - } - - &__zone-icon { - color: var(--color-text-muted); - flex-shrink: 0; - - &--running { - color: #34d399; + width: 180px; } } - &__zone-label { - font-size: $font-size-xs; - font-weight: $font-weight-semibold; - color: var(--color-text-secondary); - white-space: nowrap; - } - - &__zone-badge { - min-width: 18px; - height: 18px; - border-radius: $size-radius-full; - background: rgba(52, 211, 153, 0.12); - border: 1px solid rgba(52, 211, 153, 0.28); - color: #34d399; - font-size: 11px; - font-weight: $font-weight-semibold; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 5px; - } - - &__zone-chevron { - color: var(--color-text-muted); - flex-shrink: 0; - transition: transform $motion-fast $easing-standard; - - &--collapsed { - transform: rotate(-90deg); - } - } - - &__zone-cats { + &__detail-tags { display: flex; - gap: $size-gap-1; flex-wrap: wrap; - margin-left: $size-gap-2; - } - - &__zone-count { - font-size: $font-size-xs; - color: var(--color-text-muted); - margin-left: auto; - flex-shrink: 0; - } - - // ── Running strip ───────────────────────────────────────── - - &__run-strip { - display: flex; gap: $size-gap-2; - overflow-x: auto; - padding-bottom: 4px; - - &::-webkit-scrollbar { - height: 3px; - } - - &::-webkit-scrollbar-thumb { - background: var(--border-subtle); - border-radius: 2px; - } - } - - &__run-tile { - display: flex; - align-items: center; - gap: 8px; - height: 36px; - padding: 0 10px; - border-radius: $size-radius-base; - border: none; - background: color-mix(in srgb, #34d399 8%, var(--element-bg-soft)); - cursor: pointer; - white-space: nowrap; - flex-shrink: 0; - transition: - background $motion-fast $easing-standard, - box-shadow $motion-fast $easing-standard; - - &:hover { - background: color-mix(in srgb, #34d399 12%, var(--element-bg-medium)); - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.16); - } - - &:focus-visible { - outline: 2px solid var(--color-accent-500); - outline-offset: 1px; - } } - &__run-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: #34d399; - flex-shrink: 0; - box-shadow: 0 0 6px rgba(52, 211, 153, 0.55); - } - - &__run-name { - font-size: $font-size-xs; - font-weight: $font-weight-medium; - color: var(--color-text-primary); - } - - &__run-cat { - font-size: 10px; - color: var(--color-text-muted); - border: 1px solid var(--border-subtle); - border-radius: $size-radius-full; - padding: 0 6px; - height: 18px; + &__detail-tag { display: inline-flex; align-items: center; - } - - &__run-actions { - display: flex; gap: 4px; - margin-left: 4px; - } - - &__run-btn { - width: 22px; - height: 22px; + padding: 4px 10px; border-radius: $size-radius-full; + background: var(--element-bg-medium); border: 1px solid var(--border-subtle); - background: var(--element-bg-subtle); - color: var(--color-text-secondary); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: - background $motion-fast $easing-standard, - color $motion-fast $easing-standard, - border-color $motion-fast $easing-standard; - - &:hover { - background: var(--element-bg-soft); - border-color: var(--border-strong); - color: var(--color-text-primary); - } - - &--danger:hover { - background: rgba($color-error, 0.14); - border-color: rgba($color-error, 0.4); - color: var(--color-error); - } - } - - // ── Category chips ──────────────────────────────────────── - - &__cat-chip { - display: inline-flex; - align-items: center; - height: 22px; - padding: 0 $size-gap-2; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-full; - background: transparent; - color: var(--color-text-muted); font-size: $font-size-xs; - font-weight: $font-weight-medium; - cursor: pointer; - white-space: nowrap; - transition: - color $motion-fast $easing-standard, - background $motion-fast $easing-standard, - border-color $motion-fast $easing-standard; - - &:hover { - color: var(--color-text-primary); - background: var(--element-bg-soft); - } - - &--active { - color: var(--color-accent-500); - background: var(--color-accent-100); - border-color: var(--color-accent-300); - } - - &:focus-visible { - outline: 2px solid var(--color-accent-500); - outline-offset: -1px; - } - } - - // ── App grid ────────────────────────────────────────────── - - &__grid, - &__skeleton-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: $size-gap-3; - align-content: start; - } - - &__skeleton-grid { - --skeleton-shimmer-0: rgba(255, 255, 255, 0); - --skeleton-shimmer-peak: rgba(255, 255, 255, 0.1); - } - - &__skeleton-card { - height: 188px; - border-radius: $size-radius-lg; - background: var(--element-bg-subtle); - border: 1px solid var(--border-subtle); - position: relative; - overflow: hidden; - animation: toolbox-item-in 0.28s $easing-decelerate both; - animation-delay: calc(var(--card-index, 0) * 60ms); - - &::after { - content: ''; - position: absolute; - inset: 0; - transform: translateX(-100%); - background: linear-gradient( - 90deg, - var(--skeleton-shimmer-0) 0%, - var(--skeleton-shimmer-peak) 50%, - var(--skeleton-shimmer-0) 100% - ); - animation: toolbox-shimmer 1.6s ease-in-out calc(var(--card-index, 0) * 0.12s) infinite; - } - } - - &__empty { - display: flex; - align-items: center; - justify-content: center; - min-height: 200px; - padding: $size-gap-8 $size-gap-6; - } - - &__empty-icon { - color: var(--color-accent-400); - } - - &__spinning { - animation: toolbox-spin 0.8s linear infinite; - } -} - -// ── Animations ──────────────────────────────────────────────── - -@keyframes toolbox-spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -@keyframes toolbox-shimmer { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(200%); } -} - -@keyframes toolbox-item-in { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); + color: var(--color-text-secondary); } } -:root[data-theme-type='light'] .toolbox-gallery__skeleton-grid { - --skeleton-shimmer-0: rgba(0, 0, 0, 0); - --skeleton-shimmer-peak: rgba(0, 0, 0, 0.07); -} - @media (max-width: 720px) { .toolbox-gallery { - &__search-wrap { - max-width: none; - } - - &__grid, - &__skeleton-grid { - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - } - } -} - -@media (prefers-reduced-motion: reduce) { - .toolbox-gallery { - &__action-btn, - &__cat-chip, - &__run-tile, - &__run-btn, - &__zone-chevron, - &__skeleton-card { - transition: none; - animation: none; - } - - &__skeleton-card::after { - animation: none; + .gallery-page-header__actions { + > :first-child { + width: 100%; + } } } } diff --git a/src/web-ui/src/app/scenes/toolbox/views/GalleryView.tsx b/src/web-ui/src/app/scenes/toolbox/views/GalleryView.tsx index e3e22818..5f813365 100644 --- a/src/web-ui/src/app/scenes/toolbox/views/GalleryView.tsx +++ b/src/web-ui/src/app/scenes/toolbox/views/GalleryView.tsx @@ -1,13 +1,14 @@ import React, { useState, useMemo, useCallback } from 'react'; import { - Activity, - ChevronDown, + Box, FolderPlus, LayoutGrid, Play, RefreshCw, Sparkles, Square, + Tag, + Trash2, } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { useToolboxStore } from '../toolboxStore'; @@ -16,8 +17,18 @@ import MiniAppCard from '../components/MiniAppCard'; import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; import { createLogger } from '@/shared/utils/logger'; -import { Search, Empty, ConfirmDialog } from '@/component-library'; +import { Search, ConfirmDialog, Button, Badge } from '@/component-library'; +import { + GalleryDetailModal, + GalleryEmpty, + GalleryGrid, + GalleryLayout, + GalleryPageHeader, + GallerySkeleton, + GalleryZone, +} from '@/app/components'; import type { SceneTabId } from '@/app/components/SceneBar/types'; +import { getMiniAppIconGradient, renderMiniAppIcon } from '../utils/miniAppIcons'; import './GalleryView.scss'; const log = createLogger('GalleryView'); @@ -33,8 +44,8 @@ const GalleryView: React.FC = () => { const [search, setSearch] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); - const [runningCollapsed, setRunningCollapsed] = useState(false); const [pendingDeleteId, setPendingDeleteId] = useState(null); + const [selectedApp, setSelectedApp] = useState(null); const openTabIds = useMemo(() => new Set(openTabs.map((tab) => tab.id)), [openTabs]); const runningIdSet = useMemo(() => new Set(runningWorkerIds), [runningWorkerIds]); @@ -130,7 +141,7 @@ const GalleryView: React.FC = () => { const selected = await open({ directory: true, multiple: false, - title: '选择 MiniApp 目录(需包含 meta.json 与 source/)', + title: '选择小应用目录(需包含 meta.json 与 source/)', }); const path = Array.isArray(selected) ? selected[0] : selected; if (!path) return; @@ -148,203 +159,172 @@ const GalleryView: React.FC = () => { const renderGrid = () => { if (loading && apps.length === 0) { - return ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- ))} -
- ); + return ; } if (filtered.length === 0) { return ( -
- - ) : ( - - ) - } - imageSize={48} - description={ - apps.length === 0 - ? '还没有 MiniApp,和 AI 对话生成第一个吧。' - : '没有匹配的应用。' - } - /> -
+ + : + } + message={apps.length === 0 + ? '边聊边生成,马上可用。和 AI 对话生成第一个小应用吧。' + : '没有匹配的应用。'} + /> ); } return ( -
+ {filtered.map((app, index) => ( ))} -
+ ); }; return ( -
- {/* Scrollable body */} -
-
- {/* Hero — big centered title */} -
-

工具箱

-

统一查看、启动和切换 MiniApp

-
- - {/* Search zone — centered row with search + actions, sticky when scrolling */} -
-
- - - -
-
- - {/* Zones content — centered max-width container */} -
- - {/* Running zone — only when apps are running */} - {runningApps.length > 0 && ( -
-
setRunningCollapsed((v) => !v)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && setRunningCollapsed((v) => !v)} + + + + +
- - {!runningCollapsed && ( -
- {runningApps.map((app) => ( -
handleOpenApp(app.id)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && handleOpenApp(app.id)} - > - - {app.name} - {app.category && ( - {app.category} - )} -
- - -
-
- ))} -
- )} -
+ + )} + /> - {/* All apps zone */} -
-
- - 全部应用 - {categories.length > 1 && ( -
- {categories.map((cat) => ( - - ))} -
- )} - {filtered.length} 个 -
+
+ 0 ? {runningApps.length} : null} + > + {runningApps.length > 0 ? ( + + {runningApps.map((app, index) => ( + + ))} + + ) : ( +
+ 暂无运行中的应用 +
+ )} +
+ + {categories.length > 1 ? ( +
+ {categories.map((cat) => ( + + ))} +
+ ) : null} + {filtered.length} 个 + + )} + > {renderGrid()} -
-
{/* end __zones */} -
+
+ setSelectedApp(null)} + icon={selectedApp ? renderMiniAppIcon(selectedApp.icon || 'box', 24) : } + iconGradient={selectedApp ? getMiniAppIconGradient(selectedApp.icon || 'box') : undefined} + title={selectedApp?.name ?? ''} + badges={selectedApp?.category ? {selectedApp.category} : null} + description={selectedApp?.description} + meta={selectedApp ? v{selectedApp.version} : null} + actions={selectedApp ? ( + <> + {runningIdSet.has(selectedApp.id) ? ( + + ) : null} + + + + ) : null} + > + {selectedApp?.tags.length ? ( +
+ {selectedApp.tags.map((tag) => ( + + + {tag} + + ))} +
+ ) : null} +
+ setPendingDeleteId(null)} @@ -356,7 +336,7 @@ const GalleryView: React.FC = () => { confirmText="删除" cancelText="取消" /> -
+ ); }; diff --git a/src/web-ui/src/app/scenes/welcome/WelcomeScene.scss b/src/web-ui/src/app/scenes/welcome/WelcomeScene.scss index 89016ef1..f1f3fa51 100644 --- a/src/web-ui/src/app/scenes/welcome/WelcomeScene.scss +++ b/src/web-ui/src/app/scenes/welcome/WelcomeScene.scss @@ -6,6 +6,7 @@ @use '../../../component-library/styles/tokens.scss' as *; .welcome-scene { + position: relative; display: flex; align-items: center; justify-content: center; @@ -20,7 +21,7 @@ display: flex; flex-direction: column; gap: $size-gap-8; - max-width: 440px; + max-width: 480px; width: 100%; } @@ -32,31 +33,51 @@ gap: $size-gap-1; } + &__greeting-inner { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: $size-gap-4; + } + + &__greeting-text { + display: flex; + flex-direction: column; + gap: $size-gap-1; + min-width: 0; + } + + &__title-row { + display: flex; + align-items: baseline; + gap: $size-gap-2; + flex-wrap: nowrap; + min-width: 0; + } + &__greeting-label { - font-size: $font-size-sm; + font-size: $font-size-base; color: var(--color-text-muted); - margin: 0; + white-space: nowrap; + flex-shrink: 0; } &__workspace-title { - font-size: $font-size-3xl; + font-size: 28px; font-weight: $font-weight-semibold; color: var(--color-text-primary); margin: 0; line-height: $line-height-tight; letter-spacing: -0.02em; - } - - &__workspace-meta { - display: flex; - align-items: center; - gap: $size-gap-2; - margin-top: $size-gap-1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } &__meta-tag { display: inline-flex; align-items: center; + align-self: center; gap: 3px; padding: 2px $size-gap-2; border-radius: $size-radius-sm; @@ -132,14 +153,14 @@ display: flex; align-items: center; gap: $size-gap-2; - font-size: $font-size-sm; + font-size: $font-size-base; font-weight: $font-weight-medium; color: var(--color-text-primary); line-height: 1.3; } &__session-btn-desc { - font-size: $font-size-xs; + font-size: $font-size-sm; color: var(--color-text-muted); line-height: $line-height-base; } @@ -174,7 +195,7 @@ display: flex; align-items: center; gap: $size-gap-1; - font-size: $font-size-xs; + font-size: $font-size-sm; color: var(--color-text-muted); font-weight: $font-weight-medium; text-transform: uppercase; @@ -191,12 +212,12 @@ display: flex; align-items: center; gap: 4px; - padding: 3px $size-gap-2; + padding: 4px $size-gap-2; border: none; border-radius: $size-radius-sm; background: transparent; color: var(--color-text-muted); - font-size: $font-size-xs; + font-size: $font-size-sm; cursor: pointer; transition: background $motion-fast $easing-standard, color $motion-fast $easing-standard; @@ -226,12 +247,12 @@ display: flex; align-items: center; gap: $size-gap-2; - padding: $size-gap-1 + 1px $size-gap-2; + padding: $size-gap-1 + 2px $size-gap-2; border: none; border-radius: $size-radius-sm; background: transparent; color: var(--color-text-secondary); - font-size: $font-size-sm; + font-size: $font-size-base; cursor: pointer; text-align: left; transition: background $motion-fast $easing-standard, @@ -261,7 +282,7 @@ &__recent-time { flex-shrink: 0; - font-size: $font-size-xs; + font-size: $font-size-sm; color: var(--color-text-muted); } @@ -356,6 +377,35 @@ border-radius: $size-radius-lg; } + // ── Panda mascot ───────────────────────────────────── + + &__panda { + position: relative; + flex-shrink: 0; + width: 100px; + pointer-events: none; + user-select: none; + image-rendering: pixelated; + } + + &__panda-frame { + display: block; + width: 100%; + height: auto; + image-rendering: pixelated; + } + + &__panda-frame--2 { + position: absolute; + top: 0; + left: 0; + animation: panda-blink 1.2s steps(1, end) infinite; + } + + &__panda-frame--1 { + animation: panda-blink-inv 1.2s steps(1, end) infinite; + } + // ── First-time modifier ────────────────────────────── &--first-time { @@ -375,7 +425,17 @@ } } -// ── Animation ───────────────────────────────────────── +// ── Animations ──────────────────────────────────────── + +@keyframes panda-blink { + 0%, 49% { opacity: 0; } + 50%, 100% { opacity: 1; } +} + +@keyframes panda-blink-inv { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} @keyframes welcome-fade-in { from { @@ -394,7 +454,7 @@ .welcome-scene { padding: $size-gap-5 $size-gap-4; - &__workspace-title { font-size: $font-size-2xl; } + &__workspace-title { font-size: $font-size-3xl; } } } @@ -402,6 +462,9 @@ .welcome-scene { animation: none; + &__panda-frame--1 { animation: none; opacity: 1; } + &__panda-frame--2 { animation: none; opacity: 0; } + &__session-btn, &__primary-action, &__recent-item, diff --git a/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx b/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx index d8b87b3b..07b13675 100644 --- a/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx +++ b/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx @@ -127,15 +127,23 @@ const WelcomeScene: React.FC = () => { {/* Greeting */}
-

{t('welcomeScene.welcomeBack')}

-

{currentWorkspace?.name}

-
- {isRepository && currentBranch && ( - - - {currentBranch} - - )} +
+ +
+

{t('welcomeScene.welcomeBack')}

+
+

{currentWorkspace?.name}

+ {isRepository && currentBranch && ( + + + {currentBranch} + + )} +
+
diff --git a/src/web-ui/src/app/stores/sceneStore.ts b/src/web-ui/src/app/stores/sceneStore.ts index 1d15b2d5..86625f57 100644 --- a/src/web-ui/src/app/stores/sceneStore.ts +++ b/src/web-ui/src/app/stores/sceneStore.ts @@ -56,6 +56,14 @@ function buildSceneTab(id: SceneTabId, now: number): SceneTab { return { id, openedAt: now, lastUsed: now }; } +function resolveNavSceneId(sceneId: SceneTabId): SceneTabId | null { + if (sceneId === 'terminal') { + return 'shell'; + } + + return getSceneNav(sceneId) ? sceneId : null; +} + interface SceneState { openTabs: SceneTab[]; activeTabId: SceneTabId; @@ -146,10 +154,10 @@ export const useSceneStore = create((set, get) => ({ // Already active — re-sync left nav in case user navigated back to MainNav if (id === activeTabId) { - const hasNav = !!getSceneNav(id); + const navSceneId = resolveNavSceneId(id); const navStore = useNavSceneStore.getState(); - if (hasNav && !navStore.showSceneNav) { - navStore.openNavScene(id); + if (navSceneId && (!navStore.showSceneNav || navStore.navSceneId !== navSceneId)) { + navStore.openNavScene(navSceneId); } return; } @@ -285,10 +293,10 @@ if (typeof window !== 'undefined') { useSceneStore.subscribe((state) => { if (state.activeTabId !== prev) { prev = state.activeTabId; - const hasNav = !!getSceneNav(state.activeTabId); + const navSceneId = resolveNavSceneId(state.activeTabId); const navStore = useNavSceneStore.getState(); - if (hasNav) { - navStore.openNavScene(state.activeTabId); + if (navSceneId) { + navStore.openNavScene(navSceneId); } else { navStore.closeNavScene(); } diff --git a/src/web-ui/src/app/styles/components/forms.css b/src/web-ui/src/app/styles/components/forms.css index bf71882a..d7d9ed4b 100644 --- a/src/web-ui/src/app/styles/components/forms.css +++ b/src/web-ui/src/app/styles/components/forms.css @@ -1,4 +1,4 @@ -/* BitFun form components - blue glassmorphism style */ +/* BitFun form components - token-driven application style */ /* ========== Form base styles ========== */ diff --git a/src/web-ui/src/app/styles/utilities/animations.css b/src/web-ui/src/app/styles/utilities/animations.css index d93fc26a..ecf8e064 100644 --- a/src/web-ui/src/app/styles/utilities/animations.css +++ b/src/web-ui/src/app/styles/utilities/animations.css @@ -1,4 +1,4 @@ -/* BitFun unified animation system - blue glassmorphism style */ +/* BitFun unified animation system - token-driven motion styles */ /* ========== Core keyframe animations ========== */ diff --git a/src/web-ui/src/app/types/index.ts b/src/web-ui/src/app/types/index.ts index 8da0156a..b5f9dbde 100644 --- a/src/web-ui/src/app/types/index.ts +++ b/src/web-ui/src/app/types/index.ts @@ -31,7 +31,7 @@ export interface AgentConfig { // Panel types - removed 'chat'; chat lives in the center panel. // 'profile' replaces legacy context naming. -export type PanelType = 'sessions' | 'files' | 'git' | 'profile' | 'terminal' | 'capabilities' | 'team' | 'skills' | 'tools' | 'shell-hub' | 'toolbox'; +export type PanelType = 'sessions' | 'files' | 'git' | 'profile' | 'terminal' | 'capabilities' | 'agents' | 'skills' | 'tools' | 'toolbox'; // Layout state - three-column layout support. // Strategy: fixed left/right widths with elastic center (floating layout). diff --git a/src/web-ui/src/component-library/components/Tag/Tag.scss b/src/web-ui/src/component-library/components/Tag/Tag.scss index 06482012..1e5cb12a 100644 --- a/src/web-ui/src/component-library/components/Tag/Tag.scss +++ b/src/web-ui/src/component-library/components/Tag/Tag.scss @@ -8,33 +8,7 @@ border-radius: tokens.$size-radius-sm; font-weight: tokens.$font-weight-medium; transition: all tokens.$motion-base tokens.$easing-standard; - border: 1px solid transparent; - backdrop-filter: tokens.$blur-base; - -webkit-backdrop-filter: tokens.$blur-base; position: relative; - overflow: hidden; - - &::before { - content: ''; - position: absolute; - top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient(90deg, - transparent, - rgba(255, 255, 255, 0.05), - rgba(255, 255, 255, 0.08), - rgba(255, 255, 255, 0.05), - transparent); - transition: left tokens.$motion-slow tokens.$easing-standard; - z-index: 1; - pointer-events: none; - } - - &:hover::before { - left: 100%; - } &--small { font-size: tokens.$font-size-xs; @@ -53,89 +27,57 @@ } &--blue { - background: linear-gradient(135deg, - rgba(59, 130, 246, 0.10) 0%, - rgba(96, 165, 250, 0.08) 100%); + background: rgba(59, 130, 246, 0.14); color: tokens.$color-accent-500; - border-color: rgba(59, 130, 246, 0.2); &:hover { - @include tokens.liquid-glass-hover('blue'); - transform: translateY(-1px) scale(1.02); + background: rgba(59, 130, 246, 0.2); } } &--green { - background: linear-gradient(135deg, - rgba(52, 211, 153, 0.10) 0%, - rgba(34, 197, 94, 0.08) 100%); + background: rgba(52, 211, 153, 0.14); color: tokens.$color-success; - border-color: rgba(52, 211, 153, 0.2); &:hover { - background: linear-gradient(135deg, - rgba(52, 211, 153, 0.20) 0%, - rgba(34, 197, 94, 0.15) 100%); - transform: translateY(-1px) scale(1.02); + background: rgba(52, 211, 153, 0.2); } } &--red { - background: linear-gradient(135deg, - rgba(239, 68, 68, 0.10) 0%, - rgba(220, 38, 38, 0.08) 100%); + background: rgba(239, 68, 68, 0.14); color: tokens.$color-error; - border-color: rgba(239, 68, 68, 0.2); &:hover { - background: linear-gradient(135deg, - rgba(239, 68, 68, 0.20) 0%, - rgba(220, 38, 38, 0.15) 100%); - transform: translateY(-1px) scale(1.02); + background: rgba(239, 68, 68, 0.2); } } &--yellow { - background: linear-gradient(135deg, - rgba(245, 158, 11, 0.10) 0%, - rgba(234, 179, 8, 0.08) 100%); + background: rgba(245, 158, 11, 0.14); color: tokens.$color-warning; - border-color: rgba(245, 158, 11, 0.2); &:hover { - background: linear-gradient(135deg, - rgba(245, 158, 11, 0.20) 0%, - rgba(234, 179, 8, 0.15) 100%); - transform: translateY(-1px) scale(1.02); + background: rgba(245, 158, 11, 0.2); } } &--purple { - background: linear-gradient(135deg, - rgba(139, 92, 246, 0.10) 0%, - rgba(124, 58, 237, 0.08) 100%); + background: rgba(139, 92, 246, 0.14); color: tokens.$color-purple-500; - border-color: rgba(139, 92, 246, 0.2); &:hover { - @include tokens.liquid-glass-hover('purple'); - transform: translateY(-1px) scale(1.02); + background: rgba(139, 92, 246, 0.2); } } &--gray { - background: linear-gradient(135deg, - tokens.$element-bg-medium 0%, - tokens.$element-bg-base 100%); + background: tokens.$element-bg-medium; color: tokens.$color-text-secondary; - border-color: tokens.$border-medium; &:hover { - background: linear-gradient(135deg, - tokens.$element-bg-strong 0%, - tokens.$element-bg-medium 100%); + background: tokens.$element-bg-strong; color: tokens.$color-text-primary; - transform: translateY(-1px) scale(1.02); } } @@ -145,8 +87,6 @@ &__content { line-height: 1; - position: relative; - z-index: 2; } &__close { @@ -161,12 +101,9 @@ border-radius: 50%; cursor: pointer; transition: all tokens.$motion-base tokens.$easing-standard; - position: relative; - z-index: 2; &:hover { background: rgba(0, 0, 0, 0.15); - transform: scale(1.1); } svg { diff --git a/src/web-ui/src/component-library/components/registry.tsx b/src/web-ui/src/component-library/components/registry.tsx index b4df6113..f6b9cb83 100644 --- a/src/web-ui/src/component-library/components/registry.tsx +++ b/src/web-ui/src/component-library/components/registry.tsx @@ -64,12 +64,12 @@ function createMockToolItem( toolResult: result ? { result, success: status === 'completed', - error: status === 'error' ? '????' : undefined + error: status === 'error' ? '执行失败' : undefined } : undefined, config: config || { toolName, displayName: toolName, - icon: '??', + icon: '🔧', requiresConfirmation: false, resultDisplayType: 'summary', description: '', @@ -83,28 +83,28 @@ function createMockToolItem( export const componentRegistry: ComponentCategory[] = [ { id: 'basic', - name: '????', - description: '?????UI ??', + name: '基础组件', + description: '常用的基础UI组件', layoutType: 'grid-4', components: [ { id: 'button-primary', name: 'Button - Primary', - description: '????', + description: '主要按钮', category: 'basic', component: () => , }, { id: 'button-secondary', name: 'Button - Secondary', - description: '????', + description: '次要按钮', category: 'basic', component: () => , }, { id: 'button-ghost', name: 'Button - Ghost', - description: '????', + description: '幽灵按钮', category: 'basic', component: () => , }, @@ -123,7 +123,7 @@ export const componentRegistry: ComponentCategory[] = [ }, { id: 'tag-demo', - name: 'Tag - ??', + name: 'Tag - 演示', description: 'Demo', category: 'basic', component: () => ( @@ -150,7 +150,7 @@ export const componentRegistry: ComponentCategory[] = [ }, { id: 'icon-button-variants', - name: 'IconButton - ??', + name: 'IconButton - 变体', description: 'Demo', category: 'basic', component: () => ( @@ -193,7 +193,7 @@ export const componentRegistry: ComponentCategory[] = [ }, { id: 'icon-button-sizes', - name: 'IconButton - ??', + name: 'IconButton - 尺寸', description: 'Demo', category: 'basic', component: () => ( @@ -218,7 +218,7 @@ export const componentRegistry: ComponentCategory[] = [ }, { id: 'icon-button-shapes', - name: 'IconButton - ??', + name: 'IconButton - 形状', description: 'Demo', category: 'basic', component: () => ( @@ -238,7 +238,7 @@ export const componentRegistry: ComponentCategory[] = [ }, { id: 'window-controls-demo', - name: 'WindowControls - ????', + name: 'WindowControls - 窗口控件', description: 'Demo', category: 'basic', component: () => ( @@ -271,20 +271,20 @@ export const componentRegistry: ComponentCategory[] = [ }, { id: 'feedback', - name: '????', + name: '反馈组件', description: 'Demo', layoutType: 'demo', components: [ { id: 'cube-loading-variants', - name: 'CubeLoading - ??????', - description: '3x3x3 ??????????', + name: 'CubeLoading - 所有变体', + description: '3x3x3 立方体加载动画展示', category: 'feedback', component: () => (
{}
-
??
+
尺寸
@@ -304,8 +304,8 @@ export const componentRegistry: ComponentCategory[] = [
With text
- - + +
@@ -314,17 +314,17 @@ export const componentRegistry: ComponentCategory[] = [ { id: 'modal-basic', name: 'Modal - Basic', - description: '?????', + description: '基础弹窗', category: 'feedback', component: () => { const [isOpen, setIsOpen] = React.useState(false); return ( <> - + setIsOpen(false)} - title="?????" + title="基础弹窗" >

Modal body content

@@ -336,7 +336,7 @@ export const componentRegistry: ComponentCategory[] = [ }, { id: 'alert-demo', - name: 'Alert - ????', + name: 'Alert - 四种类型', description: 'Demo', category: 'feedback', component: () => ( @@ -350,8 +350,8 @@ export const componentRegistry: ComponentCategory[] = [ }, { id: 'stream-text-demo', - name: 'StreamText - ??????', - description: 'AI ????????', + name: 'StreamText - 流式文本演示', + description: 'AI 流式文本打字机效果', category: 'feedback', component: () => { const [key, setKey] = React.useState(0); @@ -381,7 +381,7 @@ export const componentRegistry: ComponentCategory[] = [ variant="secondary" onClick={() => setKey(prev => prev + 1)} > - ?? ???? + 重新播放
); @@ -391,8 +391,8 @@ export const componentRegistry: ComponentCategory[] = [ }, { id: 'form', - name: '????', - description: '???????', + name: '表单组件', + description: '输入类表单组件', layoutType: 'grid-2', components: [ { @@ -405,7 +405,7 @@ name: 'Input - Demo', setValue(val)} /> { const [value, setValue] = React.useState(''); @@ -549,7 +549,7 @@ name: 'Search - Demo', { label: 'Svelte', value: 'svelte' }, { label: 'Solid', value: 'solid' }, ]} - placeholder="????" + placeholder="选择技术" value={multiValue} onChange={(v) => setMultiValue(v as (string | number)[])} clearable @@ -579,8 +579,8 @@ name: 'Search - Demo', }, { id: 'select-searchable', -name: 'Select - Demo', - description: '??????????', + name: 'Select - Demo', + description: '可搜索的选择器示例', category: 'form', component: () => { const [value, setValue] = React.useState(''); @@ -616,8 +616,8 @@ name: 'Select - Demo', }, { id: 'select-grouped', - name: 'Select - ????', - description: '????????', + name: 'Select - 分组选择', + description: '带分组的选择器', category: 'form', component: () => { const [value, setValue] = React.useState(''); @@ -637,10 +637,10 @@ name: 'Select - Demo', return (