diff --git a/Cargo.toml b/Cargo.toml index 3f294b86..7fc88cf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,6 +104,7 @@ tauri-plugin-dialog = "2.6" tauri-plugin-fs = "2" tauri-plugin-log = "2" tauri-plugin-autostart = "2" +tauri-plugin-notification = "2" tauri-build = { version = "2", features = [] } # Windows-specific dependencies diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14e9d0f8..b07a9b72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: '@tauri-apps/plugin-log': specifier: ^2.8.0 version: 2.8.0 + '@tauri-apps/plugin-notification': + specifier: ^2.3.3 + version: 2.3.3 '@tauri-apps/plugin-opener': specifier: ^2.5.2 version: 2.5.3 @@ -1412,6 +1415,9 @@ packages: '@tauri-apps/plugin-log@2.8.0': resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==} + '@tauri-apps/plugin-notification@2.3.3': + resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} + '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} @@ -6027,6 +6033,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-notification@2.3.3': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-opener@2.5.3': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index d816dad8..c8225ed2 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -29,6 +29,7 @@ tauri-plugin-dialog = { workspace = true } tauri-plugin-fs = { workspace = true } tauri-plugin-log = { workspace = true } tauri-plugin-autostart = { workspace = true } +tauri-plugin-notification = { workspace = true } # Inherited from workspace tokio = { workspace = true } diff --git a/src/apps/desktop/capabilities/default.json b/src/apps/desktop/capabilities/default.json index 992ed890..94558090 100644 --- a/src/apps/desktop/capabilities/default.json +++ b/src/apps/desktop/capabilities/default.json @@ -93,10 +93,17 @@ ] }, { - "identifier": "fs:allow-home-write-recursive", + "identifier": "fs:allow-home-write-recursive", "allow": [ { "path": "$HOME/**" } ] - } + }, + "notification:default", + "notification:allow-notify", + "notification:allow-show", + "notification:allow-request-permission", + "notification:allow-check-permissions", + "notification:allow-permission-state", + "notification:allow-is-permission-granted" ] } diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index 7e4a694c..df82f43c 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -166,3 +166,25 @@ pub async fn set_macos_edit_menu_mode( Ok(()) } + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendNotificationRequest { + pub title: String, + pub body: Option, +} + +/// Send an OS-level desktop notification (Windows toast / macOS notification center). +#[tauri::command] +pub async fn send_system_notification( + app: tauri::AppHandle, + request: SendNotificationRequest, +) -> Result<(), String> { + use tauri_plugin_notification::NotificationExt; + + let mut builder = app.notification().builder().title(&request.title); + if let Some(body) = &request.body { + builder = builder.body(body); + } + builder.show().map_err(|e| e.to_string()) +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index dabe006a..805e3e5e 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -141,6 +141,7 @@ pub async fn run() { .app_name("BitFun") .build(), ) + .plugin(tauri_plugin_notification::init()) .manage(app_state) .manage(coordinator_state) .manage(scheduler_state) @@ -586,6 +587,7 @@ pub async fn run() { api::terminal_api::terminal_shutdown_all, api::terminal_api::terminal_get_history, get_system_info, + send_system_notification, check_command_exists, check_commands_exist, run_system_command, diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index 7520471e..54f288a9 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -99,6 +99,9 @@ pub struct NotificationConfig { pub enabled: bool, pub position: String, pub duration: u32, + /// Whether to show a toast notification when a dialog turn completes while the window is not focused. + #[serde(default = "default_true")] + pub dialog_completion_notify: bool, } /// Theme configuration. @@ -932,6 +935,7 @@ impl Default for AppConfig { enabled: true, position: "topRight".to_string(), duration: 5000, + dialog_completion_notify: true, }, session_config: AppSessionConfig::default(), ai_experience: AIExperienceConfig::default(), @@ -1235,6 +1239,7 @@ impl Default for NotificationConfig { enabled: true, position: "topRight".to_string(), duration: 5000, + dialog_completion_notify: true, } } } diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 2c7cd73b..4dee6dc2 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -21,6 +21,7 @@ "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.0.0", "@tauri-apps/plugin-log": "^2.8.0", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2.5.2", "@tiptap/core": "^3.20.4", "@tiptap/extension-details": "^3.20.4", diff --git a/src/web-ui/public/Logo-ICON.png b/src/web-ui/public/Logo-ICON.png index 2d84e948..e250ea97 100644 Binary files a/src/web-ui/public/Logo-ICON.png and b/src/web-ui/public/Logo-ICON.png differ diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 309d2a93..4446047f 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -1,94 +1,54 @@ /** * MainNav — default workspace navigation sidebar. * - * 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) - * - the anchor item itself → `.is-departing-anchor` (brief highlight) - * - items below the anchor → `.is-departing-down` (slide down + fade) - * This creates the visual "split-open from the clicked item" effect while - * the outer Grid accordion handles the actual height collapse. + * Layout (top to bottom): + * 1. Top: New sessions | Assistant | Extensions (expand → Agents | Skills) + * 2. Assistant sessions, Workspace + * 3. Bottom: MiniApp + * + * When a scene-nav transition is active (`isDeparting=true`), items receive + * positional CSS classes for the split-open animation effect. */ import React, { useCallback, useState, useMemo, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Plus, FolderOpen, FolderPlus, History, Check, Clock3 } from 'lucide-react'; -import { Badge, Tooltip } from '@/component-library'; +import { Plus, FolderOpen, FolderPlus, History, Check, BotMessageSquare, Users, Puzzle, Blocks, ChevronDown } from 'lucide-react'; +import { Tooltip } from '@/component-library'; import { useApp } from '../../hooks/useApp'; import { useSceneManager } from '../../hooks/useSceneManager'; -import { useNavSceneStore } from '../../stores/navSceneStore'; -import { useSessionModeStore } from '../../stores/sessionModeStore'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; -import { NAV_SECTIONS } from './config'; -import type { PanelType } from '../../types'; -import type { NavItem as NavItemConfig } from './types'; import type { SceneTabId } from '../SceneBar/types'; -import NavItem from './components/NavItem'; import SectionHeader from './components/SectionHeader'; import MiniAppEntry from './components/MiniAppEntry'; import WorkspaceListSection from './sections/workspaces/WorkspaceListSection'; -import ScheduledJobsDialog from '../ScheduledJobsDialog/ScheduledJobsDialog'; +import SessionsSection from './sections/sessions/SessionsSection'; import { useSceneStore } from '../../stores/sceneStore'; import { useMyAgentStore } from '../../scenes/my-agent/myAgentStore'; import { useMiniAppCatalogSync } from '../../scenes/miniapps/hooks/useMiniAppCatalogSync'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; -import { compareSessionsForDisplay } from '@/flow_chat/utils/sessionOrdering'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createLogger } from '@/shared/utils/logger'; -import { WorkspaceKind } from '@/shared/types'; +import { WorkspaceKind, isRemoteWorkspace } from '@/shared/types'; import { useSSHRemoteContext, SSHConnectionDialog, RemoteFileBrowser } from '@/features/ssh-remote'; +import { useSessionModeStore } from '../../stores/sessionModeStore'; -const NAV_DISPLAY_MODE_STORAGE_KEY = 'bitfun.nav.displayMode'; import './NavPanel.scss'; const log = createLogger('MainNav'); -type DepartDir = 'up' | 'anchor' | 'down' | null; -type NavDisplayMode = 'pro' | 'assistant'; - -/** - * Build a flat ordered list of (sectionId, itemTab) tuples so we can - * determine each element's position relative to the anchor item. - */ -function buildFlatItemOrder(): { sectionId: string; tab: PanelType; navSceneId?: SceneTabId }[] { - const list: { sectionId: string; tab: PanelType; navSceneId?: SceneTabId }[] = []; - for (const section of NAV_SECTIONS) { - for (const item of section.items) { - list.push({ sectionId: section.id, tab: item.tab, navSceneId: item.navSceneId }); - } - } - return list; -} - -const FLAT_ITEMS = buildFlatItemOrder(); - -function getAnchorIndex(anchorId: SceneTabId | null): number { - if (!anchorId) return -1; - return FLAT_ITEMS.findIndex(i => i.navSceneId === anchorId); -} - -function getInitialNavDisplayMode(): NavDisplayMode { - if (typeof window === 'undefined') return 'pro'; - return window.localStorage.getItem(NAV_DISPLAY_MODE_STORAGE_KEY) === 'assistant' - ? 'assistant' - : 'pro'; -} - interface MainNavProps { isDeparting?: boolean; anchorNavSceneId?: SceneTabId | null; } const MainNav: React.FC = ({ - isDeparting = false, - anchorNavSceneId = null, + isDeparting: _isDeparting = false, + anchorNavSceneId: _anchorNavSceneId = null, }) => { useMiniAppCatalogSync(); - // SSH Remote state - use context instead of hook for consistent state const sshRemote = useSSHRemoteContext(); const [isSSHConnectionDialogOpen, setIsSSHConnectionDialogOpen] = useState(false); @@ -98,12 +58,9 @@ const MainNav: React.FC = ({ } }, [sshRemote.showFileBrowser]); - const { state, switchLeftPanelTab } = useApp(); + const { switchLeftPanelTab } = useApp(); const { openScene } = useSceneManager(); - const openNavScene = useNavSceneStore(s => s.openNavScene); const activeTabId = useSceneStore(s => s.activeTabId); - const setMyAgentView = useMyAgentStore(s => s.setActiveView); - const selectedAssistantWorkspaceId = useMyAgentStore((s) => s.selectedAssistantWorkspaceId); const setSelectedAssistantWorkspaceId = useMyAgentStore((s) => s.setSelectedAssistantWorkspaceId); const { t } = useI18n('common'); const { @@ -115,66 +72,22 @@ const MainNav: React.FC = ({ setActiveWorkspace, } = 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]); - - const getDepartDir = useCallback( - (flatIdx: number): DepartDir => { - if (!isDeparting) return null; - if (anchorIdx < 0) return 'up'; - if (flatIdx < anchorIdx) return 'up'; - if (flatIdx === anchorIdx) return 'anchor'; - return 'down'; - }, - [isDeparting, anchorIdx] + // Section expand state + const [expandedSections, setExpandedSections] = useState>( + () => new Set(['assistant-sessions', 'workspace']) ); - const getSectionDepartDir = useCallback( - (sectionId: string): DepartDir => { - if (!isDeparting) return null; - if (anchorIdx < 0) return 'up'; - const first = FLAT_ITEMS.findIndex(i => i.sectionId === sectionId); - const last = FLAT_ITEMS.length - 1 - [...FLAT_ITEMS].reverse().findIndex(i => i.sectionId === sectionId); - if (last < anchorIdx) return 'up'; - if (first > anchorIdx) return 'down'; - return null; - }, - [isDeparting, anchorIdx] - ); - - const [expandedSections, setExpandedSections] = useState>(() => { - const init = new Set(); - NAV_SECTIONS.forEach(s => { - if (s.defaultExpanded !== false) init.add(s.id); - }); - return init; - }); 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 getSectionLabel = useCallback( - (sectionId: string, fallbackLabel: string | null) => { - if (!fallbackLabel) return null; - const keyMap: Record = { - assistants: 'nav.workspaces.groups.assistants', - workspace: 'nav.sections.workspace', - 'my-agent': 'nav.sections.myAgent', - miniapps: 'scenes.miniApps', - }; - const key = keyMap[sectionId]; - return key ? t(key) || fallbackLabel : fallbackLabel; - }, - [t] - ); + const [isExtensionsOpen, setIsExtensionsOpen] = useState(false); const toggleSection = useCallback((id: string) => { setExpandedSections(prev => { @@ -198,94 +111,25 @@ const MainNav: React.FC = ({ } catch (error) { log.warn('Failed to cleanup invalid workspaces before opening workspace menu', { error }); } - const rect = workspaceMenuButtonRef.current?.getBoundingClientRect(); if (!rect) return; - setWorkspaceMenuPos({ - top: rect.bottom + 6, - left: rect.left, - }); + setWorkspaceMenuPos({ top: rect.bottom + 6, left: rect.left }); setWorkspaceMenuOpen(true); setWorkspaceMenuClosing(false); }, []); const toggleWorkspaceMenu = useCallback(() => { - if (workspaceMenuOpen) { - closeWorkspaceMenu(); - return; - } + if (workspaceMenuOpen) { closeWorkspaceMenu(); return; } void openWorkspaceMenu(); }, [closeWorkspaceMenu, openWorkspaceMenu, workspaceMenuOpen]); const setSessionMode = useSessionModeStore(s => s.setMode); const isAssistantWorkspaceActive = currentWorkspace?.workspaceKind === WorkspaceKind.Assistant; + const defaultAssistantWorkspace = useMemo( - () => assistantWorkspacesList.find(workspace => !workspace.assistantId) ?? assistantWorkspacesList[0] ?? null, + () => assistantWorkspacesList.find(w => !w.assistantId) ?? assistantWorkspacesList[0] ?? null, [assistantWorkspacesList] ); - const selectedAssistantWorkspace = useMemo(() => { - if (!selectedAssistantWorkspaceId) { - return null; - } - - return assistantWorkspacesList.find( - (workspace) => workspace.id === selectedAssistantWorkspaceId - ) ?? null; - }, [assistantWorkspacesList, selectedAssistantWorkspaceId]); - const resolvedAssistantWorkspace = useMemo(() => { - if (isAssistantWorkspaceActive && currentWorkspace?.workspaceKind === WorkspaceKind.Assistant) { - return currentWorkspace; - } - - if (selectedAssistantWorkspace) { - return selectedAssistantWorkspace; - } - - return defaultAssistantWorkspace; - }, [ - currentWorkspace, - defaultAssistantWorkspace, - isAssistantWorkspaceActive, - selectedAssistantWorkspace, - ]); - const resolvedAssistantDisplayName = useMemo(() => { - if (!resolvedAssistantWorkspace) { - return ''; - } - - return resolvedAssistantWorkspace.identity?.name?.trim() || resolvedAssistantWorkspace.name; - }, [resolvedAssistantWorkspace]); - const resolvedAssistantSessionId = useMemo(() => { - const workspacePath = resolvedAssistantWorkspace?.rootPath; - if (!workspacePath) { - return undefined; - } - - const workspaceSessions = Array.from(flowChatStore.getState().sessions.values()) - .filter(session => - (session.workspacePath || workspacePath) === workspacePath && !session.parentSessionId - ) - .sort(compareSessionsForDisplay); - - return workspaceSessions[0]?.sessionId; - }, [resolvedAssistantWorkspace]); - - const [navDisplayMode, setNavDisplayMode] = useState(getInitialNavDisplayMode); - const [isModeSwitching, setIsModeSwitching] = useState(false); - const [modeLogoSrc, setModeLogoSrc] = useState('/panda_1.png'); - const [modeLogoHoverSrc, setModeLogoHoverSrc] = useState('/panda_2.png'); - const [isScheduledJobsDialogOpen, setIsScheduledJobsDialogOpen] = useState(false); - const modeSwitchTimerRef = useRef(null); - const modeSwitchSwapTimerRef = useRef(null); - - useEffect(() => () => { - if (modeSwitchTimerRef.current !== null) { - window.clearTimeout(modeSwitchTimerRef.current); - } - if (modeSwitchSwapTimerRef.current !== null) { - window.clearTimeout(modeSwitchSwapTimerRef.current); - } - }, []); useEffect(() => { openedWorkspacesList.forEach(workspace => { @@ -293,31 +137,6 @@ const MainNav: React.FC = ({ }); }, [openedWorkspacesList]); - - - - const handleCreateAssistantWorkspace = useCallback(async () => { - try { - await workspaceManager.createAssistantWorkspace(); - } catch (err) { - log.error('Failed to create assistant workspace', err); - } - }, []); - - 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 (mode?: 'agentic' | 'Cowork' | 'Claw') => { openScene('session'); switchLeftPanelTab('sessions'); @@ -341,37 +160,10 @@ const MainNav: React.FC = ({ void handleCreateSession('Cowork'); }, [handleCreateSession, setSessionMode]); - const handleCreateAssistantSession = useCallback(async () => { - const targetAssistantWorkspace = - isAssistantWorkspaceActive && currentWorkspace?.workspaceKind === WorkspaceKind.Assistant - ? currentWorkspace - : defaultAssistantWorkspace; - - if (targetAssistantWorkspace && !isAssistantWorkspaceActive) { - try { - await setActiveWorkspace(targetAssistantWorkspace.id); - } catch (error) { - log.warn('Failed to activate assistant workspace before creating session', { error }); - } - } - - await handleCreateSession('Claw'); - }, [ - currentWorkspace, - defaultAssistantWorkspace, - handleCreateSession, - isAssistantWorkspaceActive, - setActiveWorkspace, - ]); - 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'), - }); + const selected = await open({ directory: true, multiple: false, title: t('header.selectProjectDirectory') }); if (selected && typeof selected === 'string') { await workspaceManager.openWorkspace(selected); } @@ -391,7 +183,6 @@ const MainNav: React.FC = ({ await switchWorkspace(targetWorkspace); }, [closeWorkspaceMenu, recentWorkspaces, switchWorkspace]); - // SSH Remote handlers const handleOpenRemoteSSH = useCallback(() => { closeWorkspaceMenu(); setIsSSHConnectionDialogOpen(true); @@ -401,7 +192,6 @@ const MainNav: React.FC = ({ try { await sshRemote.openWorkspace(path); sshRemote.setShowFileBrowser(false); - // Close the SSH connection dialog as well setIsSSHConnectionDialogOpen(false); } catch (err) { log.error('Failed to open remote workspace', err); @@ -410,7 +200,6 @@ const MainNav: React.FC = ({ useEffect(() => { if (!workspaceMenuOpen) return; - const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node | null; if (!target) return; @@ -418,13 +207,9 @@ const MainNav: React.FC = ({ if (workspaceMenuRef.current?.contains(target)) return; closeWorkspaceMenu(); }; - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - closeWorkspaceMenu(); - } + if (event.key === 'Escape') closeWorkspaceMenu(); }; - document.addEventListener('mousedown', handleClickOutside); document.addEventListener('keydown', handleEscape); return () => { @@ -433,8 +218,7 @@ const MainNav: React.FC = ({ }; }, [closeWorkspaceMenu, workspaceMenuOpen]); - - const handleOpenProfile = useCallback(() => { + const handleOpenAssistant = useCallback(() => { const targetAssistantWorkspace = isAssistantWorkspaceActive && currentWorkspace?.workspaceKind === WorkspaceKind.Assistant ? currentWorkspace @@ -443,147 +227,39 @@ const MainNav: React.FC = ({ if (targetAssistantWorkspace?.id) { setSelectedAssistantWorkspaceId(targetAssistantWorkspace.id); } - if (!isAssistantWorkspaceActive && targetAssistantWorkspace) { void setActiveWorkspace(targetAssistantWorkspace.id).catch(error => { log.warn('Failed to activate default assistant workspace', { error }); }); } - setMyAgentView('profile'); switchLeftPanelTab('profile'); - openScene('my-agent'); + openScene('assistant'); }, [ currentWorkspace, defaultAssistantWorkspace, isAssistantWorkspaceActive, openScene, setActiveWorkspace, - setMyAgentView, setSelectedAssistantWorkspaceId, switchLeftPanelTab, ]); - const handleOpenScheduledJobsDialog = useCallback(() => { - if (resolvedAssistantWorkspace?.id) { - setSelectedAssistantWorkspaceId(resolvedAssistantWorkspace.id); - } - setIsScheduledJobsDialogOpen(true); - }, [resolvedAssistantWorkspace, setSelectedAssistantWorkspaceId]); - - const handleOpenProModeSession = useCallback(async () => { - // Pick a project workspace (non-assistant) - const projectWorkspaces = openedWorkspacesList.filter( - w => w.workspaceKind !== WorkspaceKind.Assistant - ); - - const targetWorkspace = - currentWorkspace?.workspaceKind !== WorkspaceKind.Assistant - ? currentWorkspace - : projectWorkspaces[0] ?? null; + const handleOpenAgents = useCallback(() => { + openScene('agents'); + }, [openScene]); - // If assistant workspace is active, switch to a project workspace first - if (targetWorkspace && currentWorkspace?.id !== targetWorkspace.id) { - await setActiveWorkspace(targetWorkspace.id).catch(() => {}); - } + const handleOpenSkills = useCallback(() => { + openScene('skills'); + }, [openScene]); - const workspacePath = targetWorkspace?.rootPath; - const state = flowChatStore.getState(); - - if (workspacePath) { - const workspaceSessions = Array.from(state.sessions.values()) - .filter(s => - (s.workspacePath || workspacePath) === workspacePath && - !s.parentSessionId - ) - .sort(compareSessionsForDisplay); - - if (workspaceSessions.length > 0) { - const firstSession = workspaceSessions[0]; - if (firstSession.isHistorical) { - await flowChatStore.loadSessionHistory(firstSession.sessionId, workspacePath); - } - flowChatStore.switchSession(firstSession.sessionId); - openScene('session'); - switchLeftPanelTab('sessions'); - return; - } - } + const isAgentsActive = activeTabId === 'agents'; + const isSkillsActive = activeTabId === 'skills'; - // No session yet: pass workspacePath so Code session creation is not overridden by assistant workspace - openScene('session'); - switchLeftPanelTab('sessions'); - await flowChatManager.createChatSession({ workspacePath: workspacePath || undefined }, 'agentic'); - }, [currentWorkspace, openedWorkspacesList, openScene, setActiveWorkspace, switchLeftPanelTab]); - - const handleOpenAssistantModeSession = useCallback(async () => { - const targetAssistantWorkspace = - isAssistantWorkspaceActive && currentWorkspace?.workspaceKind === WorkspaceKind.Assistant - ? currentWorkspace - : defaultAssistantWorkspace; - - if (targetAssistantWorkspace && !isAssistantWorkspaceActive) { - await setActiveWorkspace(targetAssistantWorkspace.id).catch(() => {}); - } - - const workspacePath = targetAssistantWorkspace?.rootPath; - const state = flowChatStore.getState(); - - if (workspacePath) { - const workspaceSessions = Array.from(state.sessions.values()) - .filter(s => - (s.workspacePath || workspacePath) === workspacePath && - !s.parentSessionId - ) - .sort(compareSessionsForDisplay); - - if (workspaceSessions.length > 0) { - const firstSession = workspaceSessions[0]; - if (firstSession.isHistorical) { - await flowChatStore.loadSessionHistory(firstSession.sessionId, workspacePath); - } - flowChatStore.switchSession(firstSession.sessionId); - openScene('session'); - switchLeftPanelTab('sessions'); - return; - } - } - - // No session yet: create a Claw session - await handleCreateSession('Claw'); - }, [currentWorkspace, defaultAssistantWorkspace, handleCreateSession, isAssistantWorkspaceActive, openScene, setActiveWorkspace, switchLeftPanelTab]); - - const handleToggleNavDisplayMode = useCallback(() => { - // Ignore repeat clicks while the transition runs - if (modeSwitchTimerRef.current !== null) return; - - setIsModeSwitching(true); - - // Resolve target mode synchronously on click so timeouts do not close over a stale value - const nextMode: NavDisplayMode = navDisplayMode === 'pro' ? 'assistant' : 'pro'; - - // 200ms (clip-path at smallest dot): only flip nav display; no scene/session changes yet - if (modeSwitchSwapTimerRef.current !== null) { - window.clearTimeout(modeSwitchSwapTimerRef.current); + useEffect(() => { + if (isAgentsActive || isSkillsActive) { + setIsExtensionsOpen(true); } - modeSwitchSwapTimerRef.current = window.setTimeout(() => { - setNavDisplayMode(nextMode); - window.localStorage.setItem(NAV_DISPLAY_MODE_STORAGE_KEY, nextMode); - modeSwitchSwapTimerRef.current = null; - }, 200); - - // 480ms (animation finished): then switch scene/session so tab labels do not flicker mid-animation - modeSwitchTimerRef.current = window.setTimeout(() => { - setIsModeSwitching(false); - modeSwitchTimerRef.current = null; - if (nextMode === 'assistant') { - void handleOpenAssistantModeSession(); - } else { - void handleOpenProModeSession(); - } - }, 480); - }, [navDisplayMode, handleOpenAssistantModeSession, handleOpenProModeSession]); - - let flatCounter = 0; + }, [isAgentsActive, isSkillsActive]); const workspaceMenuPortal = workspaceMenuOpen ? createPortal(
= ({ type="button" className="bitfun-nav-panel__workspace-menu-item" role="menuitem" - onClick={() => { - closeWorkspaceMenu(); - void handleOpenProject(); - }} + onClick={() => { closeWorkspaceMenu(); void handleOpenProject(); }} > {t('header.openProject')} @@ -608,10 +281,7 @@ const MainNav: React.FC = ({ type="button" className="bitfun-nav-panel__workspace-menu-item" role="menuitem" - onClick={() => { - closeWorkspaceMenu(); - handleNewProject(); - }} + onClick={() => { closeWorkspaceMenu(); handleNewProject(); }} > {t('header.newProject')} @@ -658,265 +328,210 @@ const MainNav: React.FC = ({ document.body ) : null; - const personaTooltip = t('nav.items.persona'); - const createSessionTooltip = t('nav.sessions.newClawSession'); - const createAssistantTooltip = t('nav.workspaces.actions.newAssistant'); - const scheduledJobsTooltip = t('nav.scheduledJobs.open'); - const openProjectTooltip = t('header.openProject'); const createCodeTooltip = t('nav.sessions.newCodeSession'); const createCoworkTooltip = t('nav.sessions.newCoworkSession'); - const isAssistantNavMode = navDisplayMode === 'assistant'; - const navModeLabel = isAssistantNavMode - ? t('nav.displayModes.assistant') - : t('nav.displayModes.pro'); - const navModeHint = isAssistantNavMode - ? t('nav.displayModes.switchToPro') - : t('nav.displayModes.switchToAssistant'); - const navModeDesc = isAssistantNavMode - ? t('nav.displayModes.assistantDesc') - : t('nav.displayModes.proDesc'); - const navSections = useMemo( - () => NAV_SECTIONS.filter(section => isAssistantNavMode ? section.id === 'assistants' : section.id === 'workspace'), - [isAssistantNavMode] - ); - const myAgentEntryLabel = t('nav.actions.openMyAgent'); + const assistantTooltip = t('nav.items.persona'); + const openProjectTooltip = t('header.openProject'); + const isAssistantActive = activeTabId === 'assistant'; + const agentsTooltip = t('nav.tooltips.agents'); + const skillsTooltip = t('nav.tooltips.skills'); + const extensionsLabel = t('nav.sections.extensions'); return ( <> -
+ {/* ── Top action strip ────────────────────────── */} +
+ -
- {isAssistantNavMode ? ( - <> - - - - - - - - ) : ( - <> - - - - - - - - )} -
-
+ -
- {navSections.map(section => { - const isSectionOpen = expandedSections.has(section.id); - const isCollapsible = !!section.collapsible; - const showItems = !isCollapsible || isSectionOpen; - const sectionDir = getSectionDepartDir(section.id); - const sectionDepartCls = sectionDir ? ` is-departing-${sectionDir}` : ''; - - const sectionSceneId = section.sceneId; - - return ( -
- {section.label && ( - toggleSection(section.id)} - onSceneOpen={sectionSceneId ? () => openScene(sectionSceneId) : undefined} - actions={section.id === 'assistants' ? ( -
- - - -
- ) : section.id === 'workspace' ? ( -
- - - -
- ) : undefined} - /> - )} - -
-
-
- {section.id === 'assistants' && } - {section.id === 'workspace' && } - {section.items.map(item => { - const currentFlatIdx = flatCounter++; - const { tab } = item; - const dir = getDepartDir(currentFlatIdx); - const isActive = item.navSceneId - ? false - : item.sceneId - ? item.sceneId === activeTabId - : activeTabId === 'session' && tab === activeTab; - 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' - ? isAssistantWorkspaceActive - ? t('nav.sessions.newClawSession') - : t('nav.sessions.newCodeSession') - : undefined - } - onActionClick={tab === 'sessions' ? handleCreateSession : undefined} - /> -
- ); - })} -
-
-
-
- ); - })} -
+ + + -
- + + +
+ + + + +
+ + + + + + + +
+
-
- openScene('miniapps')} - onOpenMiniApp={(appId) => openScene(`miniapp:${appId}`)} - /> + {/* ── Sections ────────────────────────────────── */} +
+ + {/* Assistant sessions */} +
+ toggleSection('assistant-sessions')} + /> +
+
+
+ {assistantWorkspacesList.map(workspace => { + const assistantDisplayName = + workspace.workspaceKind === WorkspaceKind.Assistant + ? workspace.identity?.name?.trim() || workspace.name + : workspace.name; + return ( + + ); + })} +
+
+
+
+ + {/* Workspace */} +
+ toggleSection('workspace')} + actions={ +
+ + + +
+ } + /> +
+
+
+ +
+
+
+
+ +
+ + {/* ── Bottom: MiniApp ───────────────────────── */} +
+
+ openScene('miniapps')} + onOpenMiniApp={(appId) => openScene(`miniapp:${appId}`)} + /> +
{workspaceMenuPortal} - setIsScheduledJobsDialogOpen(false)} - targetWorkspacePath={resolvedAssistantWorkspace?.rootPath} - targetSessionId={resolvedAssistantSessionId} - assistantName={resolvedAssistantDisplayName} - hideTargetFields - listScope="workspace" - /> {/* SSH Remote Dialogs */} assistant) +@keyframes bitfun-nav-mode-pill-morph-to-assistant { 0% { - clip-path: circle(120% at 50% 50%); - animation-timing-function: cubic-bezier(0.55, 0, 0.75, 0.2); - } - 42% { - clip-path: circle(26px at 50% 50%); - animation-timing-function: linear; - } - // Content swaps around this hold (~200ms); the dot masks the jump - 58% { - clip-path: circle(26px at 50% 50%); - animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); + transform: translate3d(0, 0, 0) scale(1, 1); + border-radius: 999px; + background: var(--bitfun-mode-pill-liquid-pro-bg); + animation-timing-function: cubic-bezier(0.42, 0, 0.64, 0.12); } - 100% { - clip-path: circle(120% at 50% 50%); + 24% { + transform: translate3d(19%, 0, 0) scale(1.11, 0.9); + border-radius: 15px; + background: var(--bitfun-mode-pill-liquid-mid-bg); + animation-timing-function: cubic-bezier(0.36, 0.02, 0.22, 1); } -} - -// Logo: shrink + rotate out, reverse-rotate in while scaling up -@keyframes bitfun-nav-mode-switch-logo { - 0% { - transform: scale(1) rotate(0deg); - opacity: 1; - animation-timing-function: cubic-bezier(0.55, 0, 0.75, 0.2); - } - 40% { - transform: scale(0.1) rotate(-160deg); - opacity: 0; - animation-timing-function: linear; + 48% { + transform: translate3d(50%, 0, 0) scale(1.22, 0.82); + border-radius: 10px; + background: var(--bitfun-mode-pill-liquid-mid-bg); + animation-timing-function: cubic-bezier(0.24, 0.72, 0.32, 1); } - 60% { - transform: scale(0.1) rotate(160deg); - opacity: 0; + 72% { + transform: translate3d(84%, 0, 0) scale(1.05, 0.96); + border-radius: 17px; + background: color-mix(in srgb, var(--color-accent-500) 24%, var(--element-bg-medium)); animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); } 100% { - transform: scale(1) rotate(0deg); - opacity: 1; + transform: translate3d(100%, 0, 0) scale(1, 1); + border-radius: 999px; + background: var(--bitfun-mode-pill-liquid-assistant-bg); } } -// Copy block: fade out downward, fade in from above -@keyframes bitfun-nav-mode-switch-copy { +@keyframes bitfun-nav-mode-pill-morph-to-pro { 0% { - opacity: 1; - transform: translateY(0); - filter: blur(0px); - animation-timing-function: cubic-bezier(0.55, 0, 0.75, 0.2); + transform: translate3d(100%, 0, 0) scale(1, 1); + border-radius: 999px; + background: var(--bitfun-mode-pill-liquid-assistant-bg); + animation-timing-function: cubic-bezier(0.42, 0, 0.64, 0.12); } - 36% { - opacity: 0; - transform: translateY(5px); - filter: blur(3px); - animation-timing-function: linear; + 24% { + transform: translate3d(81%, 0, 0) scale(1.11, 0.9); + border-radius: 15px; + background: var(--bitfun-mode-pill-liquid-mid-bg); + animation-timing-function: cubic-bezier(0.36, 0.02, 0.22, 1); } - 64% { - opacity: 0; - transform: translateY(-5px); - filter: blur(3px); + 48% { + transform: translate3d(50%, 0, 0) scale(1.22, 0.82); + border-radius: 10px; + background: var(--bitfun-mode-pill-liquid-mid-bg); + animation-timing-function: cubic-bezier(0.24, 0.72, 0.32, 1); + } + 72% { + transform: translate3d(16%, 0, 0) scale(1.05, 0.96); + border-radius: 17px; + background: color-mix(in srgb, var(--color-primary) 20%, var(--element-bg-medium)); animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); } 100% { - opacity: 1; - transform: translateY(0); - filter: blur(0px); + transform: translate3d(0, 0, 0) scale(1, 1); + border-radius: 999px; + background: var(--bitfun-mode-pill-liquid-pro-bg); } } @@ -1653,9 +1692,10 @@ $_section-header-height: 24px; &__section-label, &__layer--scene, &__collapsible, - &__mode-switch, - &__mode-switch-logo, - &__mode-switch-copy, + &__mode-pill, + &__mode-pill-liquid, + &__mode-pill-segment, + &__mode-pill-segment-icon-img, &__sections { transition: none; animation: none; @@ -1664,3 +1704,156 @@ $_section-header-height: 24px; } } } + +// ── Top action strip ───────────────────────────── + +.bitfun-nav-panel__top-actions { + display: flex; + flex-direction: column; + gap: $size-gap-1; + padding: $size-gap-2 $size-gap-2 $size-gap-4; + flex-shrink: 0; +} + +.bitfun-nav-panel__top-action-expand { + display: flex; + flex-direction: column; + gap: 1px; +} + +.bitfun-nav-panel__top-action-sublist { + display: flex; + flex-direction: column; + gap: 1px; + overflow: hidden; + max-height: 0; + opacity: 0; + transition: max-height 0.22s $easing-standard, + opacity 0.15s $easing-standard; + + &.is-open { + max-height: 72px; + opacity: 1; + } +} + +// Align with section headers: __section-header margin-x 8 + padding-x 8 → label starts 16px from panel; __top-actions pad-x 8 + button pl 8 → icon column starts at 16px. +.bitfun-nav-panel__top-action-btn { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 0 $size-gap-2 0 $size-gap-2; + height: 32px; + border: none; + border-radius: $size-radius-base; + background: transparent; + color: var(--color-text-secondary); + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + text-align: left; + transition: background $motion-fast $easing-standard, + color $motion-fast $easing-standard; + + // Only the label span should grow; icon / chevron wrappers must NOT get flex: 1 + > span:not(.bitfun-nav-panel__top-action-icon-circle):not(.bitfun-nav-panel__top-action-icon-slot):not(.bitfun-nav-panel__top-action-expand-icons) { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + svg { flex-shrink: 0; } + + .bitfun-nav-panel__top-action-icon-slot { + width: 22px; + min-width: 22px; + height: 22px; + flex: 0 0 22px; + display: inline-flex; + align-items: center; + justify-content: center; + } + + &:hover { + background: var(--element-bg-soft); + color: var(--color-text-primary); + } + + &:active { transform: translateY(1px); } + + &:focus-visible { + outline: 1px solid var(--color-accent-500); + outline-offset: 1px; + } + + &.is-active { + background: var(--element-bg-soft); + color: var(--color-primary); + } + + &--sub { + padding-left: 28px; + } +} + +// 扩展:默认 Blocks,悬停时图标位切换为 Chevron;展开时箭头朝上 +.bitfun-nav-panel__top-action-expand-icons { + position: relative; + width: 22px; + min-width: 22px; + height: 22px; + flex: 0 0 22px; + flex-shrink: 0; + + > svg { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + transition: opacity $motion-fast $easing-standard, + transform $motion-fast $easing-standard; + } + + .bitfun-nav-panel__top-action-expand-icon-default { + opacity: 1; + } + + .bitfun-nav-panel__top-action-expand-icon-chevron { + opacity: 0; + + &.is-open { + transform: translate(-50%, -50%) rotate(180deg); + } + } +} + +.bitfun-nav-panel__top-action-btn--expand:hover .bitfun-nav-panel__top-action-expand-icons { + .bitfun-nav-panel__top-action-expand-icon-default { + opacity: 0; + } + + .bitfun-nav-panel__top-action-expand-icon-chevron { + opacity: 1; + } +} + +.bitfun-nav-panel__top-action-icon-circle { + width: 22px; + min-width: 22px; + height: 22px; + flex: 0 0 22px; + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--element-bg-medium); + transition: background $motion-fast $easing-standard; + + .bitfun-nav-panel__top-action-btn:hover & { + background: color-mix(in srgb, var(--color-primary) 18%, var(--element-bg-medium)); + } +} diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index d2fc11c6..c2e5c6eb 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useRef } from 'react'; -import { Settings, Info, MoreVertical, PictureInPicture2, SquareTerminal, Smartphone, Globe, Network, Layers } from 'lucide-react'; +import { Settings, Info, MoreVertical, PictureInPicture2, SquareTerminal, Smartphone, Globe, Network, Layers, BarChart3 } from 'lucide-react'; import { Tooltip, Modal } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { useSceneManager } from '../../../hooks/useSceneManager'; @@ -127,6 +127,13 @@ const PersistentFooterActions: React.FC = () => { multimodalHoverTimerRef.current = setTimeout(() => setMultimodalOpen(false), 180); }, []); + const handleOpenInsights = useCallback(() => { + openScene('insights'); + }, [openScene]); + + const insightsTooltip = t('nav.items.insights'); + const isInsightsActive = activeTabId === 'insights'; + const handleShowAbout = () => { closeMenu(); setShowAbout(true); @@ -306,6 +313,17 @@ const PersistentFooterActions: React.FC = () => { ); })()}
+ + + +
diff --git a/src/web-ui/src/app/components/NavPanel/config.ts b/src/web-ui/src/app/components/NavPanel/config.ts index cdec00eb..8ae66e7a 100644 --- a/src/web-ui/src/app/components/NavPanel/config.ts +++ b/src/web-ui/src/app/components/NavPanel/config.ts @@ -6,7 +6,8 @@ * * Section groups: * - workspace: project workspace essentials (sessions, files) - * - my-agent: everything describing the super agent (profile, agents) + * - assistant: assistant persona / nursery (profile) + * - extensions: top strip expand row → agents / skills (each own scene tab, MainNav stays visible) */ import type { NavSection } from './types'; 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 5919fa0f..2f44ab35 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 @@ -54,6 +54,10 @@ interface SessionsSectionProps { remoteConnectionId?: string | null; isActiveWorkspace?: boolean; showCreateActions?: boolean; + /** When set (e.g. assistant workspace), session row tooltip includes this assistant name. */ + assistantLabel?: string; + /** When false, hide the leading mode / running icon on each row (e.g. assistant detail page). */ + showSessionModeIcon?: boolean; } const SessionsSection: React.FC = ({ @@ -61,6 +65,8 @@ const SessionsSection: React.FC = ({ workspacePath, remoteConnectionId = null, isActiveWorkspace = true, + assistantLabel, + showSessionModeIcon = true, }) => { const { t } = useI18n('common'); const { setActiveWorkspace } = useWorkspaceContext(); @@ -336,12 +342,13 @@ const SessionsSection: React.FC = ({ [handleConfirmEdit, handleCancelEdit] ); + if (topLevelSessions.length === 0) { + return null; + } + return (
- {topLevelSessions.length === 0 ? ( -
{t('nav.sessions.noSessions')}
- ) : ( - visibleItems.map(({ session, level }) => { + {visibleItems.map(({ session, level }) => { const isEditing = editingSessionId === session.sessionId; const relationship = resolveSessionRelationship(session); const isBtwChild = level === 1 && relationship.isBtw; @@ -351,14 +358,26 @@ const SessionsSection: React.FC = ({ const parentSession = parentSessionId ? flowChatState.sessions.get(parentSessionId) : undefined; const parentTitle = parentSession ? resolveSessionTitle(parentSession) : ''; const parentTurnIndex = relationship.origin?.parentTurnIndex; - const tooltipContent = isBtwChild ? ( + const trimmedAssistant = assistantLabel?.trim() ?? ''; + const showAssistantInTooltip = trimmedAssistant.length > 0; + const showRichTooltip = showAssistantInTooltip || isBtwChild; + const tooltipContent = showRichTooltip ? (
{sessionTitle}
-
- {`来自 ${parentTitle || '父会话'}${parentTurnIndex ? ` · 第 ${parentTurnIndex} 轮` : ''}`} -
+ {showAssistantInTooltip ? ( +
+ {t('nav.sessions.assistantOwner', { name: trimmedAssistant })} +
+ ) : null} + {isBtwChild ? ( +
+ {`来自 ${parentTitle || '父会话'}${parentTurnIndex ? ` · 第 ${parentTurnIndex} 轮` : ''}`} +
+ ) : null}
- ) : sessionTitle; + ) : ( + sessionTitle + ); const SessionIcon = sessionModeKey === 'cowork' ? Users @@ -382,27 +401,29 @@ const SessionsSection: React.FC = ({ .join(' ')} onClick={() => handleSwitch(session.sessionId)} > - {isRunning ? ( - - ) : ( - - )} + {showSessionModeIcon ? ( + isRunning ? ( + + ) : ( + + ) + ) : null} {isEditing ? (
e.stopPropagation()}> @@ -489,8 +510,7 @@ const SessionsSection: React.FC = ({ {row} ); - }) - )} + })} {topLevelSessions.length > SESSIONS_LEVEL_0 && (
diff --git a/src/web-ui/src/app/components/SceneBar/types.ts b/src/web-ui/src/app/components/SceneBar/types.ts index 1588b8a0..0a481144 100644 --- a/src/web-ui/src/app/components/SceneBar/types.ts +++ b/src/web-ui/src/app/components/SceneBar/types.ts @@ -18,7 +18,8 @@ export type SceneTabId = | 'miniapps' | 'browser' | 'mermaid' - | 'my-agent' + | 'assistant' + | 'insights' | 'shell' | `miniapp:${string}`; diff --git a/src/web-ui/src/app/components/TitleBar/InsightsButton.tsx b/src/web-ui/src/app/components/TitleBar/InsightsButton.tsx index 984bf955..510f33a8 100644 --- a/src/web-ui/src/app/components/TitleBar/InsightsButton.tsx +++ b/src/web-ui/src/app/components/TitleBar/InsightsButton.tsx @@ -3,7 +3,6 @@ import { BarChart3 } from 'lucide-react'; import { Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { useSceneStore } from '@/app/stores/sceneStore'; -import { useMyAgentStore } from '@/app/scenes/my-agent/myAgentStore'; import { useInsightsStore } from '@/app/scenes/my-agent/insightsStore'; import './InsightsButton.scss'; @@ -18,8 +17,7 @@ const InsightsButton: React.FC = ({ className, tooltipPlace const progress = useInsightsStore((s) => s.progress); const handleClick = useCallback(() => { - useMyAgentStore.getState().setActiveView('insights'); - useSceneStore.getState().openScene('my-agent'); + useSceneStore.getState().openScene('insights'); }, []); const progressText = generating && progress.total > 0 diff --git a/src/web-ui/src/app/hooks/useDialogCompletionNotify.ts b/src/web-ui/src/app/hooks/useDialogCompletionNotify.ts new file mode 100644 index 00000000..7b89af38 --- /dev/null +++ b/src/web-ui/src/app/hooks/useDialogCompletionNotify.ts @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react'; +import { agentAPI } from '@/infrastructure/api'; +import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; +import { configManager } from '@/infrastructure/config'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { useI18n } from '@/infrastructure/i18n'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('useDialogCompletionNotify'); + +/** + * Listens for dialog turn completion events and sends an OS-level desktop + * notification (Windows toast / macOS notification center) when the window + * is not focused and the feature is enabled in config. + * + * Notification title = session title (or short session id fallback). + * Notification body = fixed "task completed" message. + * + * "Not focused" means: the page is hidden (minimized / tab switched) OR + * the window has lost focus to another OS-level application. + */ +export const useDialogCompletionNotify = () => { + const { t } = useI18n('common'); + // Track whether the window currently has OS-level focus + const windowFocusedRef = useRef(true); + + useEffect(() => { + const handleFocus = () => { windowFocusedRef.current = true; }; + const handleBlur = () => { windowFocusedRef.current = false; }; + + window.addEventListener('focus', handleFocus); + window.addEventListener('blur', handleBlur); + + const unlisten = agentAPI.onDialogTurnCompleted(async (event) => { + // Send notification if page is hidden OR window lost OS focus + const isBackground = document.hidden || !windowFocusedRef.current; + if (!isBackground) { + return; + } + + try { + const enabled = await configManager.getConfig( + 'app.notifications.dialog_completion_notify' + ); + if (enabled === false) { + return; + } + } catch (error) { + log.warn('Failed to read dialog_completion_notify config', error); + } + + // Resolve session title from store; fall back to short session id + const sessionId: string = event?.sessionId ?? ''; + const session = sessionId + ? flowChatStore.getState().sessions.get(sessionId) + : undefined; + const sessionTitle = + session?.title?.trim() || + (sessionId ? `Session ${sessionId.slice(0, 6)}` : 'BitFun'); + + await systemAPI.sendSystemNotification( + sessionTitle, + t('notify.dialogCompleted'), + ); + }); + + return () => { + window.removeEventListener('focus', handleFocus); + window.removeEventListener('blur', handleBlur); + unlisten(); + }; + }, [t]); +}; diff --git a/src/web-ui/src/app/scenes/SceneViewport.tsx b/src/web-ui/src/app/scenes/SceneViewport.tsx index cb4d4e49..ecef1975 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.tsx +++ b/src/web-ui/src/app/scenes/SceneViewport.tsx @@ -12,6 +12,7 @@ import React, { Suspense, lazy } from 'react'; import type { SceneTabId } from '../components/SceneBar/types'; import { useSceneManager } from '../hooks/useSceneManager'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; +import { useDialogCompletionNotify } from '../hooks/useDialogCompletionNotify'; import './SceneViewport.scss'; const SessionScene = lazy(() => import('./session/SessionScene')); @@ -25,7 +26,8 @@ const SkillsScene = lazy(() => import('./skills/SkillsScene')); const MiniAppGalleryScene = lazy(() => import('./miniapps/MiniAppGalleryScene')); const BrowserScene = lazy(() => import('./browser/BrowserScene')); const MermaidEditorScene = lazy(() => import('./mermaid/MermaidEditorScene')); -const MyAgentScene = lazy(() => import('./my-agent/MyAgentScene')); +const AssistantScene = lazy(() => import('./assistant/AssistantScene')); +const InsightsScene = lazy(() => import('./my-agent/InsightsScene')); const ShellScene = lazy(() => import('./shell/ShellScene')); const WelcomeScene = lazy(() => import('./welcome/WelcomeScene')); const MiniAppScene = lazy(() => import('./miniapps/MiniAppScene')); @@ -38,6 +40,7 @@ interface SceneViewportProps { const SceneViewport: React.FC = ({ workspacePath, isEntering = false }) => { const { openTabs, activeTabId } = useSceneManager(); const { t } = useI18n('common'); + useDialogCompletionNotify(); // All tabs closed — show empty state if (openTabs.length === 0) { @@ -98,8 +101,10 @@ function renderScene(id: SceneTabId, workspacePath?: string, isEntering?: boolea return ; case 'mermaid': return ; - case 'my-agent': - return ; + case 'assistant': + return ; + case 'insights': + return ; case 'shell': return ; default: diff --git a/src/web-ui/src/app/scenes/my-agent/MyAgentScene.scss b/src/web-ui/src/app/scenes/assistant/AssistantScene.scss similarity index 82% rename from src/web-ui/src/app/scenes/my-agent/MyAgentScene.scss rename to src/web-ui/src/app/scenes/assistant/AssistantScene.scss index 3c49ff60..bc7ce18a 100644 --- a/src/web-ui/src/app/scenes/my-agent/MyAgentScene.scss +++ b/src/web-ui/src/app/scenes/assistant/AssistantScene.scss @@ -1,4 +1,4 @@ -.bitfun-my-agent-scene { +.bitfun-assistant-scene { width: 100%; height: 100%; min-height: 0; diff --git a/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx b/src/web-ui/src/app/scenes/assistant/AssistantScene.tsx similarity index 56% rename from src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx rename to src/web-ui/src/app/scenes/assistant/AssistantScene.tsx index 72518fcb..0fe4995a 100644 --- a/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx +++ b/src/web-ui/src/app/scenes/assistant/AssistantScene.tsx @@ -1,21 +1,16 @@ -import React, { Suspense, lazy } from 'react'; -import { useMemo, useEffect } from 'react'; +import React, { Suspense, lazy, useMemo, useEffect } from 'react'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { WorkspaceKind } from '@/shared/types'; -import { useMyAgentStore } from './myAgentStore'; -import './MyAgentScene.scss'; +import { useMyAgentStore } from '../my-agent/myAgentStore'; +import './AssistantScene.scss'; const ProfileScene = lazy(() => import('../profile/ProfileScene')); -const AgentsScene = lazy(() => import('../agents/AgentsScene')); -const SkillsScene = lazy(() => import('../skills/SkillsScene')); -const InsightsScene = lazy(() => import('./InsightsScene')); -interface MyAgentSceneProps { +interface AssistantSceneProps { workspacePath?: string; } -const MyAgentScene: React.FC = ({ workspacePath }) => { - const activeView = useMyAgentStore((s) => s.activeView); +const AssistantScene: React.FC = ({ workspacePath }) => { const selectedAssistantWorkspaceId = useMyAgentStore((s) => s.selectedAssistantWorkspaceId); const setSelectedAssistantWorkspaceId = useMyAgentStore((s) => s.setSelectedAssistantWorkspaceId); const { currentWorkspace, assistantWorkspacesList } = useWorkspaceContext(); @@ -31,40 +26,24 @@ const MyAgentScene: React.FC = ({ workspacePath }) => { if (!selectedAssistantWorkspaceId) { return null; } - - return assistantWorkspacesList.find( - (workspace) => workspace.id === selectedAssistantWorkspaceId - ) ?? null; + return assistantWorkspacesList.find((workspace) => workspace.id === selectedAssistantWorkspaceId) ?? null; }, [assistantWorkspacesList, selectedAssistantWorkspaceId]); const resolvedAssistantWorkspace = useMemo(() => { if (activeAssistantWorkspace) { return activeAssistantWorkspace; } - if (selectedAssistantWorkspace) { return selectedAssistantWorkspace; } - return defaultAssistantWorkspace; - }, [ - activeAssistantWorkspace, - defaultAssistantWorkspace, - selectedAssistantWorkspace, - ]); + }, [activeAssistantWorkspace, defaultAssistantWorkspace, selectedAssistantWorkspace]); useEffect(() => { - if ( - activeAssistantWorkspace?.id - && activeAssistantWorkspace.id !== selectedAssistantWorkspaceId - ) { + if (activeAssistantWorkspace?.id && activeAssistantWorkspace.id !== selectedAssistantWorkspaceId) { setSelectedAssistantWorkspaceId(activeAssistantWorkspace.id); } - }, [ - activeAssistantWorkspace, - selectedAssistantWorkspaceId, - setSelectedAssistantWorkspaceId, - ]); + }, [activeAssistantWorkspace, selectedAssistantWorkspaceId, setSelectedAssistantWorkspaceId]); useEffect(() => { const selectedExists = selectedAssistantWorkspaceId @@ -87,20 +66,15 @@ const MyAgentScene: React.FC = ({ workspacePath }) => { ]); return ( -
- }> - {activeView === 'profile' && ( - - )} - {activeView === 'agents' && } - {activeView === 'skills' && } - {activeView === 'insights' && } +
+ }> +
); }; -export default MyAgentScene; +export default AssistantScene; diff --git a/src/web-ui/src/app/scenes/my-agent/AssistantScheduleView.scss b/src/web-ui/src/app/scenes/my-agent/AssistantScheduleView.scss new file mode 100644 index 00000000..00f9c0cb --- /dev/null +++ b/src/web-ui/src/app/scenes/my-agent/AssistantScheduleView.scss @@ -0,0 +1,270 @@ +@use '../../../component-library/styles/tokens.scss' as *; + +// ── Root ────────────────────────────────────────────────────────────────────── + +.asv { + display: flex; + flex-direction: column; + gap: $size-gap-3; + width: 100%; + + // ── Header ───────────────────────────────────────────────────────────────── + + &__head { + display: flex; + align-items: center; + gap: $size-gap-3; + padding-bottom: $size-gap-3; + } + + &__head-title { + flex: 1; + font-size: $font-size-2xs; + font-weight: $font-weight-semibold; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-muted); + } + + &__new-job.btn { + flex-shrink: 0; + background: var(--element-bg-medium); + color: var(--color-text-primary); + + &:hover:not(:disabled) { + background: var(--element-bg-strong); + color: var(--color-text-primary); + } + + &:active:not(:disabled) { + background: var(--element-bg-base); + color: var(--color-text-primary); + } + } + + &__spin { + animation: asv-spin 0.8s linear infinite; + } + + // ── Empty state ───────────────────────────────────────────────────────────── + + &__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $size-gap-3; + padding: $size-gap-10 $size-gap-5; + text-align: center; + } + + &__empty-title { + margin: 0; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: var(--color-text-secondary); + } + + &__empty-text { + margin: 0; + font-size: $font-size-sm; + color: var(--color-text-muted); + line-height: $line-height-relaxed; + } + + // ── Job list ──────────────────────────────────────────────────────────────── + + &__list { + display: flex; + flex-direction: column; + gap: $size-gap-3; + border: none; + border-radius: 0; + overflow: visible; + background: transparent; + } + + // ── Job cards ─────────────────────────────────────────────────────────────── + + &__item { + display: flex; + align-items: flex-start; + gap: $size-gap-3; + padding: $size-gap-3; + border: none; + border-radius: $size-radius-lg; + background: var(--element-bg-base); + cursor: pointer; + position: relative; + transform: translateY(0); + box-shadow: none; + transition: + background $motion-fast $easing-standard, + transform $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard; + + &:hover { + z-index: 1; + background: var(--element-bg-soft); + transform: translateY(-2px); + box-shadow: var(--shadow-sm, 0 4px 14px rgba(0, 0, 0, 0.2)); + } + + &:active { + transform: translateY(-1px); + box-shadow: var(--shadow-xs, 0 2px 6px rgba(0, 0, 0, 0.16)); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500, #6366f1); + outline-offset: 2px; + } + } + + &__item-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: $size-gap-1; + } + + &__item-top { + display: flex; + align-items: center; + gap: $size-gap-3; + min-width: 0; + } + + &__item-name { + flex: 1; + min-width: 0; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__item-meta { + font-size: $font-size-xs; + color: var(--color-text-secondary); + line-height: $line-height-relaxed; + + &--dim { + color: var(--color-text-muted); + } + } + + &__item-error { + font-size: $font-size-xs; + color: var(--color-danger-500, #f87171); + line-height: $line-height-relaxed; + word-break: break-word; + } + + &__item-actions { + flex-shrink: 0; + display: flex; + align-items: center; + gap: $size-gap-2; + } + + &__switch-wrap { + flex-shrink: 0; + display: flex; + align-items: center; + } + + &__field--switch { + min-width: 0; + } + + // ── Form ────────────────────────────────────────────────────────────────── + + &__form { + display: flex; + flex-direction: column; + gap: $size-gap-4; + padding: $size-gap-4; + } + + &__form-row { + display: flex; + flex-direction: column; + + &--two { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $size-gap-4; + align-items: start; + } + } + + &__field { + display: flex; + flex-direction: column; + gap: $size-gap-3; + } + + &__field-label { + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + color: var(--color-text-secondary); + } + + &__warning { + font-size: $font-size-sm; + color: var(--color-danger-500, #f87171); + line-height: $line-height-relaxed; + padding: $size-gap-3 $size-gap-4; + border-radius: $size-radius-base; + background: rgba(248, 113, 113, 0.06); + border: 1px solid rgba(248, 113, 113, 0.2); + } + + &__form-actions { + display: flex; + justify-content: flex-end; + gap: $size-gap-3; + padding-top: $size-gap-2; + } +} + +@media (prefers-reduced-motion: reduce) { + .asv__item { + transition: background $motion-fast $easing-standard; + + &:hover, + &:active { + transform: none; + } + } +} + +// ── Animations ──────────────────────────────────────────────────────────────── + +@keyframes asv-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes asv-expand { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +// ── Legacy embed wrapper (keep compatible) ───────────────────────────────── + +.nursery-assistant-schedule-embed { + width: 100%; + + .asv { + width: 100%; + } +} + +.nursery-assistant-schedule-embed-fallback { + min-height: 80px; +} diff --git a/src/web-ui/src/app/scenes/my-agent/AssistantScheduleView.tsx b/src/web-ui/src/app/scenes/my-agent/AssistantScheduleView.tsx new file mode 100644 index 00000000..02247b3d --- /dev/null +++ b/src/web-ui/src/app/scenes/my-agent/AssistantScheduleView.tsx @@ -0,0 +1,632 @@ +/** + * AssistantScheduleView — inline view for managing scheduled jobs of an assistant. + * + * Redesigned for the 40%-wide right panel: single-column layout, + * job list at top, inline editor expands below selected job. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { RefreshCw, Trash2 } from 'lucide-react'; +import { + Button, + IconButton, + Input, + Modal, + Select, + Switch, + Textarea, + confirmDanger, +} from '@/component-library'; +import { + cronAPI, + type CreateCronJobRequest, + type CronJob, + type CronSchedule, + type UpdateCronJobRequest, +} from '@/infrastructure/api'; +import { useI18n } from '@/infrastructure/i18n'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { FlowChatState, Session } from '@/flow_chat/types/flow-chat'; +import { compareSessionsForDisplay } from '@/flow_chat/utils/sessionOrdering'; +import { notificationService } from '@/shared/notification-system/services/NotificationService'; +import { createLogger } from '@/shared/utils/logger'; +import './AssistantScheduleView.scss'; + +const log = createLogger('AssistantScheduleView'); +const MINUTE_IN_MS = 60_000; + +type ScheduleKind = CronSchedule['kind']; + +interface JobDraft { + name: string; + text: string; + enabled: boolean; + workspacePath: string; + sessionId: string; + scheduleKind: ScheduleKind; + at: string; + everyMinutes: string; + anchorMs: string; + expr: string; + tz: string; +} + +export interface AssistantScheduleViewProps { + workspacePath?: string; + sessionId?: string; + assistantName?: string; +} + +function getCurrentLocalDateTimeInput(): string { + return toLocalDateTimeInput(new Date().toISOString()); +} + +function toLocalDateTimeInput(isoTimestamp: string): string { + const date = new Date(isoTimestamp); + const timezoneOffset = date.getTimezoneOffset(); + const localDate = new Date(date.getTime() - timezoneOffset * 60_000); + return localDate.toISOString().slice(0, 16); +} + +function timestampMsToLocalDateTimeInput(timestampMs: number): string { + return toLocalDateTimeInput(new Date(timestampMs).toISOString()); +} + +function formatEveryMinutes(everyMs: number): string { + const everyMinutes = everyMs / MINUTE_IN_MS; + if (Number.isInteger(everyMinutes)) return String(everyMinutes); + return everyMinutes.toFixed(2).replace(/\.?0+$/, ''); +} + +const createEmptyDraft = (workspacePath = '', sessionId = ''): JobDraft => ({ + name: '', + text: '', + enabled: true, + workspacePath, + sessionId, + scheduleKind: 'cron', + at: getCurrentLocalDateTimeInput(), + everyMinutes: '60', + anchorMs: '', + expr: '0 8 * * *', + tz: '', +}); + +function jobToDraft(job: CronJob): JobDraft { + const base = createEmptyDraft(job.workspacePath, job.sessionId); + const draft: JobDraft = { ...base, name: job.name, text: job.payload.text, enabled: job.enabled }; + if (job.schedule.kind === 'at') { + draft.scheduleKind = 'at'; + draft.at = toLocalDateTimeInput(job.schedule.at); + } else if (job.schedule.kind === 'every') { + draft.scheduleKind = 'every'; + draft.everyMinutes = formatEveryMinutes(job.schedule.everyMs); + draft.anchorMs = job.schedule.anchorMs != null + ? timestampMsToLocalDateTimeInput(job.schedule.anchorMs) + : ''; + } else { + draft.scheduleKind = 'cron'; + draft.expr = job.schedule.expr; + draft.tz = job.schedule.tz ?? ''; + } + return draft; +} + +function buildScheduleFromDraft(draft: JobDraft): CronSchedule { + if (draft.scheduleKind === 'at') { + if (!draft.at.trim()) throw new Error('Please select a valid datetime.'); + return { kind: 'at', at: new Date(draft.at).toISOString() }; + } + if (draft.scheduleKind === 'every') { + const everyMinutes = Number(draft.everyMinutes); + if (!Number.isFinite(everyMinutes) || everyMinutes <= 0) { + throw new Error('Interval must be greater than 0 minutes.'); + } + const anchorMs = draft.anchorMs.trim() ? new Date(draft.anchorMs).getTime() : undefined; + return { kind: 'every', everyMs: Math.round(everyMinutes * MINUTE_IN_MS), anchorMs }; + } + if (!draft.expr.trim()) throw new Error('Cron expression is required.'); + return { kind: 'cron', expr: draft.expr.trim(), tz: draft.tz.trim() || undefined }; +} + +function validateDraft( + draft: JobDraft, + t: (key: string, params?: Record) => string, +): string | null { + if (!draft.name.trim()) return t('nav.scheduledJobs.validation.nameRequired'); + if (!draft.text.trim()) return t('nav.scheduledJobs.validation.promptRequired'); + if (!draft.workspacePath.trim()) return t('nav.scheduledJobs.validation.workspaceRequired'); + if (!draft.sessionId.trim()) return t('nav.scheduledJobs.validation.sessionRequired'); + return null; +} + +function getNextExecutionAtMs(job: CronJob): number | null { + return job.state.pendingTriggerAtMs ?? job.state.retryAtMs ?? job.state.nextRunAtMs ?? null; +} + +function formatScheduleSummary( + schedule: CronSchedule, + t: (key: string, params?: Record) => string, +): string { + switch (schedule.kind) { + case 'at': + return `${t('nav.scheduledJobs.scheduleKinds.at')}: ${formatTimestamp(new Date(schedule.at).getTime(), t)}`; + case 'every': + return t('nav.scheduledJobs.scheduleSummary.every', { everyMinutes: formatEveryMinutes(schedule.everyMs) }); + case 'cron': + return schedule.tz + ? t('nav.scheduledJobs.scheduleSummary.cronWithTz', { expr: schedule.expr, tz: schedule.tz }) + : t('nav.scheduledJobs.scheduleSummary.cron', { expr: schedule.expr }); + default: + return ''; + } +} + +function formatTimestamp( + timestampMs: number | null | undefined, + t: (key: string, params?: Record) => string, +): string { + if (!timestampMs || !Number.isFinite(timestampMs)) return t('nav.scheduledJobs.never'); + return new Intl.DateTimeFormat(undefined, { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', + }).format(timestampMs); +} + +function resolveSessionLabel(session: Session): string { + return session.title?.trim() || session.sessionId.slice(0, 8); +} + +const AssistantScheduleView: React.FC = ({ + workspacePath, + sessionId, +}) => { + const { t } = useI18n('common'); + const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState()); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [selectedJobId, setSelectedJobId] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [draft, setDraft] = useState(() => + createEmptyDraft(workspacePath ?? '', sessionId ?? ''), + ); + + useEffect(() => { + const unsubscribe = flowChatStore.subscribe((state) => setFlowChatState(state)); + return unsubscribe; + }, []); + + const workspaceSessions = useMemo(() => { + const wp = workspacePath?.trim() ?? ''; + if (!wp) return [] as Session[]; + return Array.from(flowChatState.sessions.values()) + .filter(s => (s.workspacePath || wp) === wp && !s.parentSessionId) + .sort(compareSessionsForDisplay); + }, [workspacePath, flowChatState.sessions]); + + const defaultSessionIdForWorkspace = useMemo( + () => workspaceSessions[0]?.sessionId ?? '', + [workspaceSessions], + ); + + const sortedJobs = useMemo(() => [...jobs].sort((a, b) => { + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + const diff = b.configUpdatedAtMs - a.configUpdatedAtMs; + return diff !== 0 ? diff : b.createdAtMs - a.createdAtMs; + }), [jobs]); + + const loadJobs = useCallback(async () => { + setLoading(true); + try { + const result = await cronAPI.listJobs({ workspacePath: workspacePath || undefined }); + setJobs(result); + setSelectedJobId(current => { + if (current && result.some(j => j.id === current)) return current; + return null; + }); + } catch (error) { + log.error('Failed to load scheduled jobs', { error }); + notificationService.error( + t('nav.scheduledJobs.messages.loadFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } finally { + setLoading(false); + } + }, [workspacePath, t]); + + useEffect(() => { void loadJobs(); }, [loadJobs]); + + useEffect(() => { + setDraft(prev => ({ + ...prev, + workspacePath: workspacePath ?? '', + sessionId: sessionId || prev.sessionId || defaultSessionIdForWorkspace, + })); + }, [workspacePath, sessionId, defaultSessionIdForWorkspace]); + + const handleCreateNew = useCallback(() => { + setSelectedJobId(null); + setDraft(createEmptyDraft(workspacePath ?? '', sessionId || defaultSessionIdForWorkspace)); + setModalOpen(true); + }, [workspacePath, sessionId, defaultSessionIdForWorkspace]); + + const handleEditJob = useCallback((job: CronJob) => { + setSelectedJobId(job.id); + setDraft(jobToDraft(job)); + setModalOpen(true); + }, []); + + const handleCloseModal = useCallback(() => { + setModalOpen(false); + }, []); + + const handleDeleteJob = useCallback(async (job: CronJob) => { + const confirmed = await confirmDanger( + t('nav.scheduledJobs.deleteDialog.title', { name: job.name }), + null, + ); + if (!confirmed) return; + try { + await cronAPI.deleteJob(job.id); + notificationService.success(t('nav.scheduledJobs.messages.deleteSuccess')); + if (selectedJobId === job.id) { setSelectedJobId(null); setModalOpen(false); } + await loadJobs(); + } catch (error) { + log.error('Failed to delete scheduled job', { jobId: job.id, error }); + notificationService.error( + t('nav.scheduledJobs.messages.deleteFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } + }, [loadJobs, selectedJobId, t]); + + const handleToggleEnabled = useCallback(async (job: CronJob, enabled: boolean) => { + try { + await cronAPI.updateJob(job.id, { enabled }); + await loadJobs(); + } catch (error) { + log.error('Failed to toggle scheduled job', { jobId: job.id, error }); + notificationService.error( + t('nav.scheduledJobs.messages.updateFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } + }, [loadJobs, t]); + + const handleSave = useCallback(async () => { + const validationError = validateDraft(draft, t); + if (validationError) { notificationService.error(validationError); return; } + let schedule: CronSchedule; + try { schedule = buildScheduleFromDraft(draft); } catch (error) { + notificationService.error(error instanceof Error ? error.message : String(error)); + return; + } + setSaving(true); + try { + if (selectedJobId) { + const request: UpdateCronJobRequest = { + name: draft.name.trim(), + payload: { text: draft.text.trim() }, + enabled: draft.enabled, + schedule, + workspacePath: draft.workspacePath.trim(), + sessionId: draft.sessionId.trim(), + }; + const updated = await cronAPI.updateJob(selectedJobId, request); + setSelectedJobId(updated.id); + setDraft(jobToDraft(updated)); + notificationService.success(t('nav.scheduledJobs.messages.updateSuccess')); + setModalOpen(false); + } else { + const request: CreateCronJobRequest = { + name: draft.name.trim(), + payload: { text: draft.text.trim() }, + enabled: draft.enabled, + schedule, + workspacePath: draft.workspacePath.trim(), + sessionId: draft.sessionId.trim(), + }; + const created = await cronAPI.createJob(request); + setSelectedJobId(created.id); + setDraft(jobToDraft(created)); + notificationService.success(t('nav.scheduledJobs.messages.createSuccess')); + setModalOpen(false); + } + await loadJobs(); + } catch (error) { + log.error('Failed to save scheduled job', { error }); + notificationService.error( + t('nav.scheduledJobs.messages.saveFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } finally { + setSaving(false); + } + }, [draft, loadJobs, selectedJobId, t]); + + const sessionOptions = useMemo( + () => workspaceSessions.map(s => ({ + value: s.sessionId, + label: resolveSessionLabel(s), + description: s.title || s.sessionId, + })), + [workspaceSessions], + ); + + const canSave = Boolean(draft.workspacePath.trim() && draft.sessionId.trim()); + + const modalTitle = selectedJobId + ? t('nav.scheduledJobs.editor.editTitle') + : t('nav.scheduledJobs.editor.createTitle'); + + return ( +
+ {/* ── Header ── */} +
+ {t('nav.scheduledJobs.title')} + +
+ + {/* ── Job list ── */} + {loading ? ( +
+ +
+ ) : sortedJobs.length === 0 ? ( +
+

{t('nav.scheduledJobs.empty.title')}

+

{t('nav.scheduledJobs.empty.description')}

+
+ ) : ( +
+ {sortedJobs.map(job => ( +
handleEditJob(job)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleEditJob(job); + } + }} + > +
+
+ {job.name} +
+
+ {formatScheduleSummary(job.schedule, t)} +
+
+ {t('nav.scheduledJobs.nextRunLabel')}: {formatTimestamp(getNextExecutionAtMs(job), t)} +
+ {job.state.lastError ? ( +
{job.state.lastError}
+ ) : null} +
+
+
e.stopPropagation()} + role="presentation" + > + { + void handleToggleEnabled(job, e.currentTarget.checked); + }} + aria-label={t('nav.scheduledJobs.actions.toggleEnabled')} + /> +
+ { e.stopPropagation(); void handleDeleteJob(job); }} + > + + +
+
+ ))} +
+ )} + + {/* ── Edit / Create modal ── */} + + {renderForm()} + +
+ ); + + function renderForm() { + return ( +
+ {!canSave && ( +

{t('nav.scheduledJobs.messages.sessionRequired')}

+ )} + +
+ { + const name = e.currentTarget.value; + setDraft(c => ({ ...c, name })); + }} + placeholder={t('nav.scheduledJobs.placeholders.name')} + /> +
+ +
+ { + const at = e.currentTarget.value; + setDraft(c => ({ ...c, at })); + }} + /> +
+ )} + + {draft.scheduleKind === 'every' && ( +
+ { + const everyMinutes = e.currentTarget.value; + setDraft(c => ({ ...c, everyMinutes })); + }} + placeholder="60" + /> + { + const anchorMs = e.currentTarget.value; + setDraft(c => ({ ...c, anchorMs })); + }} + placeholder={t('nav.scheduledJobs.placeholders.anchorMs')} + /> +
+ )} + + {draft.scheduleKind === 'cron' && ( +
+ { + const expr = e.currentTarget.value; + setDraft(c => ({ ...c, expr })); + }} + placeholder="0 8 * * *" + /> + { + const tz = e.currentTarget.value; + setDraft(c => ({ ...c, tz })); + }} + placeholder={t('nav.scheduledJobs.placeholders.timezone')} + /> +
+ )} + +
+