From 31757a7800c05b317a4b4cbe5916e00901bb026d Mon Sep 17 00:00:00 2001 From: GCWing Date: Wed, 18 Feb 2026 09:55:57 +0800 Subject: [PATCH 01/21] fix(git): optimize performance for repositories with many untracked files - Rust: use two-pass strategy in get_file_statuses to skip recursive untracked dir scanning when top-level count exceeds 200 - GitPanel: collapse untracked group by default and cap rendered list at 200 items - LeftPanel: defer mounting of heavy panels until first visit - useFileTreeGitSync: optimize git status matching from O(N*M) to O(N+M) --- src/crates/core/src/service/git/git_utils.rs | 36 ++- .../src/app/components/panels/LeftPanel.tsx | 51 +++-- src/web-ui/src/locales/en-US/panels/git.json | 3 +- src/web-ui/src/locales/zh-CN/panels/git.json | 3 +- .../file-system/hooks/useFileTreeGitSync.ts | 84 +++---- .../git/components/GitPanel/GitPanel.tsx | 216 ++++++++++-------- .../src/tools/git/state/GitStateManager.ts | 3 - 7 files changed, 239 insertions(+), 157 deletions(-) diff --git a/src/crates/core/src/service/git/git_utils.rs b/src/crates/core/src/service/git/git_utils.rs index 1d9916b9..29b665d6 100644 --- a/src/crates/core/src/service/git/git_utils.rs +++ b/src/crates/core/src/service/git/git_utils.rs @@ -93,12 +93,18 @@ pub fn status_to_string(status: Status) -> String { } } -/// Returns file statuses. -pub fn get_file_statuses(repo: &Repository) -> Result, GitError> { +/// Maximum number of untracked entries before we stop recursing into untracked +/// directories. When the non-recursive scan already reports many untracked +/// top-level entries, recursing would return thousands of paths that bloat IPC +/// payloads and DOM rendering, causing severe UI lag. +const UNTRACKED_RECURSE_THRESHOLD: usize = 200; + +/// Collects file statuses from a `StatusOptions` scan. +fn collect_statuses(repo: &Repository, recurse_untracked: bool) -> Result, GitError> { let mut status_options = StatusOptions::new(); status_options.include_untracked(true); status_options.include_ignored(false); - status_options.recurse_untracked_dirs(true); + status_options.recurse_untracked_dirs(recurse_untracked); let statuses = repo .statuses(Some(&mut status_options)) @@ -161,6 +167,30 @@ pub fn get_file_statuses(repo: &Repository) -> Result, GitErr Ok(result) } +/// Returns file statuses. +/// +/// Uses a two-pass strategy to avoid expensive recursive scans when the +/// repository contains many untracked files (e.g. missing .gitignore for +/// build artifacts). First a non-recursive pass counts top-level untracked +/// entries; only when that count is within `UNTRACKED_RECURSE_THRESHOLD` does +/// a second recursive pass run. +pub fn get_file_statuses(repo: &Repository) -> Result, GitError> { + // Pass 1: fast non-recursive scan. + let shallow = collect_statuses(repo, false)?; + + let untracked_count = shallow.iter().filter(|f| f.status.contains('?')).count(); + + if untracked_count <= UNTRACKED_RECURSE_THRESHOLD { + // Few untracked entries – safe to recurse for full detail. + collect_statuses(repo, true) + } else { + // Too many untracked entries – return the shallow result as-is. + // Untracked directories appear as a single entry (folder name with + // trailing slash) which is sufficient for the UI. + Ok(shallow) + } +} + /// Executes a Git command. pub async fn execute_git_command(repo_path: &str, args: &[&str]) -> Result { let output = crate::util::process_manager::create_tokio_command("git") diff --git a/src/web-ui/src/app/components/panels/LeftPanel.tsx b/src/web-ui/src/app/components/panels/LeftPanel.tsx index 364c5edf..5a0ea22f 100644 --- a/src/web-ui/src/app/components/panels/LeftPanel.tsx +++ b/src/web-ui/src/app/components/panels/LeftPanel.tsx @@ -1,9 +1,14 @@ /** * Left panel component * Singleton display panel - only displays one active panel content + * + * Uses a "mount-on-first-visit" strategy: heavy panels (git, project-context) + * are only mounted when the user first navigates to them, and stay mounted + * afterwards so that internal state is preserved. Lightweight panels (sessions, + * files, terminal) are always mounted for instant switching. */ -import React, { memo } from 'react'; +import React, { memo, useState, useEffect } from 'react'; import { PanelType } from '../../types'; import { GitPanel } from '../../../tools/git'; @@ -23,6 +28,9 @@ interface LeftPanelProps { isDragging?: boolean; } +/** Panels that are always mounted for instant response. */ +const ALWAYS_MOUNT: Set = new Set(['sessions', 'files', 'terminal']); + const LeftPanel: React.FC = ({ activeTab, width: _width, @@ -31,6 +39,19 @@ const LeftPanel: React.FC = ({ onSwitchTab: _onSwitchTab, isDragging: _isDragging = false }) => { + const [mountedTabs, setMountedTabs] = useState>( + () => new Set([...ALWAYS_MOUNT, activeTab]) + ); + + useEffect(() => { + setMountedTabs(prev => { + if (prev.has(activeTab)) return prev; + const next = new Set(prev); + next.add(activeTab); + return next; + }); + }, [activeTab]); + return (
= ({ />
-
- -
+ {mountedTabs.has('git') && ( +
+ +
+ )} -
- -
+ {mountedTabs.has('project-context') && ( +
+ +
+ )}
diff --git a/src/web-ui/src/locales/en-US/panels/git.json b/src/web-ui/src/locales/en-US/panels/git.json index 6239472d..2974e97e 100644 --- a/src/web-ui/src/locales/en-US/panels/git.json +++ b/src/web-ui/src/locales/en-US/panels/git.json @@ -174,7 +174,8 @@ "stagedWithCount": "Staged ({{count}})", "unstagedWithFilter": "Unstaged ({{filtered}} / {{total}})", "untrackedWithFilter": "Untracked ({{filtered}} / {{total}})", - "stagedWithFilter": "Staged ({{filtered}} / {{total}})" + "stagedWithFilter": "Staged ({{filtered}} / {{total}})", + "moreFiles": "{{count}} more files not shown..." }, "commit": { "inputPlaceholder": "Enter commit message...", diff --git a/src/web-ui/src/locales/zh-CN/panels/git.json b/src/web-ui/src/locales/zh-CN/panels/git.json index ec7684c3..8c3bdfb3 100644 --- a/src/web-ui/src/locales/zh-CN/panels/git.json +++ b/src/web-ui/src/locales/zh-CN/panels/git.json @@ -174,7 +174,8 @@ "stagedWithCount": "已暂存 ({{count}})", "unstagedWithFilter": "未暂存 ({{filtered}} / {{total}})", "untrackedWithFilter": "未跟踪 ({{filtered}} / {{total}})", - "stagedWithFilter": "已暂存 ({{filtered}} / {{total}})" + "stagedWithFilter": "已暂存 ({{filtered}} / {{total}})", + "moreFiles": "还有 {{count}} 个文件未显示..." }, "commit": { "inputPlaceholder": "输入提交信息...", diff --git a/src/web-ui/src/tools/file-system/hooks/useFileTreeGitSync.ts b/src/web-ui/src/tools/file-system/hooks/useFileTreeGitSync.ts index 1d96082d..000dc031 100644 --- a/src/web-ui/src/tools/file-system/hooks/useFileTreeGitSync.ts +++ b/src/web-ui/src/tools/file-system/hooks/useFileTreeGitSync.ts @@ -52,28 +52,6 @@ function parseGitStatusFromBackend( return undefined; } -/** - * Check if paths match (supports both absolute and relative). - */ -function pathMatches(nodePath: string, gitPath: string): boolean { - const normalizedNodePath = nodePath.replace(/\\/g, '/'); - const normalizedGitPath = gitPath.replace(/\\/g, '/'); - - if (normalizedNodePath === normalizedGitPath) return true; - - if (normalizedNodePath.endsWith('/' + normalizedGitPath) || - normalizedNodePath.endsWith(normalizedGitPath)) { - return true; - } - - if (normalizedGitPath.endsWith('/' + normalizedNodePath) || - normalizedGitPath.endsWith(normalizedNodePath)) { - return true; - } - - return false; -} - function collectChildrenGitStatuses(node: FileSystemNode): Set { const statuses = new Set(); @@ -106,51 +84,72 @@ function buildGitStatusMap( gitState.staged?.forEach(file => { const status = parseGitStatusFromBackend(file.status, file.index_status, file.workdir_status); if (status) { - gitStatusMap.set(file.path, { status: file.status, gitStatus: status }); + gitStatusMap.set(file.path.replace(/\\/g, '/'), { status: file.status, gitStatus: status }); } }); gitState.unstaged?.forEach(file => { const status = parseGitStatusFromBackend(file.status, file.index_status, file.workdir_status); - if (status && !gitStatusMap.has(file.path)) { - gitStatusMap.set(file.path, { status: file.status, gitStatus: status }); + const normalizedPath = file.path.replace(/\\/g, '/'); + if (status && !gitStatusMap.has(normalizedPath)) { + gitStatusMap.set(normalizedPath, { status: file.status, gitStatus: status }); } }); gitState.untracked?.forEach(filePath => { - if (!gitStatusMap.has(filePath)) { - gitStatusMap.set(filePath, { status: '??', gitStatus: 'untracked' }); + const normalizedPath = filePath.replace(/\\/g, '/'); + if (!gitStatusMap.has(normalizedPath)) { + gitStatusMap.set(normalizedPath, { status: '??', gitStatus: 'untracked' }); } }); return gitStatusMap; } +function getNodeStatusInfo( + nodePath: string, + workspacePath: string | undefined, + gitStatusMap: Map }> +): { status: string; gitStatus: ReturnType } | undefined { + const normalizedNodePath = nodePath.replace(/\\/g, '/'); + + const absoluteMatch = gitStatusMap.get(normalizedNodePath); + if (absoluteMatch) return absoluteMatch; + + if (!workspacePath) return undefined; + + const normalizedWorkspacePath = workspacePath.replace(/\\/g, '/').replace(/\/+$/, ''); + if (!normalizedNodePath.startsWith(`${normalizedWorkspacePath}/`)) { + return undefined; + } + + const relativePath = normalizedNodePath.slice(normalizedWorkspacePath.length + 1); + return gitStatusMap.get(relativePath); +} + /** * Update file tree nodes with Git status. Stores complete Git info regardless of expansion state. */ function updateNodeGitStatus( nodes: FileSystemNode[], - gitStatusMap: Map }> + gitStatusMap: Map }>, + workspacePath?: string ): FileSystemNode[] { return nodes.map(node => { let updatedNode = { ...node }; - let matched = false; + const statusInfo = getNodeStatusInfo(node.path, workspacePath, gitStatusMap); + const matched = !!statusInfo; - for (const [gitPath, statusInfo] of gitStatusMap.entries()) { - if (pathMatches(node.path, gitPath)) { - updatedNode = { - ...updatedNode, - gitStatus: statusInfo.gitStatus, - gitStatusText: statusInfo.status - }; - matched = true; - break; - } + if (statusInfo) { + updatedNode = { + ...updatedNode, + gitStatus: statusInfo.gitStatus, + gitStatusText: statusInfo.status + }; } if (node.children && node.children.length > 0) { - updatedNode.children = updateNodeGitStatus(node.children, gitStatusMap); + updatedNode.children = updateNodeGitStatus(node.children, gitStatusMap, workspacePath); const childStatuses = collectChildrenGitStatuses(updatedNode); @@ -219,7 +218,8 @@ export function useFileTreeGitSync({ try { const gitStatusMap = buildGitStatusMap(state); - const updatedTree = updateNodeGitStatus(targetTree, gitStatusMap); + const updatedTree = updateNodeGitStatus(targetTree, gitStatusMap, workspacePath); + onTreeUpdateRef.current(updatedTree); log.debug('Git status applied to file tree', { @@ -240,7 +240,7 @@ export function useFileTreeGitSync({ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); debounceTimerRef.current = setTimeout(doApply, debounceDelay); } - }, [debounceDelay]); + }, [debounceDelay, workspacePath]); const applyGitStatusImmediate = useCallback(() => { applyGitStatusToTree(treeRef.current, gitStateRef.current, true); diff --git a/src/web-ui/src/tools/git/components/GitPanel/GitPanel.tsx b/src/web-ui/src/tools/git/components/GitPanel/GitPanel.tsx index 0cba87e7..cdfaf422 100644 --- a/src/web-ui/src/tools/git/components/GitPanel/GitPanel.tsx +++ b/src/web-ui/src/tools/git/components/GitPanel/GitPanel.tsx @@ -83,7 +83,9 @@ const GitPanel: React.FC = ({ const [loadingDetails, setLoadingDetails] = useState(false); - const [expandedFileGroups, setExpandedFileGroups] = useState>(new Set(['unstaged', 'untracked', 'staged'])); + const [expandedFileGroups, setExpandedFileGroups] = useState>(new Set(['unstaged', 'staged'])); + + const MAX_RENDERED_FILES = 200; const [expandedCommits, setExpandedCommits] = useState>(new Set()); @@ -1253,53 +1255,66 @@ const GitPanel: React.FC = ({
- {expandedFileGroups.has('unstaged') && filteredFiles.unstaged.map((file, index) => { - const statusInfo = getFileStatusInfo(file.status); - const { fileName, dirPath } = getFileNameAndDir(file.path); - const isSelected = selectedFiles.has(file.path); - const isLoading = loadingDiffFiles.has(file.path); + {expandedFileGroups.has('unstaged') && (() => { + const visibleFiles = filteredFiles.unstaged.slice(0, MAX_RENDERED_FILES); + const hiddenCount = filteredFiles.unstaged.length - visibleFiles.length; return ( -
!isLoading && handleOpenFileDiff(file.path, file.status)} - title={isLoading ? t('common.loading') : t('tooltips.viewDiff')} - > - - {fileName} - {dirPath && {dirPath}} - - {statusInfo.text} - - { - e.stopPropagation(); - handleDiscardFile(file.path, 'unstaged'); - }} - disabled={isOperating} - tooltip={t('actions.discardFile')} - > - - -
+ <> + {visibleFiles.map((file, index) => { + const statusInfo = getFileStatusInfo(file.status); + const { fileName, dirPath } = getFileNameAndDir(file.path); + const isSelected = selectedFiles.has(file.path); + const isLoading = loadingDiffFiles.has(file.path); + return ( +
!isLoading && handleOpenFileDiff(file.path, file.status)} + title={isLoading ? t('common.loading') : t('tooltips.viewDiff')} + > + + {fileName} + {dirPath && {dirPath}} + + {statusInfo.text} + + { + e.stopPropagation(); + handleDiscardFile(file.path, 'unstaged'); + }} + disabled={isOperating} + tooltip={t('actions.discardFile')} + > + + +
+ ); + })} + {hiddenCount > 0 && ( +
+ {t('fileGroups.moreFiles', { count: hiddenCount })} +
+ )} + ); - })} + })()} )} {filteredFiles.untracked.length > 0 && ( @@ -1323,56 +1338,69 @@ const GitPanel: React.FC = ({ } - {expandedFileGroups.has('untracked') && filteredFiles.untracked.map((filePath, index) => { - const { fileName, dirPath } = getFileNameAndDir(filePath); - const isSelected = selectedFiles.has(filePath); - const isLoading = loadingDiffFiles.has(filePath); + {expandedFileGroups.has('untracked') && (() => { + const visibleFiles = filteredFiles.untracked.slice(0, MAX_RENDERED_FILES); + const hiddenCount = filteredFiles.untracked.length - visibleFiles.length; return ( -
!isLoading && handleOpenFileDiff(filePath, 'Untracked')} - title={isLoading ? t('common.loading') : t('tooltips.viewDiff')} - > - { - e.stopPropagation(); - toggleFileSelection(filePath); - }} - tooltip={isSelected ? t('selection.deselectFile') : t('selection.selectFile')} - > - {isSelected ? ( - - ) : ( - - )} - - {fileName} - {dirPath && {dirPath}} - - - U - - - { - e.stopPropagation(); - handleDiscardFile(filePath, 'untracked'); - }} - disabled={isOperating} - tooltip={t('actions.deleteFile')} - > - - -
+ <> + {visibleFiles.map((filePath, index) => { + const { fileName, dirPath } = getFileNameAndDir(filePath); + const isSelected = selectedFiles.has(filePath); + const isLoading = loadingDiffFiles.has(filePath); + return ( +
!isLoading && handleOpenFileDiff(filePath, 'Untracked')} + title={isLoading ? t('common.loading') : t('tooltips.viewDiff')} + > + { + e.stopPropagation(); + toggleFileSelection(filePath); + }} + tooltip={isSelected ? t('selection.deselectFile') : t('selection.selectFile')} + > + {isSelected ? ( + + ) : ( + + )} + + {fileName} + {dirPath && {dirPath}} + + + U + + + { + e.stopPropagation(); + handleDiscardFile(filePath, 'untracked'); + }} + disabled={isOperating} + tooltip={t('actions.deleteFile')} + > + + +
+ ); + })} + {hiddenCount > 0 && ( +
+ {t('fileGroups.moreFiles', { count: hiddenCount })} +
+ )} + ); - })} + })()} )} {filteredFiles.staged.length > 0 && ( diff --git a/src/web-ui/src/tools/git/state/GitStateManager.ts b/src/web-ui/src/tools/git/state/GitStateManager.ts index b8e5aaab..340d50e3 100644 --- a/src/web-ui/src/tools/git/state/GitStateManager.ts +++ b/src/web-ui/src/tools/git/state/GitStateManager.ts @@ -357,7 +357,6 @@ export class GitStateManager { log.debug('Starting refresh', { repositoryPath, layersToRefresh, reason }); - const refreshPromise = (async () => { try { @@ -426,7 +425,6 @@ export class GitStateManager { */ private async refreshBasicAndStatus(repositoryPath: string): Promise { try { - const isRepo = await gitAPI.isGitRepository(repositoryPath); if (!isRepo) { @@ -444,7 +442,6 @@ export class GitStateManager { return; } - const status = await gitAPI.getStatus(repositoryPath); const hasChanges = From bdde968eb23da3180dbfa189509c91c23ac9cb01 Mon Sep 17 00:00:00 2001 From: GCWing Date: Thu, 19 Feb 2026 12:50:18 +0800 Subject: [PATCH 02/21] feat(panels): add capabilities view, workflow editor, and chat panel toggle - SessionsPanel: add capabilities view tab showing skills, subagents, and MCP servers with status indicators, copy path, and refresh support - Add new WorkflowsPanel and WorkflowEditor components - Header: add center chat panel toggle button (PanelCenterIcon), support chatCollapsed state - Rename view modes from agentic/editor to cowork/coder; remove toggleViewMode in favor of setViewMode - WorkspaceLayout: replace isEditorMode logic with chatCollapsed state for panel layout calculations - i18n: add locale keys for capabilities panel and sessions in en-US/zh-CN - Update MCP, skill, and subagent APIs --- package-lock.json | 4 +- src/web-ui/src/app/App.tsx | 2 +- .../components/BottomBar/AppBottomBar.scss | 16 +- .../app/components/BottomBar/AppBottomBar.tsx | 21 +- .../src/app/components/Header/AgentOrb.scss | 2 +- .../src/app/components/Header/AgentOrb.tsx | 7 +- .../src/app/components/Header/Header.scss | 578 +++++------ .../src/app/components/Header/Header.tsx | 300 +++--- .../src/app/components/Header/PanelIcons.tsx | 44 + .../src/app/components/panels/LeftPanel.tsx | 7 +- .../app/components/panels/SessionsPanel.scss | 848 ++++++++++++++++- .../app/components/panels/SessionsPanel.tsx | 866 ++++++++++++----- .../components/panels/base/FlexiblePanel.tsx | 12 + .../components/panels/base/PanelHeader.scss | 2 +- .../src/app/components/panels/base/types.ts | 1 + .../src/app/components/panels/base/utils.ts | 11 +- .../panels/content-canvas/types/content.ts | 1 + src/web-ui/src/app/components/panels/index.ts | 1 + .../panels/workflows/WorkflowEditor.scss | 898 ++++++++++++++++++ .../panels/workflows/WorkflowEditor.tsx | 851 +++++++++++++++++ .../panels/workflows/WorkflowsPanel.scss | 552 +++++++++++ .../panels/workflows/WorkflowsPanel.tsx | 327 +++++++ .../app/components/panels/workflows/index.ts | 3 + .../components/panels/workflows/mockData.ts | 216 +++++ .../app/components/panels/workflows/types.ts | 76 ++ src/web-ui/src/app/hooks/useApp.ts | 14 +- src/web-ui/src/app/layout/AppLayout.scss | 17 +- src/web-ui/src/app/layout/AppLayout.tsx | 18 +- .../src/app/layout/WorkspaceLayout.scss | 17 + src/web-ui/src/app/layout/WorkspaceLayout.tsx | 32 +- src/web-ui/src/app/types/index.ts | 5 +- .../components/Textarea/Textarea.scss | 56 +- .../components/CurrentSessionTitle.scss | 1 - .../src/flow_chat/components/WelcomePanel.css | 11 - .../services/ProcessingStatusManager.ts | 19 +- .../contexts/ViewModeContext.tsx | 23 +- .../infrastructure/i18n/core/I18nService.ts | 5 + src/web-ui/src/locales/en-US/common.json | 8 + src/web-ui/src/locales/en-US/components.json | 3 +- src/web-ui/src/locales/en-US/flow-chat.json | 3 + .../src/locales/en-US/panels/sessions.json | 31 +- .../src/locales/en-US/panels/workflows.json | 148 +++ src/web-ui/src/locales/zh-CN/common.json | 8 + src/web-ui/src/locales/zh-CN/components.json | 3 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 3 + .../src/locales/zh-CN/panels/sessions.json | 31 +- .../src/locales/zh-CN/panels/workflows.json | 148 +++ .../shared/services/tool-execution-service.ts | 17 +- src/web-ui/src/shared/utils/tabUtils.ts | 24 + .../editor/services/EditorOptionsBuilder.ts | 4 + .../ProjectContextControlBar.scss | 4 +- .../ProjectContextPanel.scss | 10 +- 52 files changed, 5465 insertions(+), 844 deletions(-) create mode 100644 src/web-ui/src/app/components/panels/workflows/WorkflowEditor.scss create mode 100644 src/web-ui/src/app/components/panels/workflows/WorkflowEditor.tsx create mode 100644 src/web-ui/src/app/components/panels/workflows/WorkflowsPanel.scss create mode 100644 src/web-ui/src/app/components/panels/workflows/WorkflowsPanel.tsx create mode 100644 src/web-ui/src/app/components/panels/workflows/index.ts create mode 100644 src/web-ui/src/app/components/panels/workflows/mockData.ts create mode 100644 src/web-ui/src/app/components/panels/workflows/types.ts create mode 100644 src/web-ui/src/locales/en-US/panels/workflows.json create mode 100644 src/web-ui/src/locales/zh-CN/panels/workflows.json diff --git a/package-lock.json b/package-lock.json index 9fc0b55c..0879afde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "BitFun", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "BitFun", - "version": "0.1.0", + "version": "0.1.1", "hasInstallScript": true, "dependencies": { "@codemirror/autocomplete": "^6.18.7", diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 47d47aa1..b4d10570 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -215,7 +215,7 @@ function App() { // Unified layout via a single AppLayout return ( - + {/* Onboarding overlay (first launch) */} {isOnboardingActive && ( diff --git a/src/web-ui/src/app/components/BottomBar/AppBottomBar.scss b/src/web-ui/src/app/components/BottomBar/AppBottomBar.scss index 49ade050..8a50cf79 100644 --- a/src/web-ui/src/app/components/BottomBar/AppBottomBar.scss +++ b/src/web-ui/src/app/components/BottomBar/AppBottomBar.scss @@ -430,10 +430,12 @@ $_input-min-height: 32px; // Breathing animation in collapsed state .bitfun-bottom-bar:not(.is-expanded) &:not(.is-focused):not(:hover):not(.is-processing):not(.has-content):not(:focus-within):not(.is-pinned-expanded):not(.is-global-expanded)::before { animation: outerRingBreath 3s ease-in-out infinite; + will-change: opacity, transform; } .bitfun-bottom-bar:not(.is-expanded) &:not(.is-focused):not(:hover):not(.is-processing):not(.has-content):not(:focus-within):not(.is-pinned-expanded):not(.is-global-expanded)::after { animation: innerRingBreath 3s ease-in-out infinite 0.5s; + will-change: opacity, transform; } // Hide rings on hover/focus @@ -928,12 +930,10 @@ $_input-min-height: 32px; 0%, 100% { opacity: 0.3; transform: scale(1); - border-color: rgba(255, 255, 255, 0.08); } 50% { opacity: 0.6; transform: scale(1.05); - border-color: rgba(255, 255, 255, 0.15); } } @@ -941,12 +941,10 @@ $_input-min-height: 32px; 0%, 100% { opacity: 0.2; transform: translate(-50%, -50%) scale(1); - border-color: rgba(255, 255, 255, 0.05); } 50% { opacity: 0.5; transform: translate(-50%, -50%) scale(1.1); - border-color: rgba(255, 255, 255, 0.12); } } @@ -954,13 +952,10 @@ $_input-min-height: 32px; 0%, 100% { opacity: 0.6; transform: scale(1); - border-color: var(--color-accent-300); } 50% { opacity: 1; transform: scale(1.15); - border-color: var(--color-accent-700); - box-shadow: 0 0 20px $color-accent-300; } } @@ -968,13 +963,10 @@ $_input-min-height: 32px; 0%, 100% { opacity: 0.5; transform: translate(-50%, -50%) scale(1); - border-color: var(--color-purple-400); } 50% { opacity: 0.9; transform: translate(-50%, -50%) scale(1.2); - border-color: rgba(139, 92, 246, 0.9); - box-shadow: 0 0 15px $color-purple-300; } } @@ -982,12 +974,10 @@ $_input-min-height: 32px; 0%, 100% { opacity: 0.6; transform: scaleY(1); - box-shadow: 0 0 10px $color-accent-300; } 50% { opacity: 1; transform: scaleY(1.5); - box-shadow: 0 0 20px $color-accent-400; } } @@ -995,12 +985,10 @@ $_input-min-height: 32px; 0%, 100% { opacity: 0.8; transform: scaleY(1.5); - box-shadow: 0 0 15px rgba(239, 68, 68, 0.4); } 50% { opacity: 1; transform: scaleY(2); - box-shadow: 0 0 25px rgba(239, 68, 68, 0.6); } } diff --git a/src/web-ui/src/app/components/BottomBar/AppBottomBar.tsx b/src/web-ui/src/app/components/BottomBar/AppBottomBar.tsx index 2a62698e..e4ac1fe5 100644 --- a/src/web-ui/src/app/components/BottomBar/AppBottomBar.tsx +++ b/src/web-ui/src/app/components/BottomBar/AppBottomBar.tsx @@ -15,7 +15,9 @@ import { MessageSquare, MessageSquareText, Terminal, - TerminalSquare + TerminalSquare, + Workflow, + SquareKanban, } from 'lucide-react'; import { useApp } from '../../hooks/useApp'; import { PanelType } from '../../types'; @@ -145,6 +147,23 @@ const AppBottomBar: React.FC = ({ + {/* Workflows tab */} + + + + {/* File tree tab */} + + + + + ); @@ -260,18 +298,30 @@ const Header: React.FC = ({ }; }, [isMacOS, handleOpenProject, handleNewProject, handleGoHome, handleShowAbout]); - // Menu hover: expand - const handleMenuHoverEnter = useCallback(() => { - if (!menuPinned) { - setShowHorizontalMenu(true); - } - }, [menuPinned]); + // Close popup menu on outside click / Escape + useEffect(() => { + if (!showLogoMenu) return; - const handleMenuHoverLeave = useCallback(() => { - if (!menuPinned) { - setShowHorizontalMenu(false); - } - }, [menuPinned]); + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node | null; + if (!target) return; + if (logoMenuContainerRef.current?.contains(target)) return; + setShowLogoMenu(false); + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setShowLogoMenu(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [showLogoMenu]); // Horizontal menu items (no separators) const horizontalMenuItems = [ @@ -305,7 +355,7 @@ const Header: React.FC = ({ return ( <>
= ({
{/* macOS: move items to system menubar; hide custom menu button; move toggle to right */} {!isMacOS && ( -
- {/* Logo: used for mode switch with independent hover effect */} - {agentOrbNode} - - {/* Menu area: hoverable region to keep menu open on pointer move */} -
+ {/* Logo: used as the menu trigger */} + {menuOrbNode} + {modeSwitchNode} + + {/* Popup menu items */} +
- {/* Orb menu button: expand on hover */} - - - - - {/* Expanded horizontal menu items */} -
- {horizontalMenuItems.map((item, index) => ( - - {index > 0 &&
} - - - ))} -
+ {horizontalMenuItems.map((item, index) => ( + + {index > 0 &&
} + + + ))}
)} @@ -367,71 +398,82 @@ const Header: React.FC = ({
- {/* Current session title: only in Agentic mode */} - {isAgenticMode && } + {/* Current session title: show whenever chat panel is visible */} + {!chatCollapsed && } - {/* Global search: only in Editor mode */} - {isEditorMode && } + {/* Global search: show in coder layout when chat is hidden */} + {chatCollapsed && }
- {/* Immersive panel toggles: unified icon */} -
- + + + + + + + + + + + {/* Toolbar mode toggle: available when chat panel is visible */} + {!chatCollapsed && ( + + - - {/* Divider */} - - - {/* Right indicator */} - - { - e.stopPropagation(); - onToggleRightPanel(); - }} - > - - - - - {/* Toolbar mode toggle: only in Agentic mode */} - {isAgenticMode && ( - <> - - - { - e.stopPropagation(); - enableToolbarMode(); - }} - > - - - - - )} - + )}
{/* Config center button */} @@ -449,8 +491,8 @@ const Header: React.FC = ({ - {/* macOS: move Agentic/Editor toggle to the far right (after config button) */} - {isMacOS && agentOrbNode} + {/* macOS: keep mode switch accessible in the right section */} + {isMacOS && modeSwitchNode} {/* Window controls (macOS uses native traffic lights; hide custom buttons) */} {!isMacOS && ( diff --git a/src/web-ui/src/app/components/Header/PanelIcons.tsx b/src/web-ui/src/app/components/Header/PanelIcons.tsx index 7c79814e..eea9af0a 100644 --- a/src/web-ui/src/app/components/Header/PanelIcons.tsx +++ b/src/web-ui/src/app/components/Header/PanelIcons.tsx @@ -92,3 +92,47 @@ export const PanelRightIcon: React.FC = ({ ); }; +/** + * Center panel icon. + * - filled=false: center strip is outlined. + * - filled=true: center strip is filled. + */ +export const PanelCenterIcon: React.FC = ({ + size = 14, + filled = false, + className = '' +}) => { + return ( + + {/* Outer frame */} + + {/* Center section dividers */} + + + {/* Active state: fill middle area */} + {filled && ( + + )} + + ); +}; + diff --git a/src/web-ui/src/app/components/panels/LeftPanel.tsx b/src/web-ui/src/app/components/panels/LeftPanel.tsx index 5a0ea22f..b71d9422 100644 --- a/src/web-ui/src/app/components/panels/LeftPanel.tsx +++ b/src/web-ui/src/app/components/panels/LeftPanel.tsx @@ -16,6 +16,7 @@ import { ProjectContextPanel } from '../../../tools/project-context'; import { FilesPanel } from './'; import SessionsPanel from './SessionsPanel'; import TerminalSessionsPanel from './TerminalSessionsPanel'; +import { WorkflowsPanel } from './workflows'; import './LeftPanel.scss'; @@ -29,7 +30,7 @@ interface LeftPanelProps { } /** Panels that are always mounted for instant response. */ -const ALWAYS_MOUNT: Set = new Set(['sessions', 'files', 'terminal']); +const ALWAYS_MOUNT: Set = new Set(['sessions', 'workflows', 'files', 'terminal']); const LeftPanel: React.FC = ({ activeTab, @@ -61,6 +62,10 @@ const LeftPanel: React.FC = ({
+
+ +
+
=360px) @container sessions-panel (min-width: 360px) { .bitfun-sessions-panel { + &__tabs { + .bitfun-tabs__nav { + padding: $size-gap-3 $size-gap-4; + + &::after { + left: $size-gap-4; + right: $size-gap-4; + } + } + } + + &__capabilities-view { + padding: $size-gap-3 $size-gap-4; + gap: $size-gap-3; + } + + &__cap-seg { + padding: 6px 8px; + font-size: 12px; + } + + &__cap-row { + padding: 8px 10px; + } + + &__cap-row-name { + font-size: $font-size-sm; + } + &__search { padding: $size-gap-3 $size-gap-4; } @@ -548,6 +1361,10 @@ padding: $size-gap-3; } + &__capability-section { + padding: $size-gap-3 $size-gap-4; + } + &__item { padding: $size-gap-2 $size-gap-4; margin: 0 $size-gap-2 $size-gap-1 $size-gap-2; @@ -559,7 +1376,7 @@ } &__item-title { - font-size: $font-size-base; + font-size: $font-size-sm; } &__item-preview { @@ -601,3 +1418,12 @@ transform: rotate(360deg); } } + +@keyframes bitfun-cap-dot-pulse { + 0%, 100% { + opacity: 0.7; + } + 50% { + opacity: 1; + } +} diff --git a/src/web-ui/src/app/components/panels/SessionsPanel.tsx b/src/web-ui/src/app/components/panels/SessionsPanel.tsx index 52c671cc..3896ac5a 100644 --- a/src/web-ui/src/app/components/panels/SessionsPanel.tsx +++ b/src/web-ui/src/app/components/panels/SessionsPanel.tsx @@ -5,23 +5,33 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Plus, ChevronDown, ChevronRight, Pencil, Check, Loader2 } from 'lucide-react'; +import { Plus, Pencil, Check, Loader2, Puzzle, Bot, Plug, AlertTriangle, MessageSquareText, RefreshCw, Copy } from 'lucide-react'; 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 { stateMachineManager } from '../../../flow_chat/state-machine/SessionStateMachineManager'; import { SessionExecutionState } from '../../../flow_chat/state-machine/types'; -import { Search, Button, IconButton, Tooltip } from '@/component-library'; +import { Search, Button, IconButton, Tooltip, Switch, Tabs, TabPane, Card, CardBody } from '@/component-library'; import { PanelHeader } from './base'; import { createLogger } from '@/shared/utils/logger'; +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import { SubagentAPI, type SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI'; +import { MCPAPI, type MCPServerInfo } from '@/infrastructure/api/service-api/MCPAPI'; +import type { SkillInfo } from '@/infrastructure/config/types'; +import { useNotification } from '@/shared/notification-system'; import './SessionsPanel.scss'; const log = createLogger('SessionsPanel'); const ONE_HOUR_MS = 60 * 60 * 1000; +const MCP_HEALTHY_STATUSES = new Set(['connected', 'healthy']); + +type CapabilityPanelType = 'skills' | 'subagents' | 'mcp' | null; +type SessionPanelViewMode = 'sessions' | 'capabilities'; const SessionsPanel: React.FC = () => { const { t, i18n } = useTranslation('panels/sessions'); + const { error: notifyError, success: notifySuccess } = useNotification(); const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState() @@ -34,6 +44,34 @@ const SessionsPanel: React.FC = () => { const [editingTitle, setEditingTitle] = useState(''); const editInputRef = useRef(null); const [processingSessionIds, setProcessingSessionIds] = useState>(() => new Set()); + const [skills, setSkills] = useState([]); + const [subagents, setSubagents] = useState([]); + const [mcpServers, setMcpServers] = useState([]); + const [activeCapabilityPanel, setActiveCapabilityPanel] = useState('skills'); + const [viewMode, setViewMode] = useState('sessions'); + const [isCapRefreshing, setIsCapRefreshing] = useState(false); + const [expandedCapIds, setExpandedCapIds] = useState>(() => new Set()); + + const toggleCapExpanded = useCallback((id: string) => { + setExpandedCapIds(prev => { + const next = new Set(prev); + if (next.has(id)) { next.delete(id); } else { next.add(id); } + return next; + }); + }, []); + + const [copiedPath, setCopiedPath] = useState(null); + + const handleCopyPath = useCallback(async (path: string) => { + try { + await navigator.clipboard.writeText(path); + setCopiedPath(path); + notifySuccess(t('capabilities.pathCopied')); + setTimeout(() => setCopiedPath(null), 2000); + } catch { + notifyError(t('capabilities.pathCopyFailed')); + } + }, [notifySuccess, notifyError, t]); useEffect(() => { const unsubscribe = flowChatStore.subscribe((state) => { @@ -45,6 +83,28 @@ const SessionsPanel: React.FC = () => { }; }, []); + const loadCapabilities = useCallback(async (silent = false) => { + try { + const [skillList, subagentList, serverList] = await Promise.all([ + configAPI.getSkillConfigs(), + SubagentAPI.listSubagents(), + MCPAPI.getServers(), + ]); + setSkills(skillList); + setSubagents(subagentList); + setMcpServers(serverList); + } catch (error) { + log.error('Failed to load capabilities', error); + if (!silent) { + notifyError(t('capabilities.loadFailed')); + } + } + }, [notifyError, t]); + + useEffect(() => { + loadCapabilities(); + }, [loadCapabilities]); + useEffect(() => { const unsubscribe = stateMachineManager.subscribeGlobal((sessionId, machine) => { const isProcessing = machine.currentState === SessionExecutionState.PROCESSING; @@ -122,6 +182,16 @@ const SessionsPanel: React.FC = () => { const activeSessionId = flowChatState.activeSessionId; + const enabledSkills = useMemo(() => skills.filter((skill) => skill.enabled), [skills]); + const enabledSubagents = useMemo(() => subagents.filter((agent) => agent.enabled), [subagents]); + const enabledMcpServers = useMemo(() => mcpServers.filter((server) => server.enabled), [mcpServers]); + const unhealthyMcpServers = useMemo( + () => + enabledMcpServers.filter((server) => !MCP_HEALTHY_STATUSES.has((server.status || '').toLowerCase())), + [enabledMcpServers] + ); + const hasMcpIssue = unhealthyMcpServers.length > 0; + const handleSessionClick = useCallback(async (sessionId: string) => { if (sessionId !== activeSessionId) { try { @@ -137,6 +207,48 @@ const SessionsPanel: React.FC = () => { } }, [activeSessionId]); + const handleToggleSkill = useCallback(async (skill: SkillInfo) => { + const nextEnabled = !skill.enabled; + try { + await configAPI.setSkillEnabled(skill.name, nextEnabled); + await loadCapabilities(true); + } catch (error) { + notifyError(t('capabilities.toggleFailed')); + } + }, [loadCapabilities, notifyError, t]); + + const handleToggleSubagent = useCallback(async (subagent: SubagentInfo) => { + const nextEnabled = !subagent.enabled; + try { + const isCustom = subagent.subagentSource === 'user' || subagent.subagentSource === 'project'; + if (isCustom) { + await SubagentAPI.updateSubagentConfig({ + subagentId: subagent.id, + enabled: nextEnabled, + }); + } else { + await configAPI.setSubagentConfig(subagent.id, nextEnabled); + } + await loadCapabilities(true); + } catch (error) { + notifyError(t('capabilities.toggleFailed')); + } + }, [loadCapabilities, notifyError, t]); + + const handleReconnectMcp = useCallback(async (server: MCPServerInfo) => { + try { + if ((server.status || '').toLowerCase() === 'stopped') { + await MCPAPI.startServer(server.id); + } else { + await MCPAPI.restartServer(server.id); + } + await loadCapabilities(true); + notifySuccess(t('capabilities.mcpReconnectSuccess', { name: server.name })); + } catch (error) { + notifyError(t('capabilities.mcpReconnectFailed', { name: server.name })); + } + }, [loadCapabilities, notifyError, notifySuccess, t]); + const handleDeleteSession = useCallback((sessionId: string, e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); @@ -259,282 +371,530 @@ const SessionsPanel: React.FC = () => { return t('session.newConversation'); }, [t]); + const handleCapabilityChipClick = useCallback((panel: CapabilityPanelType) => { + setActiveCapabilityPanel(panel); + }, []); + + const handleRefreshCapabilities = useCallback(async () => { + setIsCapRefreshing(true); + try { + await loadCapabilities(true); + } finally { + setIsCapRefreshing(false); + } + }, [loadCapabilities]); + + const capIndex = activeCapabilityPanel === 'skills' ? 0 : activeCapabilityPanel === 'subagents' ? 1 : 2; + return (
- - -
- setSearchQuery('')} - clearable - size="small" - /> -
- -
- -
- -
- {sessions.length === 0 ? ( -
-
- - {searchQuery ? ( - - ) : ( - - )} - {searchQuery && } - -
-

- {searchQuery ? t('empty.noSearchResults', { query: searchQuery }) : t('empty.noSessions')} -

- {!searchQuery && ( - - )} +
+ setSearchQuery('')} + clearable + size="small" + />
- ) : ( - <> - {recentSessions.length > 0 && ( -
-
setIsRecentCollapsed(!isRecentCollapsed)} - > - {isRecentCollapsed ? : } - {t('groups.recent')} - {recentSessions.length} + +
+ +
+ +
+ {sessions.length === 0 ? ( +
+
+ + {searchQuery ? ( + + ) : ( + + )} + {searchQuery && } +
- {!isRecentCollapsed && ( -
- {recentSessions.map((session: Session) => { - const isActive = session.sessionId === activeSessionId; - const preview = getSessionPreview(session); - const isEditing = editingSessionId === session.sessionId; - const isProcessing = processingSessionIds.has(session.sessionId); - const displayTitle = session.title || t('session.defaultTitle', { id: session.sessionId.substring(0, 8) }); - - return ( -
!isEditing && handleSessionClick(session.sessionId)} - > -
- {isEditing ? ( -
- setEditingTitle(e.target.value)} - onKeyDown={(e) => handleEditKeyDown(e, session.sessionId)} - onBlur={() => handleEditBlur(session.sessionId)} - onClick={(e) => e.stopPropagation()} - placeholder={t('input.titlePlaceholder')} - /> - { - e.stopPropagation(); - handleSaveEdit(session.sessionId); - }} - tooltip={t('actions.save')} - > - - -
- ) : ( - <> -
- {isProcessing && ( - - - - )} - -
handleStartEdit(session.sessionId, displayTitle, e)} +

+ {searchQuery ? t('empty.noSearchResults', { query: searchQuery }) : t('empty.noSessions')} +

+ {!searchQuery && ( + + )} +
+ ) : ( + <> + {recentSessions.length > 0 && ( +
+
setIsRecentCollapsed(!isRecentCollapsed)} + > + {t('groups.recent')} + {recentSessions.length} +
+ {!isRecentCollapsed && ( +
+ {recentSessions.map((session: Session) => { + const isActive = session.sessionId === activeSessionId; + const preview = getSessionPreview(session); + const isEditing = editingSessionId === session.sessionId; + const isProcessing = processingSessionIds.has(session.sessionId); + const displayTitle = session.title || t('session.defaultTitle', { id: session.sessionId.substring(0, 8) }); + + return ( +
!isEditing && handleSessionClick(session.sessionId)} + > +
+ {isEditing ? ( +
+ setEditingTitle(e.target.value)} + onKeyDown={(e) => handleEditKeyDown(e, session.sessionId)} + onBlur={() => handleEditBlur(session.sessionId)} + onClick={(e) => e.stopPropagation()} + placeholder={t('input.titlePlaceholder')} + /> + { + e.stopPropagation(); + handleSaveEdit(session.sessionId); + }} + tooltip={t('actions.save')} > - {displayTitle} + + +
+ ) : ( + <> +
+ {isProcessing && ( + + + + )} + +
handleStartEdit(session.sessionId, displayTitle, e)} + > + {displayTitle} +
+
- +
+ + {formatTime(session.lastActiveAt)} + + + + + + + +
+ + )} +
+ {!isEditing && ( +
+ {preview}
-
- - {formatTime(session.lastActiveAt)} - - - - - -
+ ); + })} +
+ )} +
+ )} + + {oldSessions.length > 0 && ( +
+
setIsOldCollapsed(!isOldCollapsed)} + > + {t('groups.earlier')} + {oldSessions.length} +
+ {!isOldCollapsed && ( +
+ {oldSessions.map((session: Session) => { + const isActive = session.sessionId === activeSessionId; + const isEditing = editingSessionId === session.sessionId; + const isProcessing = processingSessionIds.has(session.sessionId); + const displayTitle = session.title || t('session.defaultTitle', { id: session.sessionId.substring(0, 8) }); + + return ( +
!isEditing && handleSessionClick(session.sessionId)} + > +
+ {isEditing ? ( +
+ setEditingTitle(e.target.value)} + onKeyDown={(e) => handleEditKeyDown(e, session.sessionId)} + onBlur={() => handleEditBlur(session.sessionId)} + onClick={(e) => e.stopPropagation()} + placeholder={t('input.titlePlaceholder')} + /> + { + e.stopPropagation(); + handleSaveEdit(session.sessionId); + }} + tooltip={t('actions.save')} > - - - - - -
- - )} + + +
+ ) : ( + <> +
+ {isProcessing && ( + + + + )} + +
handleStartEdit(session.sessionId, displayTitle, e)} + > + {displayTitle} +
+
+
+
+ + {formatTime(session.lastActiveAt)} + + + + + + + +
+ + )} +
+
+ ); + })} +
+ )} +
+ )} + + )} +
+ + + } + > +
+
+
+
+ + + +
+
+ + {hasMcpIssue && activeCapabilityPanel === 'mcp' && ( +
+ + {t('capabilities.mcpWarning', { count: unhealthyMcpServers.length })} +
+ )} + +
+ {activeCapabilityPanel === 'skills' && ( + skills.length === 0 ? ( +
+ {t('capabilities.emptySkills')} +
+ ) : ( +
+ {skills.map((skill) => { + const isExpanded = expandedCapIds.has(`skill:${skill.name}`); + return ( + +
toggleCapExpanded(`skill:${skill.name}`)} + > +
+ +
+
+ {skill.name} + {skill.level} +
+
e.stopPropagation()}> + handleToggleSkill(skill)} size="small" /> +
- {!isEditing && ( -
- {preview} + {isExpanded && ( + + {skill.description && ( +
{skill.description}
+ )} + +
+ )} + + ); + })} +
+ ) + )} + + {activeCapabilityPanel === 'subagents' && ( + subagents.length === 0 ? ( +
+ {t('capabilities.emptySubagents')} +
+ ) : ( +
+ {subagents.map((agent) => { + const isExpanded = expandedCapIds.has(`agent:${agent.id}`); + return ( + +
toggleCapExpanded(`agent:${agent.id}`)} + > +
+ +
+
+ {agent.name} + {agent.model && {agent.model}} + {agent.subagentSource && {agent.subagentSource}} +
+
e.stopPropagation()}> + handleToggleSubagent(agent)} size="small" />
+
+ {isExpanded && ( + + {agent.description && ( +
{agent.description}
+ )} +
+ {t('capabilities.toolCount')} + {agent.toolCount} +
+
)} -
+
); })}
- )} -
- )} + ) + )} - {oldSessions.length > 0 && ( -
-
setIsOldCollapsed(!isOldCollapsed)} - > - {isOldCollapsed ? : } - {t('groups.earlier')} - {oldSessions.length} -
- {!isOldCollapsed && ( -
- {oldSessions.map((session: Session) => { - const isActive = session.sessionId === activeSessionId; - const isEditing = editingSessionId === session.sessionId; - const isProcessing = processingSessionIds.has(session.sessionId); - const displayTitle = session.title || t('session.defaultTitle', { id: session.sessionId.substring(0, 8) }); - + {activeCapabilityPanel === 'mcp' && ( + mcpServers.length === 0 ? ( +
+ {t('capabilities.emptyMcp')} +
+ ) : ( +
+ {mcpServers.map((server) => { + const healthy = MCP_HEALTHY_STATUSES.has((server.status || '').toLowerCase()); + const isExpanded = expandedCapIds.has(`mcp:${server.id}`); return ( -
!isEditing && handleSessionClick(session.sessionId)} + -
- {isEditing ? ( -
- setEditingTitle(e.target.value)} - onKeyDown={(e) => handleEditKeyDown(e, session.sessionId)} - onBlur={() => handleEditBlur(session.sessionId)} - onClick={(e) => e.stopPropagation()} - placeholder={t('input.titlePlaceholder')} - /> - { - e.stopPropagation(); - handleSaveEdit(session.sessionId); - }} - tooltip={t('actions.save')} +
toggleCapExpanded(`mcp:${server.id}`)} + > +
+ +
+
+ {server.name} + {server.status} +
+ {!healthy && ( +
e.stopPropagation()}> +
- ) : ( - <> -
- {isProcessing && ( - - - - )} - -
handleStartEdit(session.sessionId, displayTitle, e)} - > - {displayTitle} -
-
-
-
- - {formatTime(session.lastActiveAt)} - - - - - - - -
- )}
-
+ {isExpanded && ( + +
+ {t('capabilities.serverType')} + {server.serverType} +
+
+ )} + ); })}
- )} -
- )} - - )} -
+ ) + )} +
+ +
+ +
+
+ +
); }; diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index a9f71b7a..f6c931f1 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -6,6 +6,10 @@ import { useI18n } from '@/infrastructure/i18n'; import ConfigCenterPanel from '@/infrastructure/config/components/ConfigCenterPanel'; import { createLogger } from '@/shared/utils/logger'; +const WorkflowEditorPanel = React.lazy(() => + import('../workflows/WorkflowEditor') +); + const log = createLogger('FlexiblePanel'); // Stable lazy components at module level to avoid re-creation on each render @@ -655,6 +659,14 @@ const FlexiblePanel: React.FC = memo(({ const configData = content.data || {}; return ; + case 'workflow-editor': + const wfData = content.data || {}; + return ( + Loading Workflow Editor...
}> + + + ); + case 'task-detail': const taskDetailData = content.data || {}; return ( diff --git a/src/web-ui/src/app/components/panels/base/PanelHeader.scss b/src/web-ui/src/app/components/panels/base/PanelHeader.scss index 16129f37..a4a823ce 100644 --- a/src/web-ui/src/app/components/panels/base/PanelHeader.scss +++ b/src/web-ui/src/app/components/panels/base/PanelHeader.scss @@ -6,7 +6,7 @@ // Unified header height: IconButton(xs=24px) + padding(6px*2)=36px padding: 6px 12px; background: var(--color-bg-primary, rgba(255, 255, 255, 0.03)); - border-bottom: 1px solid var(--color-border, rgba(255, 255, 255, 0.08)); + border-bottom: 1px solid var(--border-base); flex-shrink: 0; min-height: 36px; diff --git a/src/web-ui/src/app/components/panels/base/types.ts b/src/web-ui/src/app/components/panels/base/types.ts index eb6f04db..f61def25 100644 --- a/src/web-ui/src/app/components/panels/base/types.ts +++ b/src/web-ui/src/app/components/panels/base/types.ts @@ -21,6 +21,7 @@ export type PanelContentType = | 'git-branch-history' | 'ai-session' | 'config-center' + | 'workflow-editor' | 'planner' | 'task-detail' | 'plan-viewer' diff --git a/src/web-ui/src/app/components/panels/base/utils.ts b/src/web-ui/src/app/components/panels/base/utils.ts index e8ff6442..cb53ea73 100644 --- a/src/web-ui/src/app/components/panels/base/utils.ts +++ b/src/web-ui/src/app/components/panels/base/utils.ts @@ -15,7 +15,8 @@ import { Settings, ClipboardList, Image, - Network + Network, + Workflow } from 'lucide-react'; import { PanelContentType, PanelContentConfig } from './types'; @@ -141,6 +142,14 @@ export const PANEL_CONTENT_CONFIGS: Record supportsDownload: false, showHeader: false }, + 'workflow-editor': { + type: 'workflow-editor', + displayName: 'Workflow Editor', + icon: Workflow, + supportsCopy: false, + supportsDownload: false, + showHeader: false + }, 'planner': { type: 'planner', displayName: 'Planner', diff --git a/src/web-ui/src/app/components/panels/content-canvas/types/content.ts b/src/web-ui/src/app/components/panels/content-canvas/types/content.ts index 16c67e0a..35cb73fa 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/types/content.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/types/content.ts @@ -24,6 +24,7 @@ export type PanelContentType = | 'git-branch-history' | 'ai-session' | 'config-center' + | 'workflow-editor' | 'planner' | 'task-detail' | 'plan-viewer' diff --git a/src/web-ui/src/app/components/panels/index.ts b/src/web-ui/src/app/components/panels/index.ts index bfca21c0..d8a89e39 100644 --- a/src/web-ui/src/app/components/panels/index.ts +++ b/src/web-ui/src/app/components/panels/index.ts @@ -8,6 +8,7 @@ export { default as RightPanel } from './RightPanel'; export type { RightPanelRef } from './RightPanel'; export { default as FilesPanel } from './FilesPanel'; export { default as SessionsPanel } from './SessionsPanel'; +export { WorkflowsPanel } from './workflows'; export { default as TerminalSessionsPanel } from './TerminalSessionsPanel'; // ContentCanvas component exports diff --git a/src/web-ui/src/app/components/panels/workflows/WorkflowEditor.scss b/src/web-ui/src/app/components/panels/workflows/WorkflowEditor.scss new file mode 100644 index 00000000..4d2c86e8 --- /dev/null +++ b/src/web-ui/src/app/components/panels/workflows/WorkflowEditor.scss @@ -0,0 +1,898 @@ +/** + * WorkflowEditor styles. + * Multi-step form editor for the right panel. + * Uses dashed/solid line patterns consistent with SessionsPanel. + */ + +@use '../../../../component-library/styles/tokens.scss' as *; + +.bitfun-wf-editor { + display: flex; + flex-direction: column; + height: 100%; + background: var(--color-bg-primary); + overflow: hidden; + + // ==================== Header ==================== + &__header { + display: flex; + align-items: center; + gap: $size-gap-2; + padding: $size-gap-3 $size-gap-4; + border-bottom: 1px solid var(--border-base); + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: var(--color-text-primary); + flex-shrink: 0; + + &-icon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: $size-radius-sm; + border: 1px solid color-mix(in srgb, var(--border-base) 65%, transparent); + color: var(--color-text-muted); + } + } + + // ==================== Steps Nav ==================== + &__steps { + display: flex; + gap: 2px; + padding: $size-gap-2 $size-gap-4; + flex-shrink: 0; + position: relative; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: $size-gap-4; + right: $size-gap-4; + height: 0; + border-top: 1px dashed var(--border-subtle); + } + } + + &__step { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: $size-gap-1; + padding: 6px $size-gap-2; + border: none; + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-muted); + font-size: $font-size-xs; + cursor: pointer; + transition: all $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-soft); + color: var(--color-text-secondary); + } + + &.is-active { + background: var(--element-bg-base); + color: var(--color-text-primary); + } + + &.is-done { + color: var(--color-text-secondary); + } + + &-num { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 10px; + font-weight: $font-weight-medium; + border: 1px solid color-mix(in srgb, var(--border-base) 65%, transparent); + background: transparent; + + .is-active & { + border-color: var(--color-text-primary); + color: var(--color-text-primary); + } + + .is-done & { + border-color: var(--color-text-secondary); + background: var(--element-bg-soft); + } + } + + &-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + // ==================== Content ==================== + &__content { + flex: 1; + overflow-y: auto; + padding: $size-gap-3 $size-gap-4; + } + + // ==================== Form Fields ==================== + &__form { + display: flex; + flex-direction: column; + gap: $size-gap-3; + } + + &__field { + display: flex; + flex-direction: column; + gap: $size-gap-1; + + > label { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: var(--color-text-secondary); + } + + &--row { + flex-direction: row; + align-items: center; + gap: $size-gap-2; + + > label { + flex-shrink: 0; + } + } + } + + &__section-title { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: var(--color-text-secondary); + margin-top: $size-gap-2; + padding-top: $size-gap-2; + border-top: 1px dashed color-mix(in srgb, var(--border-base) 65%, transparent); + } + + &__section-desc { + font-size: $font-size-xs; + color: var(--color-text-muted); + margin-bottom: $size-gap-2; + } + + &__hint { + font-size: 10px; + color: var(--color-text-muted); + margin-top: 2px; + } + + // ==================== Icon Picker ==================== + &__icon-picker { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + &__icon-option { + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--border-base) 65%, transparent); + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: all $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-soft); + color: var(--color-text-secondary); + border-color: var(--border-base); + } + + &.is-active { + border-color: var(--color-text-primary); + color: var(--color-text-primary); + background: var(--element-bg-base); + } + } + + // ==================== Tags ==================== + &__tags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 4px; + border: 1px solid var(--border-base); + border-radius: $size-radius-sm; + background: var(--color-bg-primary); + min-height: 32px; + } + + &__tag-input-wrapper { + flex: 1; + min-width: 80px; + + .bitfun-input-wrapper { + margin: 0; + } + + .bitfun-input-container { + border: none !important; + background: transparent !important; + height: 24px; + padding: 0 4px; + } + } + + // ==================== Role Card Grid ==================== + &__role-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: $size-gap-2; + } + + &__role-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: $size-gap-2 $size-gap-1; + border: 1px solid color-mix(in srgb, var(--border-base) 65%, transparent); + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-soft); + border-color: var(--border-base); + } + + &.is-active { + border-color: var(--border-strong); + background: var(--element-bg-base); + } + + &-icon { + color: var(--color-text-muted); + + .is-active & { + color: var(--color-text-secondary); + } + } + + &-label { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: var(--color-text-primary); + } + + &-desc { + font-size: 10px; + color: var(--color-text-muted); + text-align: center; + line-height: 1.3; + } + } + + // ==================== Checklist ==================== + &__checklist { + display: flex; + flex-direction: column; + gap: $size-gap-2; + max-height: 200px; + overflow-y: auto; + padding: $size-gap-1; + border: 1px solid var(--border-base); + border-radius: $size-radius-sm; + + &-group-label { + font-size: 10px; + font-weight: $font-weight-medium; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.3px; + } + + &-items { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + } + } + + &__check-item { + display: flex; + align-items: center; + gap: 4px; + font-size: $font-size-xs; + color: var(--color-text-secondary); + cursor: pointer; + } + + // ==================== Agents Step ==================== + &__agents-step { + display: flex; + flex-direction: column; + gap: $size-gap-2; + } + + &__agents-header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: var(--color-text-secondary); + } + + &__agents-list { + display: flex; + flex-direction: column; + gap: $size-gap-1; + } + + &__agents-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: $size-gap-2; + padding: $size-gap-6; + color: var(--color-text-muted); + border: 1px dashed color-mix(in srgb, var(--border-base) 65%, transparent); + border-radius: $size-radius-sm; + + p { + font-size: $font-size-xs; + } + } + + &__agent-card { + border: 1px solid color-mix(in srgb, var(--border-base) 65%, transparent); + border-radius: $size-radius-sm; + overflow: hidden; + + &-header { + display: flex; + align-items: center; + gap: $size-gap-1; + padding: $size-gap-2; + cursor: pointer; + transition: background $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-soft); + } + + svg { + flex-shrink: 0; + color: var(--color-text-muted); + } + } + + &-name { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + &-role { + flex-shrink: 0; + font-size: 10px; + padding: 0 4px; + border-radius: $size-radius-sm; + border: 1px solid color-mix(in srgb, var(--border-base) 80%, transparent); + color: var(--color-text-muted); + } + + &-model { + flex-shrink: 0; + font-size: 10px; + color: var(--color-text-muted); + font-family: var(--font-mono); + } + + &-info { + display: flex; + align-items: center; + gap: 3px; + font-size: 10px; + color: var(--color-text-muted); + margin-left: auto; + + svg { + opacity: 0.7; + } + } + } + + &__agent-remove { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + opacity: 0; + transition: all $motion-fast $easing-standard; + + .bitfun-wf-editor__agent-card-header:hover & { + opacity: 1; + } + + &:hover { + background: color-mix(in srgb, var(--color-error) 15%, transparent); + color: var(--color-error); + } + } + + &__agent-editor { + display: flex; + flex-direction: column; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; + border-top: 1px dashed color-mix(in srgb, var(--border-base) 65%, transparent); + background: color-mix(in srgb, var(--element-bg-soft) 40%, transparent); + + &-actions { + display: flex; + justify-content: flex-end; + padding-top: $size-gap-1; + } + } + + // ==================== Pattern Grid ==================== + &__pattern-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: $size-gap-2; + } + + &__pattern-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: $size-gap-3 $size-gap-2; + border: 1px solid color-mix(in srgb, var(--border-base) 65%, transparent); + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all $motion-fast $easing-standard; + + &:hover { + background: var(--element-bg-soft); + border-color: var(--border-base); + } + + &.is-active { + border-color: var(--border-strong); + background: var(--element-bg-base); + } + } + + &__pattern-icon { + color: var(--color-text-muted); + + .is-active & { + color: var(--color-text-secondary); + } + } + + &__pattern-label { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: var(--color-text-primary); + } + + &__pattern-desc { + font-size: 10px; + color: var(--color-text-muted); + text-align: center; + line-height: 1.3; + } + + // ==================== Supervisor Picker ==================== + &__supervisor-picker { + display: flex; + flex-direction: column; + gap: $size-gap-1; + } + + &__supervisor-option { + display: flex; + align-items: center; + gap: $size-gap-2; + padding: $size-gap-2; + border: 1px solid color-mix(in srgb, var(--border-base) 65%, transparent); + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all $motion-fast $easing-standard; + text-align: left; + + &:hover { + background: var(--element-bg-soft); + border-color: var(--border-base); + } + + &.is-active { + border-color: var(--border-strong); + background: var(--element-bg-base); + } + + &-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: $size-radius-sm; + border: 1px solid color-mix(in srgb, var(--border-base) 65%, transparent); + color: var(--color-text-muted); + flex-shrink: 0; + + .is-active & { + border-color: var(--border-strong); + color: var(--color-text-secondary); + } + } + + &-body { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1; + } + + &-name { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &-role { + font-size: 10px; + color: var(--color-text-muted); + } + + &-badge { + flex-shrink: 0; + font-size: 10px; + padding: 1px 6px; + border-radius: $size-radius-sm; + border: 1px solid color-mix(in srgb, var(--border-base) 80%, transparent); + color: var(--color-text-muted); + } + } + + // ==================== Topology ==================== + &__topology { + padding: $size-gap-3; + border: 1px dashed color-mix(in srgb, var(--border-base) 65%, transparent); + border-radius: $size-radius-sm; + min-height: 80px; + display: flex; + align-items: center; + justify-content: center; + } + + &__topo-node { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: $size-radius-sm; + border: 1px solid color-mix(in srgb, var(--border-base) 80%, transparent); + background: var(--element-bg-soft); + color: var(--color-text-secondary); + font-size: $font-size-xs; + font-weight: $font-weight-medium; + white-space: nowrap; + + svg { + color: var(--color-text-muted); + flex-shrink: 0; + } + + &--primary { + border-color: var(--border-strong); + background: var(--element-bg-base); + color: var(--color-text-primary); + + svg { + color: var(--color-text-secondary); + } + } + + &--io { + border-style: dashed; + border-color: color-mix(in srgb, var(--border-base) 50%, transparent); + background: transparent; + color: var(--color-text-muted); + font-size: 10px; + padding: 3px 8px; + } + } + + &__topo-role { + font-size: 10px; + color: var(--color-text-muted); + font-weight: $font-weight-normal; + } + + &__topo-arrow { + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); + opacity: 0.5; + + &--down { + padding: 2px 0; + } + + &--fan, + &--bi { + flex-shrink: 0; + } + } + + &__topo-empty { + display: flex; + align-items: center; + justify-content: center; + padding: $size-gap-2; + color: var(--color-text-muted); + font-size: $font-size-xs; + } + + // -- Single + &__topo-single { + display: flex; + justify-content: center; + } + + // -- Pipeline (vertical) + &__topo-pipeline { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + } + + // -- Fan-out (horizontal: input → [parallel] → output) + &__topo-fanout { + display: flex; + align-items: center; + gap: $size-gap-1; + } + + &__topo-parallel { + display: flex; + flex-direction: column; + gap: 4px; + } + + // -- Supervisor (top-down: supervisor → workers row) + &__topo-supervisor { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + } + + &__topo-branch-lines { + width: 100%; + display: flex; + justify-content: center; + color: var(--color-text-muted); + opacity: 0.5; + } + + &__topo-workers { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: $size-gap-2; + } + + // -- Team (horizontal with bidirectional arrows) + &__topo-team { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + justify-content: center; + } + + // ==================== Preview ==================== + &__preview-step { + display: flex; + flex-direction: column; + gap: $size-gap-3; + } + + &__preview-header { + display: flex; + align-items: flex-start; + gap: $size-gap-3; + + &-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: $size-radius-sm; + border: 1px solid color-mix(in srgb, var(--border-base) 65%, transparent); + color: var(--color-text-muted); + flex-shrink: 0; + } + + h3 { + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: var(--color-text-primary); + margin: 0; + } + + p { + font-size: $font-size-xs; + color: var(--color-text-muted); + margin: 2px 0 0; + } + } + + &__preview-info { + display: flex; + flex-direction: column; + gap: $size-gap-1; + padding: $size-gap-2; + border: 1px solid color-mix(in srgb, var(--border-base) 65%, transparent); + border-radius: $size-radius-sm; + } + + &__preview-row { + display: flex; + align-items: center; + gap: $size-gap-2; + font-size: $font-size-xs; + color: var(--color-text-secondary); + padding: 2px 0; + + &:not(:last-child) { + border-bottom: 1px dashed color-mix(in srgb, var(--border-base) 40%, transparent); + } + } + + &__preview-label { + flex-shrink: 0; + width: 70px; + color: var(--color-text-muted); + } + + &__preview-value { + display: flex; + align-items: center; + gap: 4px; + color: var(--color-text-secondary); + } + + &__preview-agents { + display: flex; + flex-direction: column; + gap: $size-gap-2; + } + + &__preview-agent-card { + border: 1px solid color-mix(in srgb, var(--border-base) 65%, transparent); + border-radius: $size-radius-sm; + overflow: hidden; + + &-header { + display: flex; + align-items: center; + gap: $size-gap-1; + padding: $size-gap-2; + } + + &-icon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: $size-radius-sm; + border: 1px solid color-mix(in srgb, var(--border-base) 65%, transparent); + color: var(--color-text-muted); + flex-shrink: 0; + } + + &-name { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + &-role { + flex-shrink: 0; + font-size: 10px; + padding: 0 4px; + border-radius: $size-radius-sm; + border: 1px solid color-mix(in srgb, var(--border-base) 80%, transparent); + color: var(--color-text-muted); + margin-left: auto; + } + + &-meta { + display: flex; + align-items: center; + gap: $size-gap-2; + padding: 0 $size-gap-2 $size-gap-2; + padding-left: calc(24px + $size-gap-2 + $size-gap-1); + } + + &-model { + font-size: 10px; + color: var(--color-text-muted); + font-family: var(--font-mono); + } + + &-stat { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 10px; + color: var(--color-text-muted); + + svg { + opacity: 0.7; + } + } + } + + // ==================== Footer ==================== + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: $size-gap-2 $size-gap-4; + border-top: 1px solid var(--border-base); + flex-shrink: 0; + + &-right { + display: flex; + gap: $size-gap-2; + } + } +} diff --git a/src/web-ui/src/app/components/panels/workflows/WorkflowEditor.tsx b/src/web-ui/src/app/components/panels/workflows/WorkflowEditor.tsx new file mode 100644 index 00000000..b75ba094 --- /dev/null +++ b/src/web-ui/src/app/components/panels/workflows/WorkflowEditor.tsx @@ -0,0 +1,851 @@ +/** + * Workflow editor component. + * Multi-step form for creating and editing workflows. + * Rendered inside the right panel's ContentCanvas. + */ + +import React, { useState, useMemo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Bot, + Plus, + Trash2, + Wrench, + Puzzle, + ChevronDown, + ChevronRight, + Workflow as WorkflowIcon, + User, + ArrowRight, + Zap, + Crown, + Users, + Eye, + Mail, + ScanSearch, + Languages, + FileBarChart, + Hash, + Terminal, + Globe, + FileCode, + GitBranch, + Settings, + type LucideIcon, +} from 'lucide-react'; +import { + Button, + Input, + Textarea, + Checkbox, + Tooltip, + Select, + Tag, +} from '@/component-library'; +import type { + AgentNode, + AgentNodeConfig, + WorkflowEditorStep, + AgentRole, + OrchestrationPattern, + TriggerType, + WorkflowLocation, +} from './types'; +import { MOCK_WORKFLOWS } from './mockData'; +import './WorkflowEditor.scss'; + +interface WorkflowEditorProps { + workflowId?: string; +} + +const DEFAULT_AGENT: AgentNodeConfig = { + name: '', + description: '', + prompt: '', + model: 'primary', + tools: [], + skills: [], + readonly: true, +}; + +const STEPS: WorkflowEditorStep[] = ['basic', 'agents', 'orchestration', 'preview']; + +const AVAILABLE_TOOLS = [ + { group: 'Built-in', items: ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob', 'WebSearch', 'ReadLints', 'Git', 'TodoWrite'] }, + { group: 'Gmail (MCP)', items: ['mcp_gmail_send_email', 'mcp_gmail_read_email', 'mcp_gmail_search', 'mcp_gmail_create_draft'] }, + { group: 'Calendar (MCP)', items: ['mcp_calendar_get_events', 'mcp_calendar_create_event'] }, +]; + +const AVAILABLE_SKILLS = [ + { name: 'email-writing', label: 'email-writing' }, + { name: 'code-review', label: 'code-review' }, + { name: 'git-commit', label: 'git-commit' }, + { name: 'translation-glossary', label: 'translation-glossary' }, +]; + +const ICON_MAP: Record = { + mail: Mail, + 'scan-search': ScanSearch, + languages: Languages, + 'file-bar-chart': FileBarChart, + terminal: Terminal, + globe: Globe, + 'file-code': FileCode, + 'git-branch': GitBranch, + settings: Settings, + workflow: WorkflowIcon, +}; + +const ICON_OPTIONS = [ + 'workflow', 'mail', 'scan-search', 'languages', 'file-bar-chart', + 'terminal', 'globe', 'file-code', 'git-branch', 'settings', +]; + +function getIconComponent(iconName: string): LucideIcon { + return ICON_MAP[iconName] || Hash; +} + +const ROLE_ICONS: Record = { + orchestrator: Crown, + worker: Bot, + reviewer: Eye, +}; + +const PATTERN_ICONS: Record = { + single: User, + pipeline: ArrowRight, + fan_out: Zap, + supervisor: Crown, + team: Users, +}; + +const WorkflowEditor: React.FC = ({ workflowId }) => { + const { t } = useTranslation('panels/workflows'); + const isNew = !workflowId; + + const existingWorkflow = useMemo( + () => (workflowId ? MOCK_WORKFLOWS.find((w) => w.id === workflowId) : undefined), + [workflowId] + ); + + const [step, setStep] = useState('basic'); + const [editingAgentId, setEditingAgentId] = useState(null); + + const [name, setName] = useState(existingWorkflow?.name || ''); + const [displayName, setDisplayName] = useState(existingWorkflow?.displayName || ''); + const [description, setDescription] = useState(existingWorkflow?.description || ''); + const [icon, setIcon] = useState(existingWorkflow?.icon || 'workflow'); + const [triggerType, setTriggerType] = useState(existingWorkflow?.trigger.type || 'manual'); + const [triggerCommand, setTriggerCommand] = useState(existingWorkflow?.trigger.command || ''); + const [triggerHotkey, setTriggerHotkey] = useState(existingWorkflow?.trigger.hotkey || ''); + const [location, setLocation] = useState(existingWorkflow?.location || 'user'); + const [tags, setTags] = useState(existingWorkflow?.tags || []); + const [tagInput, setTagInput] = useState(''); + + const [agents, setAgents] = useState( + existingWorkflow?.agents || [] + ); + const [pattern, setPattern] = useState( + existingWorkflow?.orchestration.pattern || 'single' + ); + const [supervisorAgentId, setSupervisorAgentId] = useState( + existingWorkflow?.orchestration.supervisor?.agentId || '' + ); + + const currentStepIndex = STEPS.indexOf(step); + const IconComp = getIconComponent(icon); + + const goNext = () => { + const idx = STEPS.indexOf(step); + if (idx < STEPS.length - 1) setStep(STEPS[idx + 1]); + }; + const goPrev = () => { + const idx = STEPS.indexOf(step); + if (idx > 0) setStep(STEPS[idx - 1]); + }; + + const addAgent = useCallback(() => { + const id = `agent-${Date.now()}`; + setAgents((prev) => [ + ...prev, + { id, role: 'worker' as AgentRole, inline: { ...DEFAULT_AGENT } }, + ]); + setEditingAgentId(id); + }, []); + + const removeAgent = useCallback((id: string) => { + setAgents((prev) => prev.filter((a) => a.id !== id)); + setEditingAgentId(null); + }, []); + + const updateAgent = useCallback((id: string, updates: Partial) => { + setAgents((prev) => + prev.map((a) => (a.id === id ? { ...a, ...updates } : a)) + ); + }, []); + + const updateAgentConfig = useCallback((id: string, updates: Partial) => { + setAgents((prev) => + prev.map((a) => + a.id === id && a.inline + ? { ...a, inline: { ...a.inline, ...updates } } + : a + ) + ); + }, []); + + const toggleTool = useCallback((agentId: string, tool: string) => { + setAgents((prev) => + prev.map((a) => { + if (a.id !== agentId || !a.inline) return a; + const tools = a.inline.tools.includes(tool) + ? a.inline.tools.filter((t) => t !== tool) + : [...a.inline.tools, tool]; + return { ...a, inline: { ...a.inline, tools } }; + }) + ); + }, []); + + const toggleSkill = useCallback((agentId: string, skill: string) => { + setAgents((prev) => + prev.map((a) => { + if (a.id !== agentId || !a.inline) return a; + const skills = a.inline.skills.includes(skill) + ? a.inline.skills.filter((s) => s !== skill) + : [...a.inline.skills, skill]; + return { ...a, inline: { ...a.inline, skills } }; + }) + ); + }, []); + + const handleTagKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && tagInput.trim()) { + e.preventDefault(); + if (!tags.includes(tagInput.trim())) { + setTags((prev) => [...prev, tagInput.trim()]); + } + setTagInput(''); + } + }, + [tagInput, tags] + ); + + const removeTag = useCallback((tag: string) => { + setTags((prev) => prev.filter((t) => t !== tag)); + }, []); + + // ==================== Step Renderers ==================== + + const renderBasicStep = () => ( +
+
+ + setDisplayName(e.target.value)} + placeholder={t('editor.basic.displayNamePlaceholder')} + inputSize="small" + /> +
+
+ + setName(e.target.value)} + placeholder={t('editor.basic.namePlaceholder')} + inputSize="small" + /> +
+
+ +