From f768d549d54a99c31bea35ecf2d6109d7cbb6e88 Mon Sep 17 00:00:00 2001 From: Douglas Date: Tue, 24 Mar 2026 19:42:35 +0000 Subject: [PATCH 01/19] project side bar and new layout design --- electron/main/index.ts | 8 +- src/components/ChatBox/BottomBox/index.tsx | 8 +- src/components/ChatBox/HeaderBox/index.tsx | 12 +- .../ChatBox/ProjectChatContainer.tsx | 22 + src/components/ChatBox/index.tsx | 83 +-- src/components/GlobalSearch/index.tsx | 82 ++- src/components/HistorySidebar/index.tsx | 51 +- src/components/ProjectPageSidebar/index.tsx | 679 ++++++++++++++++++ src/components/TopBar/index.tsx | 139 ++-- src/components/ui/button.tsx | 2 +- src/components/ui/command.tsx | 42 +- src/components/ui/dialog.tsx | 28 +- src/components/ui/dropdown-menu.tsx | 23 +- src/components/ui/resizable.tsx | 21 +- src/hooks/useInitialChatPanelLayout.ts | 46 ++ src/i18n/locales/en-us/agents.json | 9 +- src/i18n/locales/en-us/layout.json | 5 +- src/lib/taskLifecycleUi.ts | 92 +++ src/pages/Agents/Skills.tsx | 42 +- .../Agents/components/SkillUploadDialog.tsx | 292 +++++--- src/pages/Agents/index.tsx | 28 +- src/pages/Browser/CDP.tsx | 46 +- src/pages/Browser/index.tsx | 32 +- src/pages/Connectors/MCP.tsx | 52 +- src/pages/Home.tsx | 25 +- src/pages/Project/Workspace.tsx | 48 +- src/store/pageTabStore.ts | 137 ++-- src/style/token.css | 2 +- tailwind.config.js | 2 + test/unit/electron/main/index.test.ts | 16 +- 30 files changed, 1583 insertions(+), 491 deletions(-) create mode 100644 src/components/ProjectPageSidebar/index.tsx create mode 100644 src/hooks/useInitialChatPanelLayout.ts create mode 100644 src/lib/taskLifecycleUi.ts diff --git a/electron/main/index.ts b/electron/main/index.ts index 1b9d5da40..e354ba564 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -2740,10 +2740,10 @@ async function createWindow() { // Windows: native frame and solid background. macOS/Linux: frameless; macOS corner radius via native hook. win = new BrowserWindow({ title: 'Eigent', - width: 1200, - height: 800, - minWidth: 1050, - minHeight: 650, + width: 1366, + height: 860, + minWidth: 1100, + minHeight: 700, // Use native frame on Windows for better native integration frame: isWindows ? true : false, show: false, // Don't show until content is ready to avoid white screen diff --git a/src/components/ChatBox/BottomBox/index.tsx b/src/components/ChatBox/BottomBox/index.tsx index afac2f994..62d7ad1e7 100644 --- a/src/components/ChatBox/BottomBox/index.tsx +++ b/src/components/ChatBox/BottomBox/index.tsx @@ -69,7 +69,7 @@ export default function BottomBox({ const enableQueuedBox = true; //TODO: Fix the reason of queued box disable in https://github.com/eigent-ai/eigent/issues/684 // Background color reflects current state only - let backgroundClass = 'bg-input-bg-default'; + let backgroundClass = 'bg-transparent'; if (state === 'splitting') backgroundClass = 'bg-input-bg-spliting'; else if (state === 'confirm') backgroundClass = 'bg-input-bg-confirm'; @@ -77,7 +77,7 @@ export default function BottomBox({
{/* QueuedBox overlay (should not affect BoxMain layout) */} {enableQueuedBox && queuedMessages.length > 0 && ( -
+
)} {/* BoxMain */} -
+
{/* BoxHeader variants */} {state === 'splitting' && } {state === 'confirm' && ( diff --git a/src/components/ChatBox/HeaderBox/index.tsx b/src/components/ChatBox/HeaderBox/index.tsx index 3c12df89d..4dcd7f689 100644 --- a/src/components/ChatBox/HeaderBox/index.tsx +++ b/src/components/ChatBox/HeaderBox/index.tsx @@ -50,21 +50,17 @@ export function HeaderBox({ return (
{/* Left: title + replay button */} -
- - {t('chat.chat-title')} - - +
{showReplayButton && (
{/* Right: project total token count */} -
+
{t('chat.token-total-label')}{' '} diff --git a/src/components/ChatBox/ProjectChatContainer.tsx b/src/components/ChatBox/ProjectChatContainer.tsx index 573284560..e2f10887c 100644 --- a/src/components/ChatBox/ProjectChatContainer.tsx +++ b/src/components/ChatBox/ProjectChatContainer.tsx @@ -13,6 +13,7 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { usePageTabStore } from '@/store/pageTabStore'; import { AnimatePresence } from 'framer-motion'; import React, { useCallback, @@ -176,6 +177,27 @@ export const ProjectChatContainer: React.FC = ({ }; }, []); + // Scroll to a specific query group when triggered from the sidebar + const scrollToQueryId = usePageTabStore((s) => s.scrollToQueryId); + const setScrollToQueryId = usePageTabStore((s) => s.setScrollToQueryId); + + useEffect(() => { + if (!scrollToQueryId || !containerRef.current) return; + + const el = containerRef.current.querySelector( + `[data-query-id="${scrollToQueryId}"]` + ); + if (el) { + const container = containerRef.current; + const containerRect = container.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const scrollOffset = elRect.top - containerRect.top + container.scrollTop; + container.scrollTo({ top: scrollOffset, behavior: 'smooth' }); + } + + setScrollToQueryId(null); + }, [scrollToQueryId, setScrollToQueryId]); + return (
{ if (!chatStore.activeTaskId) return 'input'; const task = chatStore.tasks[chatStore.activeTaskId]; - - // Queued messages no longer change BottomBox state; QueuedBox renders independently - - // Check for any to_sub_tasks message (confirmed or not) - const anyToSubTasksMessage = task.messages.find( - (m) => m.step === 'to_sub_tasks' - ); - const toSubTasksMessage = task.messages.find( - (m) => m.step === 'to_sub_tasks' && !m.isConfirm - ); - - // Determine if we're in the "splitting in progress" phase (skeleton visible) - // Only show splitting if there's NO to_sub_tasks message yet (not even confirmed) - const isSkeletonPhase = - (task.status !== 'finished' && - !anyToSubTasksMessage && - !task.hasWaitComfirm && - task.messages.length > 0) || - (task.isTakeControl && !anyToSubTasksMessage); - if (isSkeletonPhase) { - return 'splitting'; - } - - // After splitting completes and TaskCard is awaiting user confirmation, - // the Task becomes 'pending' and we show the confirm state. - if ( - toSubTasksMessage && - !toSubTasksMessage.isConfirm && - task.status === 'pending' - ) { - return 'confirm'; - } - - // If subtasks exist but not yet confirmed while task is still running, keep showing splitting - if (toSubTasksMessage && !toSubTasksMessage.isConfirm) { - return 'splitting'; - } - - // Check task status - if ( - task.status === ChatTaskStatus.RUNNING || - task.status === ChatTaskStatus.PAUSE - ) { - return 'running'; - } - - if (task.status === 'finished' && task.type !== '') { - return 'finished'; - } - - return 'input'; + return getBottomBoxStateForTask(task); }; const handleRemoveTaskQueue = async (task_id: string) => { @@ -1032,7 +983,7 @@ export default function ChatBox(): JSX.Element { } return ( -
+
{/* Unified ChatBox Structure */}
{/* Header Box - Always visible */} @@ -1049,10 +1000,10 @@ export default function ChatBox(): JSX.Element {
{/* Project Chat Container - Show when has messages (absolute, full height) */}
{/* Welcome Message - Top area, flex-1 to push content down */} -
-
+
+
{t('layout.welcome-to-eigent')}
@@ -1109,24 +1060,24 @@ export default function ChatBox(): JSX.Element { )} {/* Suggestion Area - Bottom area, flex-1 to push content up */} -
+
{!hasModel ? ( -
+
{ navigate('/history?tab=agents'); }} - className="flex cursor-pointer items-center gap-2 rounded-md bg-surface-warning px-sm py-xs" + className="gap-2 rounded-md bg-surface-warning px-sm py-xs flex cursor-pointer items-center" > - + {t('layout.please-select-model')}
) : null} {hasModel && ( -
+
{[ { label: t('layout.it-ticket-creation'), @@ -1143,7 +1094,7 @@ export default function ChatBox(): JSX.Element { ].map(({ label, message }) => (
{ setMessage(message); }} diff --git a/src/components/GlobalSearch/index.tsx b/src/components/GlobalSearch/index.tsx index 3d42566a6..ed5a3d7a5 100644 --- a/src/components/GlobalSearch/index.tsx +++ b/src/components/GlobalSearch/index.tsx @@ -14,7 +14,7 @@ import { useState } from 'react'; -import { Calculator, Calendar, Smile } from 'lucide-react'; +import { Calculator, Calendar, Search, Smile } from 'lucide-react'; import { CommandDialog, @@ -26,18 +26,49 @@ import { CommandSeparator, } from '@/components/ui/command'; import { DialogTitle } from '@/components/ui/dialog'; -import { Search } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -const _items = [ - 'Apple', - 'Banana', - 'Orange', - 'Grape', - 'Watermelon', - 'Pineapple', - 'Mango', - 'Blueberry', -]; + +export interface GlobalSearchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function GlobalSearchDialog({ + open, + onOpenChange, +}: GlobalSearchDialogProps) { + const { t } = useTranslation(); + return ( + + {t('dashboard.search')} + + + {t('dashboard.no-results')} + + + + {t('dashboard.calendar')} + + + + {t('dashboard.search-emoji')} + + + + {t('dashboard.calculator')} + + + + + + ); +} export function GlobalSearch() { const [open, setOpen] = useState(false); @@ -45,36 +76,15 @@ export function GlobalSearch() { return ( <>
setOpen(true)} > - + {t('dashboard.search-for-a-task-or-document')}
- - {t('dashboard.search')} - - - {t('dashboard.no-results')} - - - - {t('dashboard.calendar')} - - - - {t('dashboard.search-emoji')} - - - - {t('dashboard.calculator')} - - - - - + ); } diff --git a/src/components/HistorySidebar/index.tsx b/src/components/HistorySidebar/index.tsx index 501df6759..3f9dd3dac 100644 --- a/src/components/HistorySidebar/index.tsx +++ b/src/components/HistorySidebar/index.tsx @@ -303,11 +303,22 @@ export default function HistorySidebar() { }; useLayoutEffect(() => { + const PANEL_WIDTH = 360; + const GAP = 8; + const MARGIN = 8; + /** Nudge panel up relative to #active-task-title-btn (project sidebar title control). */ + const TOP_OFFSET = 12; + const updateAnchor = () => { const btn = document.getElementById('active-task-title-btn'); if (btn) { const rect = btn.getBoundingClientRect(); - setAnchorStyle({ left: rect.left, top: rect.bottom + 6 }); + let left = rect.right + GAP; + const maxLeft = window.innerWidth - PANEL_WIDTH - MARGIN; + if (left > maxLeft) { + left = Math.max(MARGIN, maxLeft); + } + setAnchorStyle({ left, top: rect.top - TOP_OFFSET }); } }; @@ -344,10 +355,10 @@ export default function HistorySidebar() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="fixed inset-0 z-40 bg-transparent" + className="inset-0 fixed z-40 bg-transparent" onClick={close} /> - {/* dropdown-style history panel under title bar */} + {/* History panel to the right of the project title control */} -
+
{/* Search */}
-
+
{/* Ongoing Projects */} {ongoingProjects .filter( @@ -389,30 +400,30 @@ export default function HistorySidebar() { navigate(`/`); close(); }} - className="relative flex w-full max-w-full cursor-pointer items-center justify-between gap-sm rounded-xl border border-solid border-border-disabled bg-project-surface-default px-4 py-3 shadow-history-item transition-all duration-300 hover:bg-project-surface-hover" + className="gap-sm rounded-xl border-border-disabled bg-project-surface-default px-4 py-3 shadow-history-item hover:bg-project-surface-hover relative flex w-full max-w-full cursor-pointer items-center justify-between border border-solid transition-all duration-300" > -
+
{project.project_name || t('layout.new-project')}
} > - + {project.project_name || t('layout.new-project')}
-
+
@@ -443,7 +454,7 @@ export default function HistorySidebar() { - +
-
+
@@ -562,7 +573,7 @@ export default function HistorySidebar() { - +
+ ); +} + +function SidebarExpandableRow({ + expanded, + children, +}: { + expanded: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export interface ProjectPageSidebarProps { + chatStore: ChatStore; + className?: string; +} + +export default function ProjectPageSidebar({ + chatStore, + className, +}: ProjectPageSidebarProps) { + const collapsed = usePageTabStore((s) => s.projectSidebarCollapsed); + const setScrollToQueryId = usePageTabStore((s) => s.setScrollToQueryId); + const historySidebarOpen = useSidebarStore((s) => s.isOpen); + const toggle = useSidebarStore((s) => s.toggle); + const projectStore = useProjectStore(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const [globalSearchOpen, setGlobalSearchOpen] = useState(false); + const [skillsMenuOpen, setSkillsMenuOpen] = useState(false); + const [connectorsMenuOpen, setConnectorsMenuOpen] = useState(false); + const [browserMenuOpen, setBrowserMenuOpen] = useState(false); + + const { skillsHubActive, connectorsHubActive, browserHubActive } = + useMemo(() => { + const path = location.pathname.replace(/\/$/, ''); + const onHistory = path === '/history' || path.endsWith('/history'); + const params = new URLSearchParams(location.search); + const rawTab = params.get('tab'); + const tab = rawTab ? (HISTORY_TAB_ALIASES[rawTab] ?? rawTab) : null; + const section = params.get('section'); + return { + skillsHubActive: onHistory && tab === 'agents' && section === 'skills', + connectorsHubActive: onHistory && tab === 'connectors', + browserHubActive: onHistory && tab === 'browser', + }; + }, [location.pathname, location.search]); + + const summaryTask = + chatStore.tasks[chatStore.activeTaskId as string]?.summaryTask; + + const activeTaskTitle = useMemo(() => { + if (chatStore.activeTaskId && summaryTask) { + return summaryTask.split('|')[0]; + } + return t('layout.new-project'); + }, [chatStore.activeTaskId, summaryTask, t]); + + // Signal to recompute when the active chatStore mutates + const activeUpdateCount = chatStore.updateCount; + + /** Collect tasks from ALL chatStores in the active project so follow-up tasks appear in the sidebar. */ + const allTaskEntries = useMemo(() => { + const pid = projectStore.activeProjectId; + if (!pid) return []; + const stores = projectStore.getAllChatStores(pid); + const entries: Array<{ + chatId: string; + taskId: string; + task: ChatStore['tasks'][string]; + firstUserMessageId: string | null; + }> = []; + for (const { chatId, chatStore: cs } of stores) { + const state = cs.getState(); + const tid = state.activeTaskId; + if (!tid || !state.tasks[tid]) continue; + const task = state.tasks[tid]; + const hasUserMessages = task.messages.some( + (m) => m.role === 'user' && m.content + ); + if (!hasUserMessages) continue; + const firstUser = task.messages.find((m) => m.role === 'user'); + entries.push({ + chatId, + taskId: tid, + task, + firstUserMessageId: firstUser?.id ?? null, + }); + } + return entries; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectStore, activeUpdateCount]); + + const expanded = !collapsed; + + return ( + +
+
+ + + + + + + +
+ + + + {!collapsed && ( +
+
+ {allTaskEntries.length === 0 ? ( +

+ {t('layout.no-tasks', { defaultValue: 'No tasks' })} +

+ ) : ( +
+ {allTaskEntries.map( + ({ chatId, taskId, task, firstUserMessageId }) => ( + + ) + )} +
+ )} +
+
+ )} + +
+ + + + + + + + + navigate( + '/history?tab=agents§ion=skills&skillAction=create' + ) + } + > + {t('agents.create-skill')} + + + navigate( + '/history?tab=agents§ion=skills&skillAction=upload' + ) + } + > + {t('agents.upload-skill')} + + navigate('/history?tab=agents§ion=skills')} + > + {t('agents.browse-skills')} + + + + + + + + + + + + + navigate('/history?tab=connectors&connectorAction=add') + } + > + {t('layout.add-new-mcp')} + + + navigate('/history?tab=connectors&connectorSection=mcp-tools') + } + > + {t('layout.browse-mcps')} + + + + + + + + + + + + + navigate('/history?tab=browser&browserAction=launch') + } + > + {t('layout.open-new-browser')} + + + navigate('/history?tab=browser&browserSection=cdp') + } + > + {t('layout.browser-settings')} + + + +
+
+ ); +} diff --git a/src/components/TopBar/index.tsx b/src/components/TopBar/index.tsx index 1dc18956b..3a1f681d4 100644 --- a/src/components/TopBar/index.tsx +++ b/src/components/TopBar/index.tsx @@ -18,7 +18,6 @@ import { proxyFetchDelete, proxyFetchGet, } from '@/api/http'; -import defaultFolderIcon from '@/assets/Folder.svg'; import giftWhiteIcon from '@/assets/gift-white.svg'; import giftIcon from '@/assets/gift.svg'; import EndNoticeDialog from '@/components/Dialog/EndNotice'; @@ -29,21 +28,21 @@ import { share } from '@/lib/share'; import { useAuthStore } from '@/store/authStore'; import { useInstallationUI } from '@/store/installationStore'; import { usePageTabStore } from '@/store/pageTabStore'; -import { useSidebarStore } from '@/store/sidebarStore'; import { ChatTaskStatus } from '@/types/constants'; import { - ChevronDown, ChevronLeft, FileDown, House, Minus, + PanelLeft, + PanelLeftClose, Plus, Power, Settings, Square, X, } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; @@ -57,8 +56,13 @@ function HeaderWin() { const location = useLocation(); //Get Chatstore for the active project's task const { chatStore, projectStore } = useChatStoreAdapter(); - const { toggle } = useSidebarStore(); const { chatPanelPosition, setChatPanelPosition } = usePageTabStore(); + const projectSidebarCollapsed = usePageTabStore( + (s) => s.projectSidebarCollapsed + ); + const toggleProjectSidebarCollapsed = usePageTabStore( + (s) => s.toggleProjectSidebarCollapsed + ); const appearance = useAuthStore((state) => state.appearance); const [endDialogOpen, setEndDialogOpen] = useState(false); const [endProjectLoading, setEndProjectLoading] = useState(false); @@ -95,16 +99,6 @@ function HeaderWin() { navigate('/'); }; - const summaryTask = - chatStore?.tasks[chatStore?.activeTaskId as string]?.summaryTask; - - const activeTaskTitle = useMemo(() => { - if (chatStore?.activeTaskId && summaryTask) { - return summaryTask.split('|')[0]; - } - return t('layout.new-project'); - }, [chatStore?.activeTaskId, summaryTask, t]); - if (!chatStore) { return
Loading...
; } @@ -201,26 +195,23 @@ function HeaderWin() { return (
- {/* left */} -
- folder-icon - {platform !== 'darwin' && ( - + {/* left — macOS uses pl-16 for traffic lights only; no extra spacer */} + {platform !== 'darwin' && ( +
+ Eigent - )} -
+
+ )} {/* center */} -
+
{location.pathname === '/history' && (
@@ -235,7 +226,44 @@ function HeaderWin() {
)} {location.pathname !== '/history' && ( -
+
+ {location.pathname === '/' && ( + + + + )}
)} - {location.pathname !== '/history' && ( - <> - {activeTaskTitle === t('layout.new-project') ? ( - - - - ) : ( - - - - )} - - )}
{/* right */} @@ -316,7 +301,7 @@ function HeaderWin() {
{chatStore.activeTaskId && chatStore.tasks[chatStore.activeTaskId as string] && @@ -335,7 +320,7 @@ function HeaderWin() { onClick={() => setEndDialogOpen(true)} variant="ghost" size="xs" - className="no-drag justify-center rounded-full bg-surface-cuation !text-text-cuation" + className="no-drag bg-surface-cuation !text-text-cuation justify-center rounded-full" > {t('layout.end-project')} @@ -356,7 +341,7 @@ function HeaderWin() { } variant="ghost" size="xs" - className="no-drag rounded-full bg-surface-information !text-text-information" + className="no-drag bg-surface-information !text-text-information rounded-full" > {t('layout.share')} @@ -417,7 +402,7 @@ function HeaderWin() {
)}
@@ -429,19 +414,19 @@ function HeaderWin() { ref={controlsRef} >
window.electronAPI.minimizeWindow()} >
window.electronAPI.toggleMaximizeWindow()} >
window.electronAPI.closeWindow()} > diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e0175999a..31538d621 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -46,7 +46,7 @@ const buttonVariants = cva( sm: 'inline-flex justify-start items-center gap-1 px-2 py-1 rounded-md text-label-sm font-medium [&_svg]:size-[16px]', md: 'inline-flex justify-start items-center gap-2 px-4 py-2 rounded-md text-label-md font-medium [&_svg]:size-[24px]', lg: 'inline-flex justify-start items-center gap-sm px-4 py-2 rounded-md text-label-lg font-bold [&_svg]:size-[24px]', - icon: 'inline-flex justify-start items-center gap-1 px-1 py-1 rounded-md [&_svg]:size-10', + icon: 'inline-flex justify-center items-center gap-1 px-1 py-1 rounded-md [&_svg]:size-10', }, }, defaultVariants: { diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index a828027fb..8b5f78ebb 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -27,7 +27,7 @@ const Command = React.forwardRef< { +type CommandDialogProps = DialogProps & { + overlayClassName?: string; + contentClassName?: string; + commandClassName?: string; +}; + +const CommandDialog = ({ + children, + overlayClassName, + contentClassName, + commandClassName, + ...props +}: CommandDialogProps) => { return ( - - + + {children} @@ -52,14 +72,14 @@ const CommandInput = React.forwardRef< React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => (
(({ className, ...props }, ref) => ( )); @@ -88,7 +108,7 @@ const CommandEmpty = React.forwardRef< >((props, ref) => ( )); @@ -102,7 +122,7 @@ const CommandGroup = React.forwardRef< void; /** Overlay behind the dialog: 'default' (transparent) or 'dark' (black overlay) */ overlayVariant?: DialogOverlayVariant; + /** Merged onto the overlay (e.g. `backdrop-blur-none` to disable default blur) */ + overlayClassName?: string; } const DialogContent = React.forwardRef< @@ -92,13 +94,17 @@ const DialogContent = React.forwardRef< closeButtonIcon, onClose, overlayVariant = 'default', + overlayClassName, ...props }, ref ) => ( (
-
+
{showBackButton && ( )} -
+
{title && ( -
+
- + {title} @@ -216,7 +222,7 @@ const DialogContentSection = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)); DialogContentSection.displayName = 'DialogContentSection'; @@ -328,9 +334,9 @@ const DialogFooter = React.forwardRef(
, React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( +>(({ className, sideOffset = 4, style, ...props }, ref) => ( @@ -101,7 +102,7 @@ const DropdownMenuItem = React.forwardRef< svg]:size-4 [&>svg]:shrink-0', + 'focus:bg-accent focus:text-accent-foreground gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-menutabs-fill-hover [&>svg]:size-4 relative flex cursor-pointer items-center transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:shrink-0', inset && 'pl-8', className )} @@ -117,13 +118,13 @@ const DropdownMenuCheckboxItem = React.forwardRef< - + @@ -141,12 +142,12 @@ const DropdownMenuRadioItem = React.forwardRef< - + @@ -192,7 +193,7 @@ const DropdownMenuShortcut = ({ }: React.HTMLAttributes) => { return ( ); diff --git a/src/components/ui/resizable.tsx b/src/components/ui/resizable.tsx index 27557997e..80c17dbd4 100644 --- a/src/components/ui/resizable.tsx +++ b/src/components/ui/resizable.tsx @@ -36,11 +36,18 @@ function ResizablePanelGroup({ ); } -function ResizablePanel({ - ...props -}: React.ComponentProps) { - return ; -} +const ResizablePanel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(function ResizablePanel({ ...props }, ref) { + return ( + + ); +}); function ResizableHandle({ withHandle, @@ -53,13 +60,13 @@ function ResizableHandle({ div]:rotate-90', + 'bg-border focus-visible:ring-ring after:inset-y-0 after:w-1 data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:translate-x-0 relative flex w-px items-center justify-center after:absolute after:left-1/2 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90', className )} {...props} > {withHandle && ( -
+
)} diff --git a/src/hooks/useInitialChatPanelLayout.ts b/src/hooks/useInitialChatPanelLayout.ts new file mode 100644 index 000000000..de9f373be --- /dev/null +++ b/src/hooks/useInitialChatPanelLayout.ts @@ -0,0 +1,46 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { useLayoutEffect, type RefObject } from 'react'; +import { + getPanelGroupElement, + type ImperativePanelHandle, +} from 'react-resizable-panels'; + +const CHAT_PANEL_DEFAULT_PX = 400; +const CHAT_PANEL_MIN_PX = 360; + +/** + * On mount (and when resetKey changes), sizes the chat panel to ~400px as a + * percentage of the panel group width. ChatBox stays w-full inside the panel + * with min-w-[360px]; user drag can grow the panel beyond 400px. + */ +export function useInitialChatPanelLayout( + panelGroupId: string, + chatPanelRef: RefObject, + enabled: boolean, + resetKey?: string | number | boolean +): void { + useLayoutEffect(() => { + if (!enabled) return; + const groupEl = getPanelGroupElement(panelGroupId); + if (!groupEl) return; + const w = groupEl.getBoundingClientRect().width; + if (w <= 0) return; + const targetPct = (CHAT_PANEL_DEFAULT_PX / w) * 100; + const minPct = (CHAT_PANEL_MIN_PX / w) * 100; + const pct = Math.min(92, Math.max(minPct, targetPct)); + chatPanelRef.current?.resize(pct); + }, [panelGroupId, enabled, resetKey, chatPanelRef]); +} diff --git a/src/i18n/locales/en-us/agents.json b/src/i18n/locales/en-us/agents.json index 13f215331..302889965 100644 --- a/src/i18n/locales/en-us/agents.json +++ b/src/i18n/locales/en-us/agents.json @@ -46,5 +46,12 @@ "custom-skill": "Custom skill", "delete-skill": "Delete Skill", "delete-skill-confirmation": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", - "skill-deleted-success": "Skill deleted successfully!" + "skill-deleted-success": "Skill deleted successfully!", + "create-skill": "Create a skill", + "upload-skill": "Upload a skill", + "browse-skills": "Browse skills", + "create-skill-default-name": "New skill", + "create-skill-default-description": "Describe what this skill does.", + "save-skill": "Save skill", + "compose-skill-hint": "Edit SKILL.md (YAML frontmatter with name and description is required), then save." } diff --git a/src/i18n/locales/en-us/layout.json b/src/i18n/locales/en-us/layout.json index f56d4ebc4..e5e6e250b 100644 --- a/src/i18n/locales/en-us/layout.json +++ b/src/i18n/locales/en-us/layout.json @@ -245,5 +245,8 @@ "restart-required": "Restart Required", "restart-required-message": "Restart the application to enable your cookie domain changes.", "restart": "Restart", - "cookie-count": "{{count}} Cookies" + "cookie-count": "{{count}} Cookies", + "add-new-mcp": "Add new MCP", + "browse-mcps": "Browse MCPs", + "browser-settings": "Browser settings" } diff --git a/src/lib/taskLifecycleUi.ts b/src/lib/taskLifecycleUi.ts new file mode 100644 index 000000000..a7490ff23 --- /dev/null +++ b/src/lib/taskLifecycleUi.ts @@ -0,0 +1,92 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import type { BottomBoxState } from '@/components/ChatBox/BottomBox'; +import { ChatTaskStatus, type ChatTaskStatusType } from '@/types/constants'; + +/** Minimal task shape for bottom-box / sidebar lifecycle (matches chatStore Task fields used). */ +export interface TaskLifecycleFields { + messages: Message[]; + type: string; + status: ChatTaskStatusType; + hasWaitComfirm: boolean; + isTakeControl: boolean; + taskInfo: { id: string; content: string }[]; +} + +export function getBottomBoxStateForTask( + task: TaskLifecycleFields +): BottomBoxState { + const anyToSubTasksMessage = task.messages.find( + (m) => m.step === 'to_sub_tasks' + ); + const toSubTasksMessage = task.messages.find( + (m) => m.step === 'to_sub_tasks' && !m.isConfirm + ); + + const isSkeletonPhase = + (task.status !== 'finished' && + !anyToSubTasksMessage && + !task.hasWaitComfirm && + task.messages.length > 0) || + (task.isTakeControl && !anyToSubTasksMessage); + if (isSkeletonPhase) { + return 'splitting'; + } + + if ( + toSubTasksMessage && + !toSubTasksMessage.isConfirm && + task.status === 'pending' + ) { + return 'confirm'; + } + + if (toSubTasksMessage && !toSubTasksMessage.isConfirm) { + return 'splitting'; + } + + if ( + task.status === ChatTaskStatus.RUNNING || + task.status === ChatTaskStatus.PAUSE + ) { + return 'running'; + } + + if (task.status === 'finished' && task.type !== '') { + return 'finished'; + } + + return 'input'; +} + +/** Shelf tone for project-style task rows; finished and idle both use `default`. */ +export type TaskListShelfTone = 'splitting' | 'running' | 'default'; + +export function getTaskListShelfTone( + task: TaskLifecycleFields +): TaskListShelfTone { + const s = getBottomBoxStateForTask(task); + if (s === 'running') return 'running'; + if (s === 'splitting' || s === 'confirm') return 'splitting'; + return 'default'; +} + +export function isWorkforceTask(task: TaskLifecycleFields): boolean { + const toSubTasks = task.messages.filter((m) => m.step === 'to_sub_tasks'); + const latest = toSubTasks[toSubTasks.length - 1]; + if (latest?.taskType === 2) return true; + if (task.taskInfo.length > 1) return true; + return false; +} diff --git a/src/pages/Agents/Skills.tsx b/src/pages/Agents/Skills.tsx index aeeedec01..52deb19f7 100644 --- a/src/pages/Agents/Skills.tsx +++ b/src/pages/Agents/Skills.tsx @@ -19,15 +19,20 @@ import { useSkillsStore, type Skill } from '@/store/skillsStore'; import { Plus } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; import SkillDeleteDialog from './components/SkillDeleteDialog'; import SkillListItem from './components/SkillListItem'; import SkillUploadDialog from './components/SkillUploadDialog'; export default function Skills() { const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); const { skills, syncFromDisk } = useSkillsStore(); const [searchQuery, setSearchQuery] = useState(''); const [uploadDialogOpen, setUploadDialogOpen] = useState(false); + const [skillDialogMode, setSkillDialogMode] = useState<'upload' | 'create'>( + 'upload' + ); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [skillToDelete, setSkillToDelete] = useState(null); @@ -37,6 +42,16 @@ export default function Skills() { syncFromDisk(); }, [syncFromDisk]); + useEffect(() => { + const action = searchParams.get('skillAction'); + if (action !== 'create' && action !== 'upload') return; + setSkillDialogMode(action); + setUploadDialogOpen(true); + const next = new URLSearchParams(searchParams); + next.delete('skillAction'); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams]); + const yourSkills = useMemo(() => { return skills .filter((skill) => !skill.isExample) @@ -75,17 +90,17 @@ export default function Skills() { return (
{/* Header Section */} -
+
{t('agents.skills')}
{/* Content Section */} -
-
+
+
-
+
-
+
setUploadDialogOpen(true)} + onClick={() => { + setSkillDialogMode('upload'); + setUploadDialogOpen(true); + }} > {t('agents.add-skill')} @@ -133,11 +151,16 @@ export default function Skills() { !searchQuery ? t('agents.add-your-first-skill') : undefined } onAddClick={ - !searchQuery ? () => setUploadDialogOpen(true) : undefined + !searchQuery + ? () => { + setSkillDialogMode('upload'); + setUploadDialogOpen(true); + } + : undefined } /> ) : ( -
+
{yourSkills.map((skill) => ( ) : ( -
+
{exampleSkills.map((skill) => ( setUploadDialogOpen(false)} /> diff --git a/src/pages/Agents/components/SkillUploadDialog.tsx b/src/pages/Agents/components/SkillUploadDialog.tsx index 65d228a59..c5242f0dd 100644 --- a/src/pages/Agents/components/SkillUploadDialog.tsx +++ b/src/pages/Agents/components/SkillUploadDialog.tsx @@ -20,21 +20,25 @@ import { DialogContentSection, DialogHeader, } from '@/components/ui/dialog'; -import { parseSkillMd } from '@/lib/skillToolkit'; +import { Textarea } from '@/components/ui/textarea'; +import { buildSkillMd, parseSkillMd } from '@/lib/skillToolkit'; import { useSkillsStore } from '@/store/skillsStore'; import { AlertCircle, File, Upload, X } from 'lucide-react'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; interface SkillUploadDialogProps { open: boolean; onClose: () => void; + /** File upload vs compose SKILL.md in the editor */ + mode?: 'upload' | 'create'; } export default function SkillUploadDialog({ open, onClose, + mode = 'upload', }: SkillUploadDialogProps) { const { t } = useTranslation(); const { addSkill, syncFromDisk } = useSkillsStore(); @@ -58,6 +62,19 @@ export default function SkillUploadDialog({ >(new Set()); const [pendingFileBuffer, setPendingFileBuffer] = useState(null); + const [composeContent, setComposeContent] = useState(''); + const [savingCompose, setSavingCompose] = useState(false); + + useEffect(() => { + if (!open || mode !== 'create') return; + setComposeContent( + buildSkillMd( + t('agents.create-skill-default-name'), + t('agents.create-skill-default-description'), + '## Instructions\n\n' + ) + ); + }, [open, mode, t]); const handleClose = useCallback(() => { setSelectedFile(null); @@ -69,9 +86,36 @@ export default function SkillUploadDialog({ setPendingConflicts([]); setConfirmedReplacements(new Set()); setPendingFileBuffer(null); + setComposeContent(''); + setSavingCompose(false); onClose(); }, [onClose]); + const handleSaveCompose = useCallback(async () => { + const meta = parseSkillMd(composeContent); + if (!meta) { + toast.error(t('agents.upload-error-invalid-yaml')); + return; + } + setSavingCompose(true); + try { + await addSkill({ + name: meta.name, + description: meta.description, + filePath: 'SKILL.md', + fileContent: composeContent, + scope: { isGlobal: true, selectedAgents: [] }, + enabled: true, + }); + toast.success(t('agents.skill-added-success')); + handleClose(); + } catch { + toast.error(t('agents.skill-add-error')); + } finally { + setSavingCompose(false); + } + }, [addSkill, composeContent, handleClose, t]); + const resetConflictState = useCallback(() => { setConflictDialog(null); setPendingConflicts([]); @@ -413,111 +457,151 @@ export default function SkillUploadDialog({ <> !isOpen && handleClose()}> - + -
- {/* Drop Zone */} -
fileInputRef.current?.click()} - > - - - {selectedFile ? ( -
-
-
- -
-
- + {mode === 'create' ? ( + <> +

+ {t('agents.compose-skill-hint')} +

+