diff --git a/src/components/AddWorker/ToolSelect.tsx b/src/components/AddWorker/ToolSelect.tsx index 930cef73..a1fa1d71 100644 --- a/src/components/AddWorker/ToolSelect.tsx +++ b/src/components/AddWorker/ToolSelect.tsx @@ -31,6 +31,7 @@ import { useHost } from '@/host'; import { capitalizeFirstLetter, getProxyBaseURL } from '@/lib'; import { cn } from '@/lib/utils'; import { useAuthStore } from '@/store/authStore'; +import { openSettings } from '@/store/settingsStore'; import type { TFunction } from 'i18next'; import { CircleAlert, X } from 'lucide-react'; import { @@ -43,7 +44,6 @@ import { useState, } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; import { Checkbox } from '../ui/checkbox'; import { Textarea } from '../ui/textarea'; import { TooltipSimple } from '../ui/tooltip'; @@ -260,7 +260,6 @@ const ToolSelect = forwardRef< const host = useHost(); const electronAPI = host?.electronAPI; const { t } = useTranslation(); - const navigate = useNavigate(); // state management - remove internal selected state, use parent passed initialSelectedTools const [keyword, setKeyword] = useState(''); const { email } = useAuthStore(); @@ -943,7 +942,7 @@ const ToolSelect = forwardRef< variant="ghost" size="xs" buttonContent="text" - onClick={() => navigate('/history?tab=connectors')} + onClick={() => openSettings('connectors')} > {t('chat.input-attach-manage-connectors')} diff --git a/src/components/AddWorker/index.tsx b/src/components/AddWorker/index.tsx index 894bde08..12067814 100644 --- a/src/components/AddWorker/index.tsx +++ b/src/components/AddWorker/index.tsx @@ -15,6 +15,10 @@ import { mcpList as fetchMcpConfig } from '@/api/brain'; import { fetchPost, proxyFetchGet } from '@/api/http'; import githubIcon from '@/assets/icon/github.svg'; +import { + getLocalPlatformName, + LOCAL_MODEL_OPTIONS, +} from '@/components/Settings/Models/localModels'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -41,10 +45,6 @@ import { buildAgentModelConfig, buildAgentModelConfigFromProvider, } from '@/lib/modelConfig'; -import { - getLocalPlatformName, - LOCAL_MODEL_OPTIONS, -} from '@/pages/Agents/localModels'; import { useAuthStore, useWorkerList } from '@/store/authStore'; import { useCloudModelStore } from '@/store/cloudModelStore'; import { Bot, Edit, Eye, EyeOff } from 'lucide-react'; diff --git a/src/components/ChatBox/BottomBox/InputBox.tsx b/src/components/ChatBox/BottomBox/InputBox.tsx index 37555e0b..f77a79ec 100644 --- a/src/components/ChatBox/BottomBox/InputBox.tsx +++ b/src/components/ChatBox/BottomBox/InputBox.tsx @@ -24,8 +24,8 @@ import { cn } from '@/lib/utils'; import type { TriggerInput } from '@/types'; import { ArrowRight, + Cable, FileText, - Hammer, Image, Paperclip, UploadCloud, @@ -553,7 +553,7 @@ export const Inputbox = ({ )} onClick={onToggleConnectorPanel} > - + )} diff --git a/src/components/ChatBox/BottomBox/ModelSelect.tsx b/src/components/ChatBox/BottomBox/ModelSelect.tsx index 8292aa3d..d89d6e09 100644 --- a/src/components/ChatBox/BottomBox/ModelSelect.tsx +++ b/src/components/ChatBox/BottomBox/ModelSelect.tsx @@ -19,6 +19,10 @@ import { proxyFetchGet } from '@/api/http'; import folderIcon from '@/assets/logo/eigent_icon_rich.svg'; +import { + getLocalPlatformName, + LOCAL_MODEL_OPTIONS, +} from '@/components/Settings/Models/localModels'; import { DropdownMenu, DropdownMenuContent, @@ -31,17 +35,12 @@ import { import { createHost } from '@/host/createHost'; import { applyDefaultModelSelection, - DEFAULT_MODEL_CONFIGURE_PATH, isDefaultModelConfigured, type DefaultModelCategory, } from '@/lib/applyDefaultModelSelection'; import { INIT_PROVODERS } from '@/lib/llm'; import { getProviderValid } from '@/lib/providerStatus'; import { cn } from '@/lib/utils'; -import { - getLocalPlatformName, - LOCAL_MODEL_OPTIONS, -} from '@/pages/Agents/localModels'; import { getModelImage, needsInvertModelImage, @@ -49,6 +48,7 @@ import { import { useAuthStore } from '@/store/authStore'; import { useCloudModelStore } from '@/store/cloudModelStore'; import { useProjectRuntimeStore } from '@/store/projectRuntimeStore'; +import { openSettings } from '@/store/settingsStore'; import { useSpaceStore } from '@/store/spaceStore'; import type { Provider } from '@/types'; @@ -63,7 +63,6 @@ import { import type { Dispatch, SetStateAction } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; export interface ModelSelectProps { disabled?: boolean; @@ -93,7 +92,6 @@ export function ModelSelect({ readOnly = false, }: ModelSelectProps) { const { t } = useTranslation(); - const navigate = useNavigate(); const { modelType, cloud_model_type, @@ -412,7 +410,7 @@ export function ModelSelect({ localProviderIds, }) ) { - navigate(DEFAULT_MODEL_CONFIGURE_PATH); + openSettings('models'); return; } if (projectId) { @@ -474,7 +472,6 @@ export function ModelSelect({ localProviderIds, localPlatform, localTypes, - navigate, projectId, setProjectModel, setModelType, @@ -668,7 +665,7 @@ export function ModelSelect({ if (isConfigured) { handleCodexSetDefault(); } else { - navigate(DEFAULT_MODEL_CONFIGURE_PATH); + openSettings('models'); } return; } diff --git a/src/components/ChatBox/BottomBox/PickerPanel.tsx b/src/components/ChatBox/BottomBox/PickerPanel.tsx index 67da4829..57c900ce 100644 --- a/src/components/ChatBox/BottomBox/PickerPanel.tsx +++ b/src/components/ChatBox/BottomBox/PickerPanel.tsx @@ -30,11 +30,11 @@ import { import { skillNameToDirName } from '@/lib/skillToolkit'; import { cn } from '@/lib/utils'; import { useServerCapabilityStore } from '@/store/serverCapabilityStore'; +import { openSettings } from '@/store/settingsStore'; import { useSkillsStore } from '@/store/skillsStore'; import { Check, Plus, Wrench } from 'lucide-react'; import { Fragment, useEffect, useMemo, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; /** * An item shown in a picker panel. `token` is the exact string inserted inline @@ -223,7 +223,6 @@ export function ConnectorPickerPanel({ onToggleItem, }: WiredPickerPanelProps) { const { t } = useTranslation(); - const navigate = useNavigate(); const [builtInItems, setBuiltInItems] = useState([]); const [openItems, setOpenItems] = useState([]); const [yourMcps, setYourMcps] = useState([]); @@ -380,7 +379,7 @@ export function ConnectorPickerPanel({ loading={loading} emptyLabel={t('chat.no-connectors-added')} emptyActionLabel={t('chat.input-attach-manage-connectors')} - onEmptyAction={() => navigate('/history?tab=connectors')} + onEmptyAction={() => openSettings('connectors')} /> ); } @@ -391,7 +390,6 @@ export function SkillPickerPanel({ onToggleItem, }: WiredPickerPanelProps) { const { t } = useTranslation(); - const navigate = useNavigate(); const skills = useSkillsStore((s) => s.skills); const items = useMemo( @@ -428,7 +426,7 @@ export function SkillPickerPanel({ }} emptyLabel={t('chat.no-skills-added')} emptyActionLabel={t('chat.input-attach-manage-skills')} - onEmptyAction={() => navigate('/history?tab=agents')} + onEmptyAction={() => openSettings('skills')} /> ); } diff --git a/src/components/ChatBox/MessageItem/FeedbackCard.tsx b/src/components/ChatBox/MessageItem/FeedbackCard.tsx index a5424410..4c0633cc 100644 --- a/src/components/ChatBox/MessageItem/FeedbackCard.tsx +++ b/src/components/ChatBox/MessageItem/FeedbackCard.tsx @@ -70,12 +70,12 @@ export function FeedbackCard({ return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {/* Copy button - appears on hover */} -
+
@@ -79,12 +79,12 @@ export const SummaryMarkDown = ({ ( -

+

{children}

), h2: ({ children }) => ( -

+

{children}

), @@ -94,17 +94,17 @@ export const SummaryMarkDown = ({ ), p: ({ children }) => ( -

+

{children}

), ul: ({ children }) => ( -
    +
      {children}
    ), ol: ({ children }) => ( -
      +
        {children}
      ), @@ -114,17 +114,17 @@ export const SummaryMarkDown = ({ ), code: ({ children }) => ( - + {children} ), pre: ({ children }) => ( -
      +            
                     {children}
                   
      ), blockquote: ({ children }) => ( -
      +
      {children}
      ), @@ -134,7 +134,7 @@ export const SummaryMarkDown = ({ ), em: ({ children }) => ( - + {children} ), diff --git a/src/components/ChatBox/MessageItem/TaskCompletionCard.tsx b/src/components/ChatBox/MessageItem/TaskCompletionCard.tsx index 3a7ee19e..77c442e7 100644 --- a/src/components/ChatBox/MessageItem/TaskCompletionCard.tsx +++ b/src/components/ChatBox/MessageItem/TaskCompletionCard.tsx @@ -41,7 +41,7 @@ export const TaskCompletionCard: React.FC = ({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25 }} - className="group rounded-xl p-3 bg-ds-bg-neutral-default-default relative flex w-full flex-row items-center" + className="group relative flex w-full flex-row items-center rounded-xl bg-ds-bg-neutral-default-default p-3" > {onDismiss && ( )}
      {t('chat.task-completed-card-title')} @@ -73,7 +73,7 @@ export const TaskCompletionCard: React.FC = ({ variant="primary" size="sm" onClick={handleAddTrigger} - className="rounded-lg h-fit" + className="h-fit rounded-lg" > {t('triggers.add-trigger')} diff --git a/src/components/ChatBox/TaskBox/PlanTaskBox/ExpandedOverlay.tsx b/src/components/ChatBox/TaskBox/PlanTaskBox/ExpandedOverlay.tsx index a73f6494..92abb8e9 100644 --- a/src/components/ChatBox/TaskBox/PlanTaskBox/ExpandedOverlay.tsx +++ b/src/components/ChatBox/TaskBox/PlanTaskBox/ExpandedOverlay.tsx @@ -119,7 +119,7 @@ export function ExpandedOverlay({
      )} -
      +
      {hasTaskInfo ? ( { }; return (
      {typeMap[type].label} diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index 52d18fce..4d2c1bfd 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -45,6 +45,7 @@ import { isChatEventTimelineEnabled } from '@/store/chatEventProjectionBridge'; import { buildProjectContinuationContext } from '@/store/chatStore'; import { usePageTabStore } from '@/store/pageTabStore'; import type { ProjectEventStoreSnapshot } from '@/store/projectEventStore'; +import { openSettings } from '@/store/settingsStore'; import { useSpaceStore } from '@/store/spaceStore'; import { ExecutionStatus } from '@/types'; import { AgentStep, ChatTaskStatus, SessionMode } from '@/types/constants'; @@ -57,7 +58,7 @@ import { useState, } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import BottomBox from './BottomBox'; import type { @@ -493,11 +494,9 @@ export default function ChatBox(): JSX.Element { const queuedDispatchRef = useRef(null); const hydratedFollowUpProjectsRef = useRef>(new Set()); - const navigate = useNavigate(); - const handleSelectModel = useCallback(() => { - navigate('/history?tab=agents'); - }, [navigate]); + openSettings('models'); + }, []); const [loading, setLoading] = useState(false); const [isPauseResumeLoading, setIsPauseResumeLoading] = useState(false); @@ -718,7 +717,7 @@ export default function ChatBox(): JSX.Element { return; } toast.error('Please select a model first.'); - navigate('/history?tab=agents'); + openSettings('models'); return; } @@ -756,7 +755,6 @@ export default function ChatBox(): JSX.Element { hasModel, isCloudUsageLimited, cloudUsageLimitMessage, - navigate, t, ] ); @@ -820,7 +818,7 @@ export default function ChatBox(): JSX.Element { return; } toast.error('Please select a model first.'); - navigate('/history?tab=agents'); + openSettings('models'); return; } diff --git a/src/components/Dashboard/HistoryTabsNav.tsx b/src/components/Dashboard/HistoryTabsNav.tsx deleted file mode 100644 index 076c153b..00000000 --- a/src/components/Dashboard/HistoryTabsNav.tsx +++ /dev/null @@ -1,260 +0,0 @@ -// ========= 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 { Blocks } from '@/components/ui/animate-ui/icons/blocks'; -import { Bot } from '@/components/ui/animate-ui/icons/bot'; -import { Compass } from '@/components/ui/animate-ui/icons/compass'; -import { Hammer } from '@/components/ui/animate-ui/icons/hammer'; -import { AnimateIcon } from '@/components/ui/animate-ui/icons/icon'; -import { Radio } from '@/components/ui/animate-ui/icons/radio'; -import { Settings } from '@/components/ui/animate-ui/icons/settings'; -import { cn } from '@/lib/utils'; -import { motion } from 'framer-motion'; -import type { ReactNode } from 'react'; -import { useCallback, useLayoutEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -const underlineSlideTransition = { - type: 'spring' as const, - stiffness: 420, - damping: 34, - mass: 0.55, -}; - -const underlineInstantTransition = { duration: 0 }; - -export const HISTORY_TAB_IDS = [ - 'home', - 'agents', - 'channels', - 'connectors', - 'browser', - 'settings', -] as const; - -export type HistoryTabId = (typeof HISTORY_TAB_IDS)[number]; - -export function isHistoryTabId(value: string): value is HistoryTabId { - return (HISTORY_TAB_IDS as readonly string[]).includes(value); -} - -type TabConfig = { - id: HistoryTabId; - icon: ReactNode; - iconAnimateOnHover: boolean | string; -}; - -const HISTORY_TABS: TabConfig[] = [ - { id: 'home', icon: , iconAnimateOnHover: 'default' }, - { id: 'agents', icon: , iconAnimateOnHover: 'default' }, - { id: 'channels', icon: , iconAnimateOnHover: 'default' }, - { id: 'connectors', icon: , iconAnimateOnHover: 'default' }, - { id: 'browser', icon: , iconAnimateOnHover: 'default' }, - { id: 'settings', icon: , iconAnimateOnHover: 'default' }, -]; - -const tabButtonClass = - 'group relative z-10 inline-flex h-8 min-h-8 shrink-0 items-center gap-1 rounded-lg px-2 text-label-sm font-bold transition-colors'; - -const iconSlotClass = - 'inline-flex size-4 shrink-0 items-center justify-center [&_svg]:size-4'; - -export type HistoryTabsNavProps = { - activeTab: HistoryTabId; - onChange: (value: string) => void; - className?: string; -}; - -export function HistoryTabsNav({ - activeTab, - onChange, - className, -}: HistoryTabsNavProps) { - const { t } = useTranslation(); - const navRef = useRef(null); - const [hoveredTab, setHoveredTab] = useState(null); - const [hoverRect, setHoverRect] = useState({ - left: 0, - top: 0, - width: 0, - height: 0, - }); - const [activeLine, setActiveLine] = useState({ - left: 0, - top: 0, - width: 0, - }); - /** False until first layout; enter uses fade-in instead of spring from (0,0). */ - const [underlineEntered, setUnderlineEntered] = useState(false); - const isFirstUnderlinePositionRef = useRef(true); - - const updateActiveLine = useCallback(() => { - const nav = navRef.current; - if (!nav) return; - const el = nav.querySelector( - `[data-history-tab="${activeTab}"]` - ); - if (!el) return; - const r = el.getBoundingClientRect(); - const nr = nav.getBoundingClientRect(); - const gapPx = 8; - const next = { - left: r.left - nr.left, - top: r.bottom - nr.top + gapPx, - width: r.width, - }; - if (next.width <= 0) return; - setActiveLine(next); - if (isFirstUnderlinePositionRef.current) { - isFirstUnderlinePositionRef.current = false; - requestAnimationFrame(() => setUnderlineEntered(true)); - } - }, [activeTab]); - - useLayoutEffect(() => { - updateActiveLine(); - const nav = navRef.current; - if (!nav) return; - const onResize = () => updateActiveLine(); - window.addEventListener('resize', onResize); - const ro = new ResizeObserver(onResize); - ro.observe(nav); - return () => { - window.removeEventListener('resize', onResize); - ro.disconnect(); - }; - }, [updateActiveLine]); - - const updateHoverRect = useCallback((el: HTMLElement) => { - const nav = navRef.current; - if (!nav) return; - const r = el.getBoundingClientRect(); - const nr = nav.getBoundingClientRect(); - setHoverRect({ - left: r.left - nr.left, - top: r.top - nr.top, - width: r.width, - height: r.height, - }); - }, []); - - useLayoutEffect(() => { - if (!hoveredTab) return; - const el = navRef.current?.querySelector( - `[data-history-tab="${hoveredTab}"]` - ); - if (el) updateHoverRect(el); - }, [activeTab, hoveredTab, updateHoverRect]); - - useLayoutEffect(() => { - if (!hoveredTab) return; - const el = navRef.current?.querySelector( - `[data-history-tab="${hoveredTab}"]` - ); - if (!el || !navRef.current) return; - const nav = navRef.current; - const onResize = () => updateHoverRect(el); - window.addEventListener('resize', onResize); - const ro = new ResizeObserver(onResize); - ro.observe(nav); - return () => { - window.removeEventListener('resize', onResize); - ro.disconnect(); - }; - }, [hoveredTab, updateHoverRect]); - - return ( -
      setHoveredTab(null)} - role="tablist" - > - - {activeLine.width > 0 && ( - - )} - {HISTORY_TABS.map(({ id, icon, iconAnimateOnHover }) => ( - - - - ))} -
      - ); -} diff --git a/src/components/Dashboard/IntegrationList/index.tsx b/src/components/Dashboard/IntegrationList/index.tsx index 1336576f..2c3bc82f 100644 --- a/src/components/Dashboard/IntegrationList/index.tsx +++ b/src/components/Dashboard/IntegrationList/index.tsx @@ -23,6 +23,7 @@ import { import { CircleAlert, Settings2 } from 'lucide-react'; import ellipseIcon from '@/assets/mcp/Ellipse-25.svg'; +import { MCPEnvDialog } from '@/components/Settings/Connectors/components/MCPEnvDialog'; import { Select, SelectContent, @@ -37,7 +38,6 @@ import { import { getProxyBaseURL } from '@/lib'; import { OAuth } from '@/lib/oauth'; import { cn } from '@/lib/utils'; -import { MCPEnvDialog } from '@/pages/Connectors/components/MCPEnvDialog'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -364,7 +364,7 @@ export default function IntegrationList({ } > {isSelectMode ? ( -
      +
      {selectWithCheckbox && ( {item.name}
      ) : ( -
      -
      +
      +
      {showStatusDot && (
      -
      +
      {showConfigButton && ( -
      -
      -
      - {/* Ongoing Projects */} - {ongoingProjects - .filter( - (project) => - project.last_prompt - ?.toLowerCase() - .includes(searchValue.toLowerCase()) || - project.project_name - ?.toLowerCase() - .includes(searchValue.toLowerCase()) - ) - .map((project) => ( -
      { - projectStore.setActiveProject(project.project_id); - navigate(`/`); - close(); - }} - className="duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] relative flex w-full max-w-full cursor-pointer items-center justify-between gap-sm rounded-xl border border-solid border-ds-border-neutral-subtle-default bg-ds-bg-neutral-default-default px-4 py-3 shadow-history-item transition-colors hover:bg-ds-bg-neutral-default-hover" - > - - -
      - - {project.project_name || t('layout.new-project')} -
      - } - > - - {project.project_name || t('layout.new-project')} - - -
      - -
      - - - - - {formatTokenCount( - resolveProjectTokenCount(project) - )} - - - - - - - - - {formatCompactCount(project.task_count)} - - - - - - - - - {formatCompactCount(project.total_triggers)} - - - -
      - - - - - - -
      - - - - - - - -
      -
      -
      -
      - ))} - - {/* History Projects */} - {historyTasks - .filter( - (project) => - project.last_prompt - ?.toLowerCase() - .includes(searchValue.toLowerCase()) || - project.project_name - ?.toLowerCase() - .includes(searchValue.toLowerCase()) - ) - .map((project) => ( -
      { - handleSetActive( - project.project_id, - project.last_prompt, - project.project_id - ); - }} - key={project.project_id} - className="duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] relative flex w-full max-w-full cursor-pointer items-center justify-between gap-sm rounded-xl border border-solid border-ds-border-neutral-subtle-default bg-ds-bg-neutral-default-default px-4 py-3 shadow-history-item transition-colors hover:bg-ds-bg-neutral-default-hover" - > - - -
      - - {project.last_prompt || - project.project_name || - t('layout.new-project')} -
      - } - > - - {project.last_prompt || - project.project_name || - t('layout.new-project')} - - -
      - -
      - - - - - {formatTokenCount( - resolveProjectTokenCount(project) - )} - - - - - - - - - {formatCompactCount(project.task_count)} - - - - - - - - - {formatCompactCount(project.total_triggers)} - - - -
      - - - - - - -
      - - - - - - - -
      -
      -
      -
      - ))} -
      -
      - - - )} - - ); -} diff --git a/src/components/Home/HomeHeader.tsx b/src/components/Home/HomeHeader.tsx new file mode 100644 index 00000000..410ee9e3 --- /dev/null +++ b/src/components/Home/HomeHeader.tsx @@ -0,0 +1,330 @@ +// ========= 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 SearchInput from '@/components/Dashboard/SearchInput'; +import ContentHeader from '@/components/Layout/ContentHeader'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { TooltipSimple } from '@/components/ui/tooltip'; +import { useHost } from '@/host'; +import { + createSpaceFromFolderPicker, + getFolderSpaceErrorMessage, +} from '@/lib/createSpaceFromFolder'; +import { ensureScratchSpaceWorkspaceBinding } from '@/lib/scratchSpaceWorkspace'; +import { getDefaultNewSpaceName } from '@/lib/spaceLabel'; +import { useAuthStore } from '@/store/authStore'; +import { usePageTabStore } from '@/store/pageTabStore'; +import { useProjectRuntimeStore } from '@/store/projectRuntimeStore'; +import { useSpaceStore } from '@/store/spaceStore'; +import { + ArrowUpDown, + ChevronDown, + Columns2, + Filter, + FolderOpen, + LayoutGrid, + List, + PlusCircle, +} from 'lucide-react'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; +import { useHomeHub } from './context'; +import { type HomeSection, useHomeSection } from './hooks/useHomeSection'; +import { + capitalizeLabel, + defaultSortDirectionForField, + type HomeSortBy, +} from './utils'; + +const SEARCH_PLACEHOLDER_KEYS: Record = { + spaces: 'layout.search-spaces', + projects: 'layout.search-projects', + tasks: 'layout.search-tasks', + triggers: 'layout.search-triggers', +}; + +const SECTION_TITLE_KEYS: Record = { + spaces: 'layout.spaces', + projects: 'layout.projects', + tasks: 'layout.tasks', + triggers: 'layout.triggers', +}; + +/** + * Home content-pane header: section title plus the hub controls (search, + * filter, sort, view switch, create). Uses the shared 44px `ContentHeader` + * so it lines up with the project / context / triggers headers. + */ +export default function HomeHeader() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const host = useHost(); + const email = useAuthStore((s) => s.email); + const userId = useAuthStore((s) => s.user_id); + const projectStore = useProjectRuntimeStore(); + const activeSpaceId = useSpaceStore((s) => s.activeSpaceId); + const createSpaceOnServer = useSpaceStore((s) => s.createSpaceOnServer); + const setActiveSpace = useSpaceStore((s) => s.setActiveSpace); + const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab); + const requestWorkspaceChatFocus = usePageTabStore( + (s) => s.requestWorkspaceChatFocus + ); + const { section: activeSection } = useHomeSection(); + const { + viewMode, + setViewMode, + searchQuery, + setSearchQuery, + sortBy, + setSortBy, + sortDirection, + setSortDirection, + } = useHomeHub(); + + const sortLabel = useMemo(() => { + switch (sortBy) { + case 'updated': + return t('layout.home-sort-updated'); + case 'name': + return t('layout.home-sort-name'); + case 'created': + default: + return t('layout.home-sort-created'); + } + }, [sortBy, t]); + + const handleSortChange = (nextSortBy: HomeSortBy) => { + if (nextSortBy === sortBy) { + setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'); + return; + } + setSortBy(nextSortBy); + setSortDirection(defaultSortDirectionForField(nextSortBy)); + }; + + const goToWorkspace = useCallback(() => { + setActiveWorkspaceTab('workforce'); + requestWorkspaceChatFocus(); + navigate('/'); + }, [navigate, requestWorkspaceChatFocus, setActiveWorkspaceTab]); + + const handleCreateBlankSpace = useCallback(async () => { + try { + const spaceId = await createSpaceOnServer({ + name: getDefaultNewSpaceName(t), + sourceType: 'blank', + setActive: false, + metadata: { + createdFrom: 'home_hub_toolbar', + autoCreatedPlaceholder: true, + }, + }); + await ensureScratchSpaceWorkspaceBinding({ + email, + userId, + space: useSpaceStore.getState().getSpaceById(spaceId), + }); + setActiveSpace(spaceId); + projectStore.setActiveProject(null); + goToWorkspace(); + } catch (error) { + console.error('Failed to create Space:', error); + toast.error(t('layout.spaces-create-failed'), { + closeButton: true, + }); + } + }, [ + createSpaceOnServer, + email, + goToWorkspace, + projectStore, + setActiveSpace, + t, + userId, + ]); + + const handleCreateSpaceFromFolder = useCallback(async () => { + try { + const spaceId = await createSpaceFromFolderPicker({ + host, + email, + userId, + activeSpaceId, + projectStore, + createdFrom: 'home_hub_toolbar', + }); + if (!spaceId) return; + goToWorkspace(); + } catch (error) { + console.warn('[HomeHeader] Failed to create folder Space:', error); + toast.error(getFolderSpaceErrorMessage(error, t), { + closeButton: true, + }); + } + }, [activeSpaceId, email, goToWorkspace, host, projectStore, t, userId]); + + return ( + + setSearchQuery(event.target.value)} + placeholder={t(SEARCH_PLACEHOLDER_KEYS[activeSection])} + /> + + + + + + + + + + + + + + + + + handleSortChange('created')}> + {t('layout.home-sort-created')} + + handleSortChange('updated')}> + {t('layout.home-sort-updated')} + + handleSortChange('name')}> + {t('layout.home-sort-name')} + + + + + + setViewMode(value as 'grid' | 'list' | 'board') + } + > + + + +
      + +
      +
      +
      + + +
      + +
      +
      +
      + + +
      + +
      +
      +
      +
      +
      + + + + + + + { + event.preventDefault(); + void handleCreateBlankSpace(); + }} + > + + {t('layout.workspace-start-from-scratch')} + + { + event.preventDefault(); + void handleCreateSpaceFromFolder(); + }} + > + + {t('layout.workspace-use-local-folder')} + + + + + } + /> + ); +} diff --git a/src/components/Home/HomeSections.tsx b/src/components/Home/HomeSections.tsx new file mode 100644 index 00000000..78e5480a --- /dev/null +++ b/src/components/Home/HomeSections.tsx @@ -0,0 +1,33 @@ +// ========= 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 Projects from './Projects'; +import Spaces from './Spaces'; +import Tasks from './Tasks'; +import Triggers from './Triggers'; +import { useHomeSection } from './hooks/useHomeSection'; + +/** Table / grid / board body for the section selected in the sidebar rail. */ +export default function HomeSections() { + const { section } = useHomeSection(); + + return ( +
      + {section === 'spaces' && } + {section === 'projects' && } + {section === 'tasks' && } + {section === 'triggers' && } +
      + ); +} diff --git a/src/components/Home/HomeSidebarNav.tsx b/src/components/Home/HomeSidebarNav.tsx new file mode 100644 index 00000000..dd40c403 --- /dev/null +++ b/src/components/Home/HomeSidebarNav.tsx @@ -0,0 +1,96 @@ +// ========= 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 { + NavTab, + SidebarBrandHeader, + SidebarNavGroup, + SidebarSection, + SidebarShell, +} from '@/components/Layout/AppSidebar'; +import type { LucideIcon } from 'lucide-react'; +import { Folder, ListChecks, MessageCircle, Zap } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHomeHub } from './context'; +import { type HomeSection, useHomeSection } from './hooks/useHomeSection'; +import { capitalizeLabel } from './utils'; + +const SECTION_ICONS: Record = { + spaces: Folder, + projects: MessageCircle, + tasks: ListChecks, + triggers: Zap, +}; + +const SECTION_LABEL_KEYS: Record = { + spaces: 'layout.spaces', + projects: 'layout.projects', + tasks: 'layout.tasks', + triggers: 'layout.triggers', +}; + +/** Home-only trailing count chip for a sidebar tab. */ +function HomeSidebarCountBadge({ count }: { count: number }) { + return ( +
      + + {count} + +
      + ); +} + +/** Home rail: one tab per hub section, each with its item count. */ +export default function HomeSidebarNav({ className }: { className?: string }) { + const { t } = useTranslation(); + const { sectionCounts } = useHomeHub(); + const { section: activeSection, setSection } = useHomeSection(); + + const items = useMemo( + () => + (Object.keys(SECTION_ICONS) as HomeSection[]).map((id) => ({ + id, + label: capitalizeLabel(t(SECTION_LABEL_KEYS[id])), + icon: SECTION_ICONS[id], + count: sectionCounts[id], + })), + [sectionCounts, t] + ); + + return ( + + + + + {items.map(({ id, label, icon: Icon, count }) => { + const active = activeSection === id; + return ( + setSection(id)} + leading={} + label={label} + trailing={} + ariaLabel={label} + ariaCurrentPage={active} + /> + ); + })} + + + + ); +} diff --git a/src/pages/Home/Projects.tsx b/src/components/Home/Projects.tsx similarity index 93% rename from src/pages/Home/Projects.tsx rename to src/components/Home/Projects.tsx index c9192d6c..bf1ab2e3 100644 --- a/src/pages/Home/Projects.tsx +++ b/src/components/Home/Projects.tsx @@ -13,7 +13,7 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import type { ProjectGroup } from '@/types/history'; -import { FolderOpen } from 'lucide-react'; +import { MessageCircle } from 'lucide-react'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import HomeHubBoard from './components/HomeHubBoard'; @@ -158,7 +158,7 @@ export default function Projects() { if (projectsLoading) { return ( -
      +
      {t('layout.loading')}
      @@ -167,17 +167,17 @@ export default function Projects() { } return ( -
      -
      +
      +
      {projects.length === 0 ? ( -
      - +
      +
      {t('dashboard.no-projects-found')}
      ) : filteredProjects.length === 0 ? ( -
      +
      {t('layout.search-no-results')}
      diff --git a/src/pages/Home/Spaces.tsx b/src/components/Home/Spaces.tsx similarity index 98% rename from src/pages/Home/Spaces.tsx rename to src/components/Home/Spaces.tsx index 601c3b47..8eed6357 100644 --- a/src/pages/Home/Spaces.tsx +++ b/src/components/Home/Spaces.tsx @@ -19,7 +19,7 @@ import { useSpaceStore, type Space, } from '@/store/spaceStore'; -import { FolderKanban } from 'lucide-react'; +import { Folder } from 'lucide-react'; import { useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import HomeHubBoard from './components/HomeHubBoard'; @@ -236,7 +236,7 @@ export default function Spaces() {
      {spaceSections.length === 0 ? (
      - +
      {t('layout.spaces-hub-empty-title')}
      diff --git a/src/pages/Home/Tasks.tsx b/src/components/Home/Tasks.tsx similarity index 94% rename from src/pages/Home/Tasks.tsx rename to src/components/Home/Tasks.tsx index 029d9abf..55b16607 100644 --- a/src/pages/Home/Tasks.tsx +++ b/src/components/Home/Tasks.tsx @@ -155,7 +155,7 @@ export default function Tasks() { if (projectsLoading) { return ( -
      +
      {t('layout.loading')}
      @@ -164,17 +164,17 @@ export default function Tasks() { } return ( -
      -
      +
      +
      {tasks.length === 0 ? ( -
      +
      {t('dashboard.no-tasks-found') || t('layout.total-tasks')}
      ) : filteredTasks.length === 0 ? ( -
      +
      {t('layout.search-no-results')}
      diff --git a/src/pages/Home/Triggers.tsx b/src/components/Home/Triggers.tsx similarity index 95% rename from src/pages/Home/Triggers.tsx rename to src/components/Home/Triggers.tsx index 5262a20b..650bdfee 100644 --- a/src/pages/Home/Triggers.tsx +++ b/src/components/Home/Triggers.tsx @@ -183,7 +183,7 @@ export default function Triggers() { if (triggersLoading) { return ( -
      +
      {t('layout.loading')}
      @@ -192,17 +192,17 @@ export default function Triggers() { } return ( -
      -
      +
      +
      {triggers.length === 0 ? ( -
      +
      {t('triggers.no-triggers') || t('layout.triggers')}
      ) : filteredTriggers.length === 0 ? ( -
      +
      {t('layout.search-no-results')}
      diff --git a/src/pages/Home/components/HomeHubBoard.tsx b/src/components/Home/components/HomeHubBoard.tsx similarity index 80% rename from src/pages/Home/components/HomeHubBoard.tsx rename to src/components/Home/components/HomeHubBoard.tsx index ae4b0529..9538c99a 100644 --- a/src/pages/Home/components/HomeHubBoard.tsx +++ b/src/components/Home/components/HomeHubBoard.tsx @@ -58,7 +58,7 @@ export default function HomeHubBoard({ return (
      @@ -70,21 +70,20 @@ export default function HomeHubBoard({
      @@ -92,7 +91,7 @@ export default function HomeHubBoard({ @@ -100,11 +99,11 @@ export default function HomeHubBoard({
      -
      +
      {items.length > 0 ? ( items ) : ( -
      +
      {t('layout.home-board-column-empty')}
      )} diff --git a/src/pages/Home/components/HomeHubBoardCard.tsx b/src/components/Home/components/HomeHubBoardCard.tsx similarity index 100% rename from src/pages/Home/components/HomeHubBoardCard.tsx rename to src/components/Home/components/HomeHubBoardCard.tsx diff --git a/src/pages/Home/components/HomeHubCard.tsx b/src/components/Home/components/HomeHubCard.tsx similarity index 98% rename from src/pages/Home/components/HomeHubCard.tsx rename to src/components/Home/components/HomeHubCard.tsx index 114f207e..003ca263 100644 --- a/src/pages/Home/components/HomeHubCard.tsx +++ b/src/components/Home/components/HomeHubCard.tsx @@ -18,9 +18,9 @@ import { useSpaceStore } from '@/store/spaceStore'; import { TriggerStatus } from '@/types'; import { Folder, - FolderKanban, ListChecks, Loader2, + MessageCircle, Pencil, Power, Share2, @@ -187,7 +187,7 @@ function SpaceItemContent({ {layout === 'list' ? ( } + nameIcon={} listCells={[ { id: 'type', content: spaceKindLabel }, { @@ -325,14 +325,14 @@ function ProjectItemContent({ className="relative" > {loading ? ( -
      +
      ) : null} {layout === 'list' ? ( } + nameIcon={} listCells={[ { id: 'space', content: spaceLabel || '—' }, { @@ -417,7 +417,7 @@ function TaskItemContent({ className={loading ? 'relative' : undefined} > {loading ? ( -
      +
      ) : null} diff --git a/src/pages/Home/components/HomeHubGrid.tsx b/src/components/Home/components/HomeHubGrid.tsx similarity index 100% rename from src/pages/Home/components/HomeHubGrid.tsx rename to src/components/Home/components/HomeHubGrid.tsx diff --git a/src/pages/Home/components/HomeHubItemShared.tsx b/src/components/Home/components/HomeHubItemShared.tsx similarity index 99% rename from src/pages/Home/components/HomeHubItemShared.tsx rename to src/components/Home/components/HomeHubItemShared.tsx index 182648df..ae586c53 100644 --- a/src/pages/Home/components/HomeHubItemShared.tsx +++ b/src/components/Home/components/HomeHubItemShared.tsx @@ -25,7 +25,13 @@ import { cn } from '@/lib/utils'; import type { Space } from '@/store/spaceStore'; import { Trigger } from '@/types'; import { HistoryTask, ProjectGroup as ProjectGroupType } from '@/types/history'; -import { Folder, ListChecks, MoreHorizontal, Zap } from 'lucide-react'; +import { + Folder, + ListChecks, + MessageCircle, + MoreHorizontal, + Zap, +} from 'lucide-react'; import { Fragment, useState, @@ -557,7 +563,7 @@ export function HomeHubProjectCardBody({ return ( } + icon={} menuItems={menuItems} statItems={statItems} updatedAt={updatedAt} @@ -587,7 +593,7 @@ export function HomeHubProjectBoardCardBody({ return ( } + icon={} menuItems={menuItems} statRows={[ { label: t('layout.tasks'), value: taskCount }, diff --git a/src/pages/Home/components/HomeHubListItem.tsx b/src/components/Home/components/HomeHubListItem.tsx similarity index 100% rename from src/pages/Home/components/HomeHubListItem.tsx rename to src/components/Home/components/HomeHubListItem.tsx diff --git a/src/pages/Home/components/HomeHubListTable.tsx b/src/components/Home/components/HomeHubListTable.tsx similarity index 91% rename from src/pages/Home/components/HomeHubListTable.tsx rename to src/components/Home/components/HomeHubListTable.tsx index a9dceba0..754b2cb8 100644 --- a/src/pages/Home/components/HomeHubListTable.tsx +++ b/src/components/Home/components/HomeHubListTable.tsx @@ -70,13 +70,13 @@ export default function HomeHubListTable({ const gridClass = HOME_HUB_LIST_GRID_CLASS[kind]; return ( -
      -
      +
      +
      {columns.map((column) => ( @@ -84,7 +84,7 @@ export default function HomeHubListTable({ ))}
      -
      {children}
      +
      {children}
      ); } diff --git a/src/pages/Home/context.tsx b/src/components/Home/context.tsx similarity index 91% rename from src/pages/Home/context.tsx rename to src/components/Home/context.tsx index 15791512..e729c01e 100644 --- a/src/pages/Home/context.tsx +++ b/src/components/Home/context.tsx @@ -20,7 +20,16 @@ import type { HomeSortBy, HomeSortDirection, HomeViewMode } from './utils'; export type { HomeSortBy, HomeSortDirection, HomeViewMode } from './utils'; +export type HomeSectionCounts = { + spaces: number; + projects: number; + tasks: number; + triggers: number; +}; + export type HomeHubContextValue = { + /** Item counts per rail tab (spaces / projects / tasks / triggers). */ + sectionCounts: HomeSectionCounts; viewMode: HomeViewMode; setViewMode: (mode: HomeViewMode) => void; searchQuery: string; diff --git a/src/pages/Home/hooks/useHomeHubCounts.ts b/src/components/Home/hooks/useHomeHubCounts.ts similarity index 100% rename from src/pages/Home/hooks/useHomeHubCounts.ts rename to src/components/Home/hooks/useHomeHubCounts.ts diff --git a/src/pages/Home/hooks/useHomeHubNavigation.ts b/src/components/Home/hooks/useHomeHubNavigation.ts similarity index 100% rename from src/pages/Home/hooks/useHomeHubNavigation.ts rename to src/components/Home/hooks/useHomeHubNavigation.ts diff --git a/src/pages/Home/hooks/useHomeHubProjects.ts b/src/components/Home/hooks/useHomeHubProjects.ts similarity index 100% rename from src/pages/Home/hooks/useHomeHubProjects.ts rename to src/components/Home/hooks/useHomeHubProjects.ts diff --git a/src/pages/Home/hooks/useHomeHubTriggers.ts b/src/components/Home/hooks/useHomeHubTriggers.ts similarity index 100% rename from src/pages/Home/hooks/useHomeHubTriggers.ts rename to src/components/Home/hooks/useHomeHubTriggers.ts diff --git a/src/components/Home/hooks/useHomeSection.ts b/src/components/Home/hooks/useHomeSection.ts new file mode 100644 index 00000000..93e59d15 --- /dev/null +++ b/src/components/Home/hooks/useHomeSection.ts @@ -0,0 +1,55 @@ +// ========= 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 { useCallback } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +export const HOME_SECTIONS = [ + 'spaces', + 'projects', + 'tasks', + 'triggers', +] as const; + +export type HomeSection = (typeof HOME_SECTIONS)[number]; + +export function isHomeSection(value: string | null): value is HomeSection { + return value !== null && HOME_SECTIONS.includes(value as HomeSection); +} + +/** + * The URL is the source of truth for the active home section, so the sidebar + * rail and the content pane stay in sync without mirrored state. + */ +export function useHomeSection(): { + section: HomeSection; + setSection: (section: string) => void; +} { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const sectionFromUrl = searchParams.get('section'); + const section: HomeSection = isHomeSection(sectionFromUrl) + ? sectionFromUrl + : 'spaces'; + + const setSection = useCallback( + (next: string) => { + if (!isHomeSection(next)) return; + navigate(`?section=${next}`, { replace: true }); + }, + [navigate] + ); + + return { section, setSection }; +} diff --git a/src/pages/Home/hooks/useSpaceLabel.ts b/src/components/Home/hooks/useSpaceLabel.ts similarity index 100% rename from src/pages/Home/hooks/useSpaceLabel.ts rename to src/components/Home/hooks/useSpaceLabel.ts diff --git a/src/pages/Home/index.tsx b/src/components/Home/index.tsx similarity index 73% rename from src/pages/Home/index.tsx rename to src/components/Home/index.tsx index 5b0e2d34..46bd7c08 100644 --- a/src/pages/Home/index.tsx +++ b/src/components/Home/index.tsx @@ -17,10 +17,14 @@ import AlertDialog from '@/components/ui/alertDialog'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { share } from '@/lib/share'; import { ChatTaskStatus } from '@/types/constants'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import HomeHubToolbar from './components/HomeHubToolbar'; import { HomeHubProvider, type HomeSortBy, @@ -30,28 +34,21 @@ import { import { useHomeHubCounts } from './hooks/useHomeHubCounts'; import { useHomeHubProjects } from './hooks/useHomeHubProjects'; import { useHomeHubTriggers } from './hooks/useHomeHubTriggers'; -import Projects from './Projects'; -import Spaces from './Spaces'; -import Tasks from './Tasks'; -import Triggers from './Triggers'; -import { - capitalizeLabel, - persistHomeViewMode, - readStoredHomeViewMode, -} from './utils'; +import { useHomeSection } from './hooks/useHomeSection'; +import { persistHomeViewMode, readStoredHomeViewMode } from './utils'; -const HOME_SECTIONS = ['spaces', 'projects', 'tasks', 'triggers'] as const; -type HomeSection = (typeof HOME_SECTIONS)[number]; +export { default as HomeHeader } from './HomeHeader'; +export { default as HomeSections } from './HomeSections'; +export { default as HomeSidebarNav } from './HomeSidebarNav'; -function isHomeSection(value: string | null): value is HomeSection { - return value !== null && HOME_SECTIONS.includes(value as HomeSection); -} - -export default function HomeHub() { +/** + * Data + dialog host for the home surface. Rendered above the app shell so the + * sidebar rail (tab counts) and the content pane (header controls + tables) + * read the same hub state. + */ +export default function HomeHubRoot({ children }: { children: ReactNode }) { const { t } = useTranslation(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const sectionFromUrl = searchParams.get('section'); + const { section: activeTab } = useHomeSection(); const { chatStore } = useChatStoreAdapter(); const { projects, @@ -72,11 +69,6 @@ export default function HomeHub() { (() => Promise) | null >(null); - // URL is the source of truth for the active section — derive directly - // instead of mirroring into local state (avoids a resync window). - const activeTab: HomeSection = isHomeSection(sectionFromUrl) - ? sectionFromUrl - : 'spaces'; const [viewMode, setViewModeState] = useState( readStoredHomeViewMode ); @@ -94,37 +86,6 @@ export default function HomeHub() { setSortDirection('desc'); }, [activeTab]); - const menuItems = useMemo( - () => [ - { - id: 'spaces' as const, - name: capitalizeLabel(t('layout.spaces')), - count: sectionCounts.spaces, - }, - { - id: 'projects' as const, - name: capitalizeLabel(t('layout.projects')), - count: sectionCounts.projects, - }, - { - id: 'tasks' as const, - name: capitalizeLabel(t('layout.tasks')), - count: sectionCounts.tasks, - }, - { - id: 'triggers' as const, - name: capitalizeLabel(t('layout.triggers')), - count: sectionCounts.triggers, - }, - ], - [sectionCounts, t] - ); - - const handleTabChange = (tabId: string) => { - if (!HOME_SECTIONS.includes(tabId as HomeSection)) return; - navigate(`?tab=home§ion=${tabId}`, { replace: true }); - }; - const handleDelete = (id: string, callback?: () => void) => { setCurHistoryId(id); setDeleteModalOpen(true); @@ -198,6 +159,7 @@ export default function HomeHub() { const hubContextValue = useMemo( () => ({ + sectionCounts, viewMode, setViewMode, searchQuery, @@ -226,6 +188,7 @@ export default function HomeHub() { // are infrequent; include only the data dependencies React tracks here. // eslint-disable-next-line react-hooks/exhaustive-deps [ + sectionCounts, viewMode, searchQuery, sortBy, @@ -265,20 +228,7 @@ export default function HomeHub() { cancelText={t('layout.cancel')} /> -
      - - -
      - {activeTab === 'spaces' && } - {activeTab === 'projects' && } - {activeTab === 'tasks' && } - {activeTab === 'triggers' && } -
      -
      + {children} ); } diff --git a/src/pages/Home/utils.ts b/src/components/Home/utils.ts similarity index 100% rename from src/pages/Home/utils.ts rename to src/components/Home/utils.ts diff --git a/src/pages/Home/utils/boardStatus.ts b/src/components/Home/utils/boardStatus.ts similarity index 100% rename from src/pages/Home/utils/boardStatus.ts rename to src/components/Home/utils/boardStatus.ts diff --git a/src/components/Layout/AppShellLayout.tsx b/src/components/Layout/AppShellLayout.tsx new file mode 100644 index 00000000..0d53e03c --- /dev/null +++ b/src/components/Layout/AppShellLayout.tsx @@ -0,0 +1,133 @@ +// ========= 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 { cn } from '@/lib/utils'; +import { motion } from 'framer-motion'; +import { useLayoutEffect, useRef, type ReactNode } from 'react'; + +import { SIDEBAR_FOLD_SPRING } from './AppSidebar/constants'; + +/** Fixed rail width. The sidebar is no longer resizable. */ +export const APP_SHELL_SIDEBAR_WIDTH_PX = 240; +/** Gap between the rail and the content pane. */ +const SIDEBAR_CONTENT_GAP_PX = 2; + +/** Content pane surface — the rounded card every page renders its body into. */ +export const APP_SHELL_CONTENT_SURFACE_CLASS = + 'rounded-l-2xl bg-ds-bg-neutral-subtle-default min-w-0 flex h-full w-full flex-col overflow-hidden'; +/** Inner column inside the surface (header + body). */ +export const APP_SHELL_CONTENT_CLASS = + 'min-h-0 min-w-0 flex h-full w-full flex-col'; + +export interface AppShellLayoutProps { + /** Page rail — compose it from `@/components/Layout/AppSidebar`. */ + sidebar: ReactNode; + /** Content pane; typically `` plus a scrolling body. */ + children: ReactNode; + /** Rendered after the columns (e.g. always-mounted webview layers). */ + overlay?: ReactNode; + /** + * Collapse the rail away. Only the workspace passes this — Home and Settings + * always show their rail, so they simply omit it. + */ + sidebarHidden?: boolean; + /** + * Wrap `children` in the rounded content surface (default). Pages that paint + * their own surface (or need several) can opt out. + */ + contentSurface?: boolean; + className?: string; + contentClassName?: string; +} + +/** + * Shared page frame below the `TopBar`: a fixed-width sidebar and content pane. + * Workspace, Home and Settings all render through it so the rail width, gutters + * and motion are identical across pages. + */ +export default function AppShellLayout({ + sidebar, + children, + overlay, + sidebarHidden = false, + contentSurface = true, + className, + contentClassName, +}: AppShellLayoutProps) { + const sidebarRailRef = useRef(null); + + // React 18 does not support the boolean `inert` JSX prop. Set the native + // attribute before paint so a folded rail and all of its descendants leave + // the focus order and accessibility tree while its width animates closed. + useLayoutEffect(() => { + const sidebarRail = sidebarRailRef.current; + if (!sidebarRail) return; + + if (sidebarHidden) { + sidebarRail.setAttribute('inert', ''); + } else { + sidebarRail.removeAttribute('inert'); + } + }, [sidebarHidden]); + + return ( +
      +
      + + {/* Fixed inner width so rail content doesn't reflow mid-animation. */} +
      + {sidebar} +
      +
      + + + {contentSurface ? ( +
      + {children} +
      + ) : ( + children + )} +
      +
      + {overlay} +
      + ); +} diff --git a/src/components/ProjectPageSidebar/NavTab.tsx b/src/components/Layout/AppSidebar/NavTab.tsx similarity index 72% rename from src/components/ProjectPageSidebar/NavTab.tsx rename to src/components/Layout/AppSidebar/NavTab.tsx index f6986466..54aed182 100644 --- a/src/components/ProjectPageSidebar/NavTab.tsx +++ b/src/components/Layout/AppSidebar/NavTab.tsx @@ -14,91 +14,41 @@ import { TooltipSimple } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; -import type { WebSocketConnectionStatus } from '@/store/triggerStore'; import { motion } from 'framer-motion'; -import { RefreshCw } from 'lucide-react'; import type { ReactNode } from 'react'; -import { useTranslation } from 'react-i18next'; import { - PROJECT_SIDEBAR_FOLD_SPRING, + SIDEBAR_FOLD_SPRING, SIDEBAR_TOOLTIP_CONTENT_CLASS, } from './constants'; -/** Workspace tabs: layout identical expanded/folded so the leading icon does not jump — text clips as the rail narrows. */ -export function workspaceTabButtonClass(active: boolean): string { +/** App-sidebar tabs keep the same layout while folding so the leading icon does not jump. */ +export function sidebarTabButtonClass(active: boolean): string { return cn( 'no-drag h-8 min-h-8 w-full min-w-0 shrink-0 rounded-xl cursor-pointer ease-in-out flex items-center justify-start gap-3 px-3 text-left outline-none overflow-hidden transition-colors duration-200', - 'text-ds-text-neutral-muted-default', 'hover:bg-ds-bg-neutral-subtle-default focus-visible:ring-ds-ring-neutral-subtle-default focus-visible:ring-2 focus-visible:outline-none', - active && 'bg-ds-bg-neutral-subtle-default' + active + ? [ + 'bg-ds-bg-neutral-subtle-default text-ds-text-neutral-muted-default', + // Beat global `button .lucide` color so icon matches label emphasis. + '[&_.lucide]:!text-ds-icon-neutral-muted-default', + ] + : [ + 'text-ds-text-neutral-subtle-default', + '[&_.lucide]:!text-ds-icon-neutral-subtle-default', + ] ); } -export const WORKSPACE_TAB_LABEL_CLASS = - 'min-w-0 flex-1 truncate text-ds-text-neutral-muted-default text-body-sm font-medium'; +export const SIDEBAR_TAB_LABEL_CLASS = + 'min-w-0 flex-1 truncate !text-body-sm font-medium'; const SPLIT_MAIN_BUTTON_CLASS = - 'no-drag min-h-8 min-w-0 gap-3 rounded-xl py-0 px-3 relative flex flex-1 items-center text-left outline-none text-ds-text-neutral-muted-default focus-visible:ring-ds-ring-neutral-subtle-default hover:bg-transparent focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none'; + 'no-drag min-h-8 min-w-0 gap-3 rounded-xl py-0 px-3 relative flex flex-1 items-center text-left outline-none focus-visible:ring-ds-ring-neutral-subtle-default hover:bg-transparent focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none'; const SPLIT_OUTER_EXTRA_CLASS = 'min-w-0 gap-0 !p-0 relative flex items-stretch overflow-visible'; -export function triggerListenerLeadIconClass( - status: WebSocketConnectionStatus -): string { - switch (status) { - case 'connected': - return 'text-ds-icon-neutral-muted-default'; - case 'connecting': - return 'text-ds-icon-status-warning-default animate-pulse'; - case 'unhealthy': - return 'text-ds-icon-status-error-default'; - case 'disconnected': - default: - return '!text-ds-icon-status-error-default'; - } -} - -export interface NavTabReconnectSuffixProps { - wsConnectionStatus: WebSocketConnectionStatus; - onReconnect: () => void; -} - -/** Reconnect button for the triggers tab — direct click, no dropdown. */ -export function NavTabReconnectSuffix({ - wsConnectionStatus, - onReconnect, -}: NavTabReconnectSuffixProps) { - const { t } = useTranslation(); - const reconnectLabel = t('layout.triggers-reconnect-hint'); - return ( - - - - ); -} - export type NavTabLayout = 'simple' | 'split'; export interface NavTabProps { @@ -122,10 +72,16 @@ export interface NavTabProps { endAction?: ReactNode; /** Override the max-width reveal class on the endAction wrapper (default: `group-hover:max-w-10`). */ endActionMaxWidthClass?: string; - tooltip: string; + /** + * Hover tooltip. Omit it on rails whose labels are always fully visible — + * only rows that can truncate (project names) need one. + */ + tooltip?: string; /** When true, tooltips are hidden (labels are visible in the fixed-width sidebar). */ tooltipEnabledWhenCollapsed?: boolean; ariaLabel?: string; + /** Id of supporting text that describes this control. */ + ariaDescribedBy?: string; ariaCurrentPage?: boolean; /** Merged onto the outer control (`button` when simple, shell `div` when split). */ className?: string; @@ -134,9 +90,13 @@ export interface NavTabProps { /** Icon-only rail: fade/shrink label, trailing, and dot; keep leading icon fixed. */ folded?: boolean; disabled?: boolean; + /** Hover/focus hooks on the primary control (e.g. preloading a lazy section). */ + onPointerEnter?: () => void; + onFocus?: () => void; } function tabMainInner({ + active, leading, label, trailing, @@ -146,6 +106,7 @@ function tabMainInner({ folded = false, }: Pick< NavTabProps, + | 'active' | 'leading' | 'label' | 'trailing' @@ -164,11 +125,20 @@ function tabMainInner({ opacity: folded ? 0 : 1, maxWidth: folded ? 0 : 1600, }} - transition={PROJECT_SIDEBAR_FOLD_SPRING} + transition={SIDEBAR_FOLD_SPRING} aria-hidden={folded} style={{ pointerEvents: folded ? 'none' : undefined }} > - {label} + + {label} + {trailing} {showNotificationDot && (
      @@ -268,7 +246,7 @@ export function NavTab({ opacity: folded ? 0 : 1, maxWidth: folded ? 0 : 160, }} - transition={PROJECT_SIDEBAR_FOLD_SPRING} + transition={SIDEBAR_FOLD_SPRING} aria-hidden={folded} style={{ pointerEvents: folded ? 'none' : undefined }} > @@ -310,12 +288,15 @@ export function NavTab({ onClick(); }} className={cn( - workspaceTabButtonClass(active), + sidebarTabButtonClass(active), folded && 'gap-0', disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent', className )} + onPointerEnter={onPointerEnter} + onFocus={onFocus} aria-label={ariaLabel} + aria-describedby={ariaDescribedBy} aria-current={ariaCurrentPage ? 'page' : undefined} aria-disabled={disabled || undefined} > diff --git a/src/components/Layout/AppSidebar/SidebarBrandHeader.tsx b/src/components/Layout/AppSidebar/SidebarBrandHeader.tsx new file mode 100644 index 00000000..9561a79e --- /dev/null +++ b/src/components/Layout/AppSidebar/SidebarBrandHeader.tsx @@ -0,0 +1,67 @@ +// ========= 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 eigentAppIconBlack from '@/assets/logo/icon_black.svg'; +import eigentAppIconWhite from '@/assets/logo/icon_white.svg'; +import { cn } from '@/lib/utils'; +import { useAuthStore } from '@/store/authStore'; + +import { SIDEBAR_TAB_LABEL_CLASS } from './NavTab'; + +export interface SidebarBrandHeaderProps { + className?: string; +} + +/** + * 44px brand row for Home / Settings rails. Height matches the content-pane + * `ContentHeader`; icon size, gap and type match `NavTab` so the mark lines up + * with the tabs below. `-mt-1` cancels the shell's top padding so this row + * shares the same top edge as the content header. + */ +export function SidebarBrandHeader({ className }: SidebarBrandHeaderProps) { + const appearance = useAuthStore((state) => state.appearance); + + return ( +
      +
      + {/* Fixed 16px slot matches NavTab icons; larger mark is centered so layout stays put. */} + + + + + Eigent + +
      +
      + ); +} diff --git a/src/components/Layout/AppSidebar/SidebarScrollArea.tsx b/src/components/Layout/AppSidebar/SidebarScrollArea.tsx new file mode 100644 index 00000000..f6b5c56f --- /dev/null +++ b/src/components/Layout/AppSidebar/SidebarScrollArea.tsx @@ -0,0 +1,83 @@ +// ========= 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 { cn } from '@/lib/utils'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; + +export interface SidebarScrollAreaProps { + children: ReactNode; + className?: string; + /** Pass `navigation` when the region is a menu. */ + role?: 'navigation'; + ariaLabel?: string; +} + +/** + * Scrolling region for app-sidebar content. The scrollbar (and the gutter it + * reserves) only exists while the content actually overflows, so rows keep the + * rail's full width instead of losing a strip down the right edge. + */ +export function SidebarScrollArea({ + children, + className, + role, + ariaLabel, +}: SidebarScrollAreaProps) { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const checkOverflow = () => { + setOverflowing(el.scrollHeight > el.clientHeight + 1); + }; + + // Children are observed individually so a row growing — not just the list + // gaining rows — re-runs the check. + let resizeObserver = new ResizeObserver(checkOverflow); + const observeAll = () => { + resizeObserver.disconnect(); + resizeObserver = new ResizeObserver(checkOverflow); + resizeObserver.observe(el); + Array.from(el.children).forEach((child) => resizeObserver.observe(child)); + checkOverflow(); + }; + + observeAll(); + const mutationObserver = new MutationObserver(observeAll); + mutationObserver.observe(el, { childList: true, subtree: true }); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, []); + + return ( +
      + {children} +
      + ); +} diff --git a/src/components/Layout/AppSidebar/SidebarShell.tsx b/src/components/Layout/AppSidebar/SidebarShell.tsx new file mode 100644 index 00000000..bb0c8361 --- /dev/null +++ b/src/components/Layout/AppSidebar/SidebarShell.tsx @@ -0,0 +1,111 @@ +// ========= 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 { cn } from '@/lib/utils'; +import type { ReactNode } from 'react'; + +export interface SidebarShellProps { + children: ReactNode; + className?: string; + /** Accessible name for the rail (each page passes its own). */ + ariaLabel?: string; +} + +/** + * Outer rail surface shared by every page in the app layout (workspace, home, + * settings). Owns the panel chrome only — what goes inside is composed from + * {@link SidebarSection}, {@link SidebarNavGroup} and `NavTab`. + */ +export function SidebarShell({ + children, + className, + ariaLabel, +}: SidebarShellProps) { + return ( + + ); +} + +export interface SidebarSectionProps { + children: ReactNode; + className?: string; + /** + * `fixed` — natural height, never scrolls (top nav blocks). + * `fill` — takes the remaining height and owns its own scrolling child. + */ + grow?: 'fixed' | 'fill'; +} + +/** Vertical band inside {@link SidebarShell}. */ +export function SidebarSection({ + children, + className, + grow = 'fixed', +}: SidebarSectionProps) { + return ( +
      + {children} +
      + ); +} + +/** Hairline divider between sidebar sections. */ +export function SidebarSeparator({ className }: { className?: string }) { + return ( +
      +
      +
      + ); +} + +export interface SidebarNavGroupProps { + /** Uppercase group heading (e.g. Workspace / Device / Settings). */ + label?: string; + children: ReactNode; + className?: string; +} + +/** Labelled column of `NavTab` rows. */ +export function SidebarNavGroup({ + label, + children, + className, +}: SidebarNavGroupProps) { + return ( +
      + {label ? ( +
      + {label} +
      + ) : null} +
      {children}
      +
      + ); +} diff --git a/src/components/Layout/AppSidebar/constants.ts b/src/components/Layout/AppSidebar/constants.ts new file mode 100644 index 00000000..5b459dbf --- /dev/null +++ b/src/components/Layout/AppSidebar/constants.ts @@ -0,0 +1,27 @@ +// ========= 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 { Transition } from 'framer-motion'; + +/** Shared spring for app-sidebar fold and page-layout motion. */ +export const SIDEBAR_FOLD_SPRING: Transition = { + type: 'spring', + stiffness: 380, + damping: 38, + mass: 0.85, +}; + +/** Radix tooltip content: cap width so long labels (e.g. session titles) wrap. */ +export const SIDEBAR_TOOLTIP_CONTENT_CLASS = + 'max-w-[400px] break-words whitespace-normal'; diff --git a/src/components/Layout/AppSidebar/index.ts b/src/components/Layout/AppSidebar/index.ts new file mode 100644 index 00000000..c826211e --- /dev/null +++ b/src/components/Layout/AppSidebar/index.ts @@ -0,0 +1,47 @@ +// ========= 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. ========= + +/** + * Reusable app-sidebar kit. Every page rail in the layout (workspace, home, + * settings) is composed from these primitives so rows, spacing and motion stay + * identical across pages. + */ +export { + SIDEBAR_FOLD_SPRING, + SIDEBAR_TOOLTIP_CONTENT_CLASS, +} from './constants'; +export { + NavTab, + SIDEBAR_TAB_LABEL_CLASS, + sidebarTabButtonClass, + type NavTabLayout, + type NavTabProps, +} from './NavTab'; +export { + SidebarBrandHeader, + type SidebarBrandHeaderProps, +} from './SidebarBrandHeader'; +export { + SidebarScrollArea, + type SidebarScrollAreaProps, +} from './SidebarScrollArea'; +export { + SidebarNavGroup, + SidebarSection, + SidebarSeparator, + SidebarShell, + type SidebarNavGroupProps, + type SidebarSectionProps, + type SidebarShellProps, +} from './SidebarShell'; diff --git a/src/components/Layout/ContentHeader.tsx b/src/components/Layout/ContentHeader.tsx new file mode 100644 index 00000000..42a9eb34 --- /dev/null +++ b/src/components/Layout/ContentHeader.tsx @@ -0,0 +1,81 @@ +// ========= 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 { cn } from '@/lib/utils'; +import type { ReactNode } from 'react'; + +/** + * Canonical Layout header row for the content pane: 44px tall, 8px horizontal + * padding. Every page header (project, context, triggers, home, settings) + * uses this so they line up across tabs. + */ +export const CONTENT_HEADER_CLASS = + 'flex h-[44px] min-h-[44px] w-full shrink-0 items-center gap-2 px-2'; + +/** Bottom hairline for headers that sit above a scrolling list. */ +export const CONTENT_HEADER_BORDER_CLASS = + 'border-b border-x-0 border-t-0 border-solid border-ds-border-neutral-subtle-default'; + +/** + * Controls placed in a `ContentHeader` share one size so their heights match + * the 44px row: `size="sm"` (28px) with `buttonContent="icon-only"` for icon + * buttons and `buttonContent="text"` for labelled ones. + */ +export const CONTENT_HEADER_CONTROL_HEIGHT_CLASS = 'h-7 min-h-[28px]'; + +export interface ContentHeaderProps { + /** Leading control before the title (e.g. back/toggle button). */ + leading?: ReactNode; + /** Header title; omit for headers that only carry controls. */ + title?: ReactNode; + /** Right-aligned controls — keep every button at `size="sm"`. */ + actions?: ReactNode; + /** Free-form children rendered after the title, before `actions`. */ + children?: ReactNode; + /** Bottom divider (default true). */ + border?: boolean; + className?: string; +} + +export default function ContentHeader({ + leading, + title, + actions, + children, + border = true, + className, +}: ContentHeaderProps) { + return ( +
      + {leading} + {title ? ( + + {title} + + ) : null} + {children} + {actions ? ( +
      + {actions} +
      + ) : null} +
      + ); +} diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 0d7be9db..340c9961 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -16,17 +16,44 @@ import { InstallDependencies } from '@/components/InstallStep/InstallDependencie import TopBar from '@/components/TopBar'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { useInstallationSetup } from '@/hooks/useInstallationSetup'; +import { shellBackState } from '@/hooks/useShellBackTarget'; import { useHost } from '@/host'; +import { isSettingsRoutePath } from '@/lib/shellRoutes'; import { useAuthStore } from '@/store/authStore'; import { hasAnyActiveRun } from '@/store/chatStore'; import { useInstallationUI } from '@/store/installationStore'; +import { useSettingsStore } from '@/store/settingsStore'; import { useSpaceStore } from '@/store/spaceStore'; import { useEffect, useState } from 'react'; -import { Outlet } from 'react-router-dom'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import CloseNoticeDialog from '../Dialog/CloseNotice'; -import HistorySidebar from '../HistorySidebar'; import InstallationErrorDialog from '../InstallStep/InstallationErrorDialog/InstallationErrorDialog'; +/** + * Settings used to be a modal, and `openSettings(section)` is still the + * call every feature uses to jump into a section. Settings is now a page in + * the app shell, so translate that request into a route change and clear the + * flag; `activeSection` stays in the store and drives the page. + */ +function SettingsRouteBridge() { + const navigate = useNavigate(); + const location = useLocation(); + const isOpen = useSettingsStore((state) => state.isOpen); + const closeSettings = useSettingsStore((state) => state.closeSettings); + + useEffect(() => { + if (!isOpen) return; + closeSettings(); + if (isSettingsRoutePath(location.pathname)) return; + // Record where the user came from so the title-bar back button returns there. + navigate('/settings', { + state: shellBackState(`${location.pathname}${location.search}`), + }); + }, [closeSettings, isOpen, location.pathname, location.search, navigate]); + + return null; +} + const Layout = () => { const host = useHost(); const { chatStore, projectStore } = useChatStoreAdapter(); @@ -50,7 +77,6 @@ const Layout = () => { latestLog, error, backendError, - isInstalling, isBackendReady, shouldShowInstallScreen, retryInstallation, @@ -114,7 +140,7 @@ const Layout = () => { const shouldShowMainContent = !actualShouldShowInstallScreen; return ( -
      +
      { >
      +
      {/* Installation screen */} {actualShouldShowInstallScreen && } {/* Main app content */} - {shouldShowMainContent && ( - <> - - - - )} + {shouldShowMainContent && } {(backendError || (error && installationState === 'error')) && ( diff --git a/src/components/ProjectPageSidebar/ProjectNavList.tsx b/src/components/ProjectPageSidebar/ProjectNavList.tsx index 6a732060..86e5e5cf 100644 --- a/src/components/ProjectPageSidebar/ProjectNavList.tsx +++ b/src/components/ProjectPageSidebar/ProjectNavList.tsx @@ -12,13 +12,12 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { NavTab, SidebarScrollArea } from '@/components/Layout/AppSidebar'; import { cn } from '@/lib/utils'; -import { motion } from 'framer-motion'; -import { ChevronDown, Plus } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { Plus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { NavTab } from './NavTab'; import { ProjectNavListRows, type ProjectNavItem } from './ProjectNavListRows'; +import { SidebarAccordionSection } from './SidebarAccordionSection'; export { NAV_LIST_PROJECTS_RECENT_MAX, @@ -39,59 +38,9 @@ export interface ProjectNavListProps { onNewProject: () => void; /** Selected state for the New Project row. */ newProjectActive?: boolean; - /** Icon-only rail: match other sidebar `NavTab`s. */ - folded: boolean; className?: string; } -/** - * Collapsible section with a label header. - * Chevron is always visible when collapsed; only visible on hover when expanded. - */ -function AccordionSection({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) { - const [expanded, setExpanded] = useState(true); - - return ( -
      - - - -
      {children}
      -
      -
      - ); -} - /** New Project row, optional Pinned section, and Projects section. */ export function ProjectNavList({ projects, @@ -102,29 +51,9 @@ export function ProjectNavList({ onPinProject, onNewProject, newProjectActive = false, - folded, className, }: ProjectNavListProps) { const { t } = useTranslation(); - const projectListRef = useRef(null); - const [projectListOverflow, setProjectListOverflow] = useState(false); - - useEffect(() => { - const el = projectListRef.current; - if (!el) return; - - const checkOverflow = () => { - setProjectListOverflow(el.scrollHeight > el.clientHeight + 1); - }; - - checkOverflow(); - - const observer = new ResizeObserver(checkOverflow); - observer.observe(el); - Array.from(el.children).forEach((child) => observer.observe(child)); - - return () => observer.disconnect(); - }, [projects, folded]); const newProjectLabel = t('layout.new'); const pinnedLabel = t('layout.pinned', { defaultValue: 'Pinned' }); @@ -141,7 +70,7 @@ export function ProjectNavList({ onDeleteProject, onAchieveProject, onPinProject, - folded, + folded: false as const, }; return ( @@ -158,66 +87,27 @@ export function ProjectNavList({ onClick={onNewProject} leading={} label={newProjectLabel} - tooltip={newProjectLabel} - tooltipEnabledWhenCollapsed={!folded} - folded={folded} ariaLabel={newProjectLabel} ariaCurrentPage={newProjectActive} />
      {/* Scrollable section list */} -
      + {hasPinned && ( + + + )} - > - {folded ? ( - // Icon-only rail: flat list, no section headers - <> - {hasPinned && ( - - )} - {hasUnpinned && ( - - )} - - ) : ( - // Expanded: accordion sections - <> - {hasPinned && ( - - - - )} - {hasUnpinned && ( - - - - )} - + {hasUnpinned && ( + + + )} -
      +
      ); } @@ -229,7 +119,6 @@ export interface NavListProps { onDeleteSession?: (sessionId: string) => void; onNewSession: () => void; newSessionActive?: boolean; - folded: boolean; className?: string; } diff --git a/src/components/ProjectPageSidebar/ProjectNavListRows.tsx b/src/components/ProjectPageSidebar/ProjectNavListRows.tsx index 244be3ec..e2fa9a55 100644 --- a/src/components/ProjectPageSidebar/ProjectNavListRows.tsx +++ b/src/components/ProjectPageSidebar/ProjectNavListRows.tsx @@ -12,14 +12,22 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { sidebarTabButtonClass } from '@/components/Layout/AppSidebar'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { TooltipSimple } from '@/components/ui/tooltip'; import type { SessionNavLeadPresentation } from '@/lib/sessionNavLead'; import { cn } from '@/lib/utils'; -import { Archive, Pin, Zap } from 'lucide-react'; +import { Archive, MoreHorizontal, Pin, Trash2, Zap } from 'lucide-react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { SIDEBAR_TOOLTIP_CONTENT_CLASS } from './constants'; -import { workspaceTabButtonClass } from './NavTab'; +import { SIDEBAR_TAB_TOOLTIP_CONTENT_CLASS } from './constants'; export interface ProjectNavItem { id: string; @@ -37,7 +45,6 @@ export interface ProjectNavListRowsProps { projects: ProjectNavItem[]; activeProjectId?: string | null; onProjectClick?: (projectId: string) => void; - /** Kept for backward compat (parent still wires up the delete dialog). */ onDeleteProject?: (projectId: string) => void; onAchieveProject?: (projectId: string) => void; onPinProject?: (projectId: string) => void; @@ -59,11 +66,214 @@ export interface ProjectNavListRowsProps { */ export const NAV_LIST_PROJECTS_RECENT_MAX = 5; +function ProjectNavRowMenu({ + projectId, + pinned, + achieved, + open, + onOpenChange, + onPinProject, + onAchieveProject, + onDeleteProject, +}: { + projectId: string; + pinned?: boolean; + achieved?: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onPinProject?: (projectId: string) => void; + onAchieveProject?: (projectId: string) => void; + onDeleteProject?: (projectId: string) => void; +}) { + const { t } = useTranslation(); + const pinLabel = pinned + ? t('layout.unpin', { defaultValue: 'Unpin' }) + : t('layout.pin', { defaultValue: 'Pin' }); + const achieveLabel = t('layout.achieve-project', { + defaultValue: 'Achieve Project', + }); + const deleteLabel = t('layout.delete-project'); + const moreLabel = t('layout.more-actions'); + + return ( +
      + + + + + e.stopPropagation()} + > + onPinProject?.(projectId)} + > + + {pinLabel} + + onAchieveProject?.(projectId)} + > + + {achieveLabel} + + + onDeleteProject?.(projectId)} + > + + {deleteLabel} + + + +
      + ); +} + +function ProjectNavRow({ + project, + active, + panelListHover, + showRowMenu, + triggerSourceLabel, + onProjectClick, + onPinProject, + onAchieveProject, + onDeleteProject, +}: { + project: ProjectNavItem; + active: boolean; + panelListHover: boolean; + showRowMenu: boolean; + triggerSourceLabel: string; + onProjectClick?: (projectId: string) => void; + onPinProject?: (projectId: string) => void; + onAchieveProject?: (projectId: string) => void; + onDeleteProject?: (projectId: string) => void; +}) { + const [menuOpen, setMenuOpen] = useState(false); + const LeadIcon = project.sessionLead.Icon; + const leadClassName = cn( + 'h-4 w-4 shrink-0', + project.sessionLead.iconClassName, + project.sessionLead.spin && 'animate-spin' + ); + const selected = active || menuOpen; + + return ( +
      + {/* Tooltip trigger is the whole tab so it anchors to the row’s right edge, + not the inner title button. avoidCollisions={false} stops Floating UI + from treating the sidebar’s overflow clip as the boundary and shifting + the tip back over the row. */} + +
      + + + {showRowMenu ? ( + + ) : null} +
      +
      +
      + ); +} + export function ProjectNavListRows({ projects, activeProjectId, onProjectClick, - onDeleteProject: _onDeleteProject, + onDeleteProject, onAchieveProject, onPinProject, folded, @@ -72,10 +282,6 @@ export function ProjectNavListRows({ showRowMenu = true, }: ProjectNavListRowsProps) { const { t } = useTranslation(); - const achieveLabel = t('layout.achieve', { defaultValue: 'Achieve' }); - const achievedLabel = t('layout.achieved', { defaultValue: 'Achieved' }); - const pinLabel = t('layout.pin', { defaultValue: 'Pin' }); - const unpinLabel = t('layout.unpin', { defaultValue: 'Unpin' }); const triggerSourceLabel = t('layout.task-source-trigger'); const list = maxItems != null ? projects.slice(0, maxItems) : projects; @@ -96,16 +302,18 @@ export function ProjectNavListRows({ - - {/* Pin + archive buttons — in-flow so title truncates; snap visible on hover, no animation */} - {showRowMenu && ( -
      - - - - - - -
      - )} -
      -
      + ); })} diff --git a/src/components/ProjectPageSidebar/SidebarAccordionSection.tsx b/src/components/ProjectPageSidebar/SidebarAccordionSection.tsx new file mode 100644 index 00000000..74fc6f01 --- /dev/null +++ b/src/components/ProjectPageSidebar/SidebarAccordionSection.tsx @@ -0,0 +1,70 @@ +// ========= 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 { cn } from '@/lib/utils'; +import { motion } from 'framer-motion'; +import { ChevronDown } from 'lucide-react'; +import { type ReactNode, useState } from 'react'; + +export interface SidebarAccordionSectionProps { + label: string; + children: ReactNode; + defaultExpanded?: boolean; + className?: string; +} + +/** + * Collapsible sidebar section with a label header. + * Chevron is always visible when collapsed; only visible on hover when expanded. + */ +export function SidebarAccordionSection({ + label, + children, + defaultExpanded = true, + className, +}: SidebarAccordionSectionProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( +
      + + + +
      {children}
      +
      +
      + ); +} diff --git a/src/components/ProjectPageSidebar/SpaceSwitchDropdown.tsx b/src/components/ProjectPageSidebar/SpaceSwitchDropdown.tsx index 7e0a11fd..7f57c0cc 100644 --- a/src/components/ProjectPageSidebar/SpaceSwitchDropdown.tsx +++ b/src/components/ProjectPageSidebar/SpaceSwitchDropdown.tsx @@ -62,7 +62,7 @@ import { } from 'react'; import { useTranslation } from 'react-i18next'; -import { SIDEBAR_TOOLTIP_CONTENT_CLASS } from './constants'; +import { SIDEBAR_TOOLTIP_CONTENT_CLASS } from '@/components/Layout/AppSidebar'; const SPACE_LIST_ITEM_HEIGHT_CLASS = 'h-8'; const SPACE_LIST_MAX_HEIGHT_CLASS = 'max-h-40'; diff --git a/src/components/ProjectPageSidebar/TriggerNavTab.tsx b/src/components/ProjectPageSidebar/TriggerNavTab.tsx new file mode 100644 index 00000000..bc0a506a --- /dev/null +++ b/src/components/ProjectPageSidebar/TriggerNavTab.tsx @@ -0,0 +1,74 @@ +// ========= 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 { TooltipSimple } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import type { WebSocketConnectionStatus } from '@/store/triggerStore'; +import { RefreshCw } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +export function triggerListenerLeadIconClass( + status: WebSocketConnectionStatus +): string { + switch (status) { + case 'connected': + return 'text-ds-icon-neutral-muted-default'; + case 'connecting': + return 'text-ds-icon-status-warning-default animate-pulse'; + case 'unhealthy': + return 'text-ds-icon-status-error-default'; + case 'disconnected': + default: + return '!text-ds-icon-status-error-default'; + } +} + +export interface NavTabReconnectSuffixProps { + wsConnectionStatus: WebSocketConnectionStatus; + onReconnect: () => void; +} + +/** Reconnect button for the triggers tab — direct click, no dropdown. */ +export function NavTabReconnectSuffix({ + wsConnectionStatus, + onReconnect, +}: NavTabReconnectSuffixProps) { + const { t } = useTranslation(); + const reconnectLabel = t('layout.triggers-reconnect-hint'); + return ( + + + + ); +} diff --git a/src/components/ProjectPageSidebar/constants.ts b/src/components/ProjectPageSidebar/constants.ts index 0348560f..aa6e73a7 100644 --- a/src/components/ProjectPageSidebar/constants.ts +++ b/src/components/ProjectPageSidebar/constants.ts @@ -12,23 +12,6 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import type { Transition } from 'framer-motion'; - -/** Keep in sync with `HOME_MAIN_LAYOUT_SPRING` in `pages/Workspace.tsx`. */ -export const PROJECT_SIDEBAR_FOLD_SPRING: Transition = { - type: 'spring', - stiffness: 380, - damping: 38, - mass: 0.85, -}; - -/** - * Icon rail width: outer `aside` padding (`p-1`, 4+4) + tab `px-3` padding - * (12+12) + 16px icon = 48px. Keeps the folded leading icon in the same - * position as the expanded `NavTab` / `workspaceTabButtonClass` column. - */ -export const PROJECT_SIDEBAR_RAIL_WIDTH_PX = 48; - -/** Radix tooltip content: cap width so long labels (e.g. session titles) wrap. */ -export const SIDEBAR_TOOLTIP_CONTENT_CLASS = - 'max-w-[400px] break-words whitespace-normal'; +/** Project / tab name tooltips: narrower so they sit beside the row without covering it. */ +export const SIDEBAR_TAB_TOOLTIP_CONTENT_CLASS = + 'max-w-[160px] break-words whitespace-normal'; diff --git a/src/components/ProjectPageSidebar/index.tsx b/src/components/ProjectPageSidebar/index.tsx index cf704c28..89126f51 100644 --- a/src/components/ProjectPageSidebar/index.tsx +++ b/src/components/ProjectPageSidebar/index.tsx @@ -19,9 +19,15 @@ import { proxyFetchGet, } from '@/api/http'; import { GlobalSearchDialog } from '@/components/GlobalSearch'; +import { + NavTab, + SidebarNavGroup, + SidebarSection, + SidebarSeparator, + SidebarShell, +} from '@/components/Layout/AppSidebar'; import AlertDialog from '@/components/ui/alertDialog'; import { Button } from '@/components/ui/button'; -import { TooltipSimple } from '@/components/ui/tooltip'; import { useHost } from '@/host'; import { isProjectAchieved, @@ -45,22 +51,30 @@ import { useAuthStore } from '@/store/authStore'; import type { ChatStore } from '@/store/chatStore'; import { usePageTabStore } from '@/store/pageTabStore'; import { useProjectRuntimeStore } from '@/store/projectRuntimeStore'; +import { openSettings } from '@/store/settingsStore'; import { getVisibleProjectMetasForSpace, useSpaceStore, } from '@/store/spaceStore'; import { useTriggerStore } from '@/store/triggerStore'; import { ChatTaskStatus } from '@/types/constants'; -import { Cast, Inbox, LayoutGrid, Plus, Zap, ZapOff } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Cast, + Inbox, + LayoutGrid, + Plus, + ToolCase, + Zap, + ZapOff, +} from 'lucide-react'; +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; +import { ProjectNavList } from './ProjectNavList'; import { - NavTab, NavTabReconnectSuffix, triggerListenerLeadIconClass, -} from './NavTab'; -import { ProjectNavList } from './ProjectNavList'; +} from './TriggerNavTab'; export interface ProjectPageSidebarProps { chatStore: ChatStore | null; @@ -73,6 +87,7 @@ export default function ProjectPageSidebar({ chatStore: _chatStore, className, }: ProjectPageSidebarProps) { + const contextTabDescriptionId = useId(); const activeWorkspaceTab = usePageTabStore((s) => s.activeWorkspaceTab); const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab); const requestWorkspaceChatFocus = usePageTabStore( @@ -81,7 +96,6 @@ export default function ProjectPageSidebar({ const requestOpenTriggerAddDialog = usePageTabStore( (s) => s.requestOpenTriggerAddDialog ); - const projectSidebarFolded = usePageTabStore((s) => s.projectSidebarFolded); const unviewedTabs = usePageTabStore((s) => s.unviewedTabs); const inboxUnviewedForProjects = usePageTabStore( (s) => s.inboxUnviewedForProjects @@ -126,7 +140,6 @@ export default function ProjectPageSidebar({ }); const scheduledTabLabel = t('layout.scheduled-tab'); - const triggersTabTooltip = scheduledTabLabel; const triggersTabAriaLabel = useMemo(() => { const base = scheduledTabLabel; @@ -694,7 +707,7 @@ export default function ProjectPageSidebar({ setAchieveProjectId(null); }} onConfirm={() => void confirmAchieveProject()} - title={t('layout.end-project')} + title={t('layout.achieve-project')} message={t('layout.ending-this-project-will-stop')} confirmText={t('layout.yes-end-project')} cancelText={t('layout.cancel')} @@ -702,188 +715,158 @@ export default function ProjectPageSidebar({ confirmDisabled={achieveProjectLoading} /> - + ); } diff --git a/src/components/Session/HeaderBox/ChatTimeline.tsx b/src/components/Session/HeaderBox/ChatTimeline.tsx index a0402cc9..8dc777c1 100644 --- a/src/components/Session/HeaderBox/ChatTimeline.tsx +++ b/src/components/Session/HeaderBox/ChatTimeline.tsx @@ -84,16 +84,16 @@ function TaskQueryScrollLabel({
      setRowHovered(true)} onMouseLeave={() => setRowHovered(false)} className={cn( - 'no-drag h-8 rounded-lg min-w-0 gap-3 px-3 relative flex w-full max-w-full shrink-0 cursor-pointer items-center text-left transition-colors', + 'no-drag relative flex h-8 w-full min-w-0 max-w-full shrink-0 cursor-pointer items-center gap-3 rounded-lg px-3 text-left transition-colors', rowBg )} aria-current={active ? 'true' : undefined} @@ -214,28 +214,28 @@ export function ChatTimeline({ return (
      - + {title}
      -
      +
      {entries.length === 0 ? ( -

      +

      {emptyLabel}

      ) : ( -
      +
      {entries.map(({ chatId, taskId, task, firstUserMessageId }) => ( s.toggleSessionPreview); const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon; - const backToWorkspaceTooltip = t('layout.back-to-workspace-tooltip', { - defaultValue: 'Back to workspace', - }); - // Own key (not the old file-preview one): the control's meaning changed, so - // stale translations must not carry over. - const windowPreviewTooltip = t('layout.toggle-window-preview-tooltip', { - defaultValue: 'Toggle window preview', + const backTooltip = t('layout.back-tooltip', { + defaultValue: 'Back', }); + const windowPreviewTooltip = sessionPreviewOpen + ? t('layout.close-preview-tooltip', { defaultValue: 'Close preview' }) + : t('layout.open-preview-tooltip', { defaultValue: 'Open preview' }); if (empty) { return (
      ); } return ( -
      +
      {/* Left: return to workspace + display-only Project identity. */}
      - + @@ -104,13 +101,25 @@ export function HeaderBox({
      - + - -
      - {t('layout.sessions-full-title')} -
      -
      + + + } + title={t('layout.sessions-full-title')} + />
      {sessions.length === 0 ? (

      diff --git a/src/pages/Setting/Appearance.tsx b/src/components/Settings/Appearance/index.tsx similarity index 56% rename from src/pages/Setting/Appearance.tsx rename to src/components/Settings/Appearance/index.tsx index 479e73b7..79edc473 100644 --- a/src/pages/Setting/Appearance.tsx +++ b/src/components/Settings/Appearance/index.tsx @@ -44,6 +44,8 @@ import { useAuthStore, type WorkspaceMainBackground } from '@/store/authStore'; import { Monitor, Moon, RotateCcw, Sun } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import SettingsSection from '../SettingsSection'; +import SettingsSectionPage from '../SettingsSectionPage'; const DEFAULT_EDITABLE_THEME_IDS = [ 'eigent', @@ -72,16 +74,6 @@ function buildMergedCatalog(customThemeCatalog: ThemeCatalog): ThemeCatalog { }; } -function formatThemeLabel(id: string): string { - if (id === 'whale') return 'Whale'; - if (id === 'custom') return 'Custom'; - if (id === 'camel') return 'CAMEL'; - if (id === 'claw') return 'Claw'; - if (id === 'starfish') return 'Starfish'; - if (!id) return id; - return id.charAt(0).toUpperCase() + id.slice(1); -} - function ColorSeedEditor({ label, value, @@ -91,6 +83,7 @@ function ColorSeedEditor({ value: string; onChange: (value: string) => void; }) { + const { t } = useTranslation(); const normalizedPreview = normalizeHexColor(value) ?? 'var(--colors-black-100)'; @@ -113,46 +106,42 @@ function ColorSeedEditor({ }; return ( -

      -
      +
      + {label} -
      -
      + +
      onChange(e.target.value)} - note={ - normalizeHexColor(value) - ? '' - : 'Hex format: six digits (e.g. 1a2b3c)' - } + note={normalizeHexColor(value) ? '' : t('setting.hex-color-format')} />
      @@ -190,12 +179,13 @@ function ContrastSlider({ value: number; onChange: (v: number) => void; }) { + const { t } = useTranslation(); return ( -
      -
      - Contrast -
      -
      +
      + + {t('setting.theme-contrast')} + +
      onChange(Number(e.target.value))} - className="h-2 bg-ds-bg-neutral-subtle-disabled accent-ds-bg-brand-default-default my-auto w-full cursor-pointer appearance-none rounded-full" - aria-label="Theme contrast" + className="my-auto h-2 w-full cursor-pointer appearance-none rounded-full bg-ds-bg-neutral-subtle-disabled accent-ds-bg-brand-default-default" + aria-label={t('setting.theme-contrast')} /> -
      + {value} -
      +
      ); @@ -250,16 +240,16 @@ export default function AppearanceSettings() { () => [ ...DEFAULT_EDITABLE_THEME_IDS.map((id) => ({ id, - label: formatThemeLabel(id), + label: t(`setting.theme-${id}`), isDefault: true, })), ...CUSTOM_THEME_IDS.map((id) => ({ id, - label: formatThemeLabel(id), + label: t(`setting.theme-${id}`), isDefault: false, })), ], - [] + [t] ); const allowedThemeIds = useMemo( @@ -407,156 +397,139 @@ export default function AppearanceSettings() { }; return ( -
      -
      -
      -
      -
      - {t('setting.appearance-tab')} -
      -
      -
      -
      + + + + setAppearanceMode(value as 'light' | 'dark' | 'system') + } + > + + + + + {t('setting.light')} + + + + + + {t('setting.dark')} + + + + + + {t('setting.system-default')} + + + + + -
      -
      -
      - Mode + +
      +
      + + + {themeOptions.map((option) => ( + + + {option.label} + + + ))} + +
      - - setAppearanceMode(value as 'light' | 'dark' | 'system') - } +
      -
      -
      -
      - Theme Customization -
      -
      - -
      -
      - - - {themeOptions.map((option) => ( - -
      - {option.label} -
      -
      - ))} -
      -
      -
      - - -
      - -
      - - - - -
      +
      + + + +
      + -
      -
      -
      - {t('setting.workspace-main-background')} -
      -
      - {t('setting.workspace-main-background-description')} -
      -
      - + +
      + + {t('setting.workspace-main-background-description')} +
      -
      -
      + +
      + ); } diff --git a/src/pages/Browser/CDP.tsx b/src/components/Settings/Browser/index.tsx similarity index 50% rename from src/pages/Browser/CDP.tsx rename to src/components/Settings/Browser/index.tsx index c76d148a..06c303f0 100644 --- a/src/pages/Browser/CDP.tsx +++ b/src/components/Settings/Browser/index.tsx @@ -15,12 +15,21 @@ import { fetchDelete, fetchGet, fetchPost } from '@/api/http'; import AlertDialog from '@/components/ui/alertDialog'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/components/ui/dialog'; import { useHost } from '@/host'; import { Globe, Link2, Loader2, Plus, Trash2 } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; +import SettingsSection from '../SettingsSection'; +import SettingsSectionLoading from '../SettingsSectionLoading'; +import SettingsSectionPage from '../SettingsSectionPage'; interface CdpBrowser { id: string; @@ -36,6 +45,8 @@ export default function CDP() { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const [cdpBrowsers, setCdpBrowsers] = useState([]); + const [browsersLoading, setBrowsersLoading] = useState(true); + const [browsersError, setBrowsersError] = useState(null); const [deletingBrowser, setDeletingBrowser] = useState(null); const [browserToRemove, setBrowserToRemove] = useState( null @@ -45,8 +56,11 @@ export default function CDP() { const [connectChecking, setConnectChecking] = useState(false); const [connectError, setConnectError] = useState(''); const isDesktopMode = !!electronAPI?.getCdpBrowsers; + const failedToLoadBrowsers = t('layout.failed-to-load-browsers'); - const loadCdpBrowsers = async () => { + const loadCdpBrowsers = useCallback(async () => { + setBrowsersLoading(true); + setBrowsersError(null); try { if (electronAPI?.getCdpBrowsers) { const browsers = await electronAPI.getCdpBrowsers(); @@ -58,12 +72,17 @@ export default function CDP() { setCdpBrowsers(Array.isArray(browsers) ? browsers : []); } catch (error) { console.error('Failed to load CDP browsers:', error); + setBrowsersError( + error instanceof Error ? error.message : failedToLoadBrowsers + ); + } finally { + setBrowsersLoading(false); } - }; + }, [electronAPI, failedToLoadBrowsers]); useEffect(() => { - loadCdpBrowsers(); - }, [electronAPI]); + void loadCdpBrowsers(); + }, [loadCdpBrowsers]); useEffect(() => { if (!electronAPI?.onCdpPoolChanged) return; @@ -127,7 +146,7 @@ export default function CDP() { id: 'launch-browser', }); } - }, [t]); + }, [electronAPI, isDesktopMode, loadCdpBrowsers, t]); useEffect(() => { if (searchParams.get('browserAction') !== 'launch') return; @@ -178,7 +197,7 @@ export default function CDP() { const addResult = await electronAPI.addCdpBrowser( portNum, true, - `External Browser (${portNum})` + t('layout.external-browser-name', { port: portNum }) ); if (!addResult?.success) { setConnectError( @@ -189,7 +208,7 @@ export default function CDP() { } else { const connectResult = await fetchPost('/browser/cdp/connect', { port: portNum, - name: `External Browser (${portNum})`, + name: t('layout.external-browser-name', { port: portNum }), }); if (!connectResult?.success) { setConnectError( @@ -210,7 +229,7 @@ export default function CDP() { }; return ( -
      + setBrowserToRemove(null)} @@ -221,7 +240,9 @@ export default function CDP() { }} title={t('layout.remove-browser')} message={t('layout.remove-browser-confirm', { - name: browserToRemove?.name || `Browser ${browserToRemove?.port}`, + name: + browserToRemove?.name || + t('layout.browser-name', { port: browserToRemove?.port }), port: browserToRemove?.port, })} confirmText={t('layout.remove')} @@ -229,151 +250,177 @@ export default function CDP() { confirmVariant="caution" /> - {/* Header Section */} -
      -
      -
      -
      - {t('layout.cdp-browser-connection')} -
      -
      -
      -
      - -
      -
      - {/* Header Section */} -
      -
      - {t('layout.cdp-browser-pool')} -
      -
      - - -
      -
      - {/* Content Section */} -
      - {cdpBrowsers.length > 0 ? ( -
      - {cdpBrowsers.map((browser) => ( -
      -
      -
      -
      - - {browser.name || `Browser ${browser.port}`} - - - {t('layout.port')} {browser.port} - -
      -
      - -
      - ))} -
      - ) : ( -
      - -
      - {t('layout.no-browsers-in-pool')} -
      -

      - {t('layout.add-browsers-hint')} -

      -
      - )} -
      -
      -
      - - {showConnectDialog && ( -
      -
      -
      + + +
      -

      - {t('layout.connect-existing-browser-description')} -

      - { - setConnectPort(event.target.value); - setConnectError(''); - }} - placeholder={t('layout.enter-port-number')} - className="rounded-lg border-ds-border-neutral-muted-disabled bg-ds-bg-neutral-default-default px-4 py-2 text-body-sm text-ds-text-neutral-default-default focus:border-ds-border-brand-default-focus w-full border outline-none" - onKeyDown={(event) => { - if (event.key === 'Enter') handleCheckAndConnect(); - }} - /> - {connectError && ( -

      - {connectError} -

      - )} -
      - - -
      +
      + } + boxClassName="min-h-[200px] gap-2" + > +
      + {browsersLoading && cdpBrowsers.length === 0 ? ( + + ) : browsersError && cdpBrowsers.length === 0 ? ( +
      + {browsersError} + +
      + ) : cdpBrowsers.length > 0 ? ( +
      + {cdpBrowsers.map((browser) => ( +
      +
      +
      +
      + + {browser.name || + t('layout.browser-name', { port: browser.port })} + + + {t('layout.port')} {browser.port} + +
      +
      + +
      + ))} +
      + ) : ( +
      + + + {t('layout.no-browsers-in-pool')} + + + {t('layout.add-browsers-hint')} + +
      + )}
      - )} -
      + + + { + if (!open && connectChecking) return; + setShowConnectDialog(open); + }} + > + { + if (connectChecking) event.preventDefault(); + }} + onPointerDownOutside={(event) => { + if (connectChecking) event.preventDefault(); + }} + > + + + {t('layout.connect-existing-browser')} + + + + + {t('layout.connect-existing-browser-description')} + + + { + setConnectPort(event.target.value); + setConnectError(''); + }} + placeholder={t('layout.enter-port-number')} + className="w-full rounded-lg border border-ds-border-neutral-muted-disabled bg-ds-bg-neutral-default-default px-4 py-2 text-body-sm text-ds-text-neutral-default-default outline-none focus:border-ds-border-brand-default-focus" + onKeyDown={(event) => { + if (event.key === 'Enter') void handleCheckAndConnect(); + }} + /> + {connectError && ( + + {connectError} + + )} +
      + + +
      +
      +
      + ); } diff --git a/src/components/Settings/Channels/index.tsx b/src/components/Settings/Channels/index.tsx new file mode 100644 index 00000000..20664d80 --- /dev/null +++ b/src/components/Settings/Channels/index.tsx @@ -0,0 +1,41 @@ +// ========= 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 { MessageSquare } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import SettingsSection from '../SettingsSection'; +import SettingsSectionPage from '../SettingsSectionPage'; + +export default function Channels() { + const { t } = useTranslation(); + + return ( + + +
      + +
      + + {t('layout.coming-soon')} + + + {t('layout.channels-overview-coming-soon-description')} + +
      +
      + ); +} diff --git a/src/pages/Connectors/ConnectorGateway.tsx b/src/components/Settings/Connectors/ConnectorGateway.tsx similarity index 77% rename from src/pages/Connectors/ConnectorGateway.tsx rename to src/components/Settings/Connectors/ConnectorGateway.tsx index 9acdcc63..ad5c0445 100644 --- a/src/pages/Connectors/ConnectorGateway.tsx +++ b/src/components/Settings/Connectors/ConnectorGateway.tsx @@ -49,22 +49,22 @@ import { useAuthStore } from '@/store/authStore'; import { useServerCapabilityStore } from '@/store/serverCapabilityStore'; import type { TFunction } from 'i18next'; import { - BadgeCheck, ChevronDown, Ellipsis, ExternalLink, - Hammer, Pencil, Plus, RefreshCw, Server, - Settings, Trash2, Wrench, } from 'lucide-react'; import { + lazy, + Suspense, useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -73,15 +73,20 @@ import { import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; -import AddConnectorDialog, { - ProviderIcon, +import { + SettingsHeaderActions, + useSettingsHeader, +} from '../SettingsHeaderContext'; +import SettingsSection from '../SettingsSection'; +import SettingsSectionPage from '../SettingsSectionPage'; +import ConnectorBrowserPage, { actionLabel, isConnectedProvider, providerActionCount, + ProviderIcon, providerLabel, type AddConnectorTarget, -} from './components/AddConnectorDialog'; -import AddCustomConnectorDialog from './components/AddCustomConnectorDialog'; +} from './components/ConnectorBrowserPage'; import { GoogleSearchPanel } from './components/GoogleSearchPanel'; import MCPConfigDialog from './components/MCPConfigDialog'; import MCPDeleteDialog from './components/MCPDeleteDialog'; @@ -94,6 +99,13 @@ import { arrayToArgsJson, parseArgsToArray } from './components/utils'; const IS_LOCAL_MODE = import.meta.env.VITE_USE_LOCAL_PROXY === 'true'; const OVERVIEW_ID = '__overview__'; +type ConnectorPage = 'overview' | 'browse' | 'custom'; +const loadAddCustomConnectorPage = () => + import('./components/AddCustomConnectorPage'); +const AddCustomConnectorPage = lazy(loadAddCustomConnectorPage); +const preloadAddCustomConnectorPage = () => { + void loadAddCustomConnectorPage().catch(() => undefined); +}; const HIDDEN_BUILT_INS = new Set([ 'RAG', 'X(Twitter)', @@ -102,6 +114,10 @@ const HIDDEN_BUILT_INS = new Set([ 'Github', ]); +/** Shared surface for recommended and installed connector cards. */ +const CONNECTOR_ITEM_SURFACE_CLASS = + 'rounded-2xl border border-solid border-transparent !bg-ds-bg-neutral-subtle-default transition-colors hover:border-ds-border-neutral-default-default hover:!bg-ds-bg-neutral-subtle-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus'; + /** Preferred hosted connector service keys for the overview recommendations. */ const RECOMMENDED_SERVICE_KEYS = [ ['slack'], @@ -110,8 +126,6 @@ const RECOMMENDED_SERVICE_KEYS = [ ['google_drive', 'googledrive', 'google-drive'], ['github'], ['google_calendar', 'googlecalendar', 'google-calendar'], - ['stripe'], - ['feishu', 'lark'], ] as const; type ConnectorListItem = @@ -322,6 +336,9 @@ async function resolveOpenProviderByName( export default function ConnectorGateway() { const { t } = useTranslation(); + const connectorCardListId = useId(); + const connectorHeaderTitle = t('layout.connectors'); + const { setHeaderOverride } = useSettingsHeader(); const [searchParams, setSearchParams] = useSearchParams(); const { checkAgentTool, modelType } = useAuthStore(); const capabilityStatus = useServerCapabilityStore((state) => state.status); @@ -337,23 +354,23 @@ export default function ConnectorGateway() { const [openConnections, setOpenConnections] = useState( [] ); + const [openConnectionsLoaded, setOpenConnectionsLoaded] = + useState(IS_LOCAL_MODE); const [selectedId, setSelectedId] = useState(OVERVIEW_ID); const [recommendedProviders, setRecommendedProviders] = useState< ConnectorProvider[] >([]); - const [recommendedLoading, setRecommendedLoading] = useState(false); + const [recommendedLoading, setRecommendedLoading] = useState(!IS_LOCAL_MODE); const [openDetail, setOpenDetail] = useState(null); const [listQuery, setListQuery] = useState(''); const [loadingOpen, setLoadingOpen] = useState(false); - const [loadingCustom, setLoadingCustom] = useState(false); - const [loadingBuiltIns, setLoadingBuiltIns] = useState(false); + const [loadingCustom, setLoadingCustom] = useState(true); + const [loadingBuiltIns, setLoadingBuiltIns] = useState(true); const [detailLoading, setDetailLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false); const [pageError, setPageError] = useState(null); - const [browseDialogOpen, setBrowseDialogOpen] = useState(false); - const [browseDialogTarget, setBrowseDialogTarget] = - useState(null); - const [customDialogOpen, setCustomDialogOpen] = useState(false); + const [connectorPage, setConnectorPage] = useState('overview'); + const [browseTarget, setBrowseTarget] = useState(null); const [showConfig, setShowConfig] = useState(null); const [configForm, setConfigForm] = useState(null); const [configError, setConfigError] = useState(null); @@ -367,6 +384,7 @@ export default function ConnectorGateway() { const { installed: rawBuiltInInstalled, configs, + configsLoading, fetchInstalled: refreshBuiltIns, saveEnvAndConfig, handleUninstall, @@ -426,9 +444,11 @@ export default function ConnectorGateway() { const loadOpenConnections = useCallback(async () => { if (!connectorGatewayEnabled) { setOpenConnections([]); + setOpenConnectionsLoaded(true); return; } setLoadingOpen(true); + setOpenConnectionsLoaded(false); try { setOpenConnections(await fetchConnectedProviders()); } catch (error: any) { @@ -436,6 +456,7 @@ export default function ConnectorGateway() { setOpenConnections([]); } finally { setLoadingOpen(false); + setOpenConnectionsLoaded(true); } }, [connectorGatewayEnabled, t]); @@ -462,7 +483,11 @@ export default function ConnectorGateway() { }, [capabilityStatus, connectorGatewayEnabled, loadOpenConnections]); useEffect(() => { - if (capabilityStatus !== 'ready' || !connectorGatewayEnabled) { + if (capabilityStatus === 'idle' || capabilityStatus === 'loading') { + setRecommendedLoading(true); + return; + } + if (!connectorGatewayEnabled) { setRecommendedProviders([]); setRecommendedLoading(false); return; @@ -623,10 +648,10 @@ export default function ConnectorGateway() { return; } if (section === 'your-mcp') { - setCustomDialogOpen(true); + setConnectorPage('custom'); } else { - setBrowseDialogTarget(null); - setBrowseDialogOpen(true); + setBrowseTarget(null); + setConnectorPage('browse'); } const next = new URLSearchParams(searchParams); next.delete('connectorAction'); @@ -660,30 +685,38 @@ export default function ConnectorGateway() { ); }, [connectorItems, listQuery, t]); - const openBrowseDialog = (target: AddConnectorTarget = null) => { - // Non-local hosts always use Connector Gateway providers — never Built-in. - // Open the dialog immediately and resolve the matching hosted provider in the - // background so the button never feels dead. - if (!IS_LOCAL_MODE && target?.source === 'builtin') { - setBrowseDialogTarget(null); - setBrowseDialogOpen(true); - if (connectorGatewayEnabled) { - const builtInKey = target.item.key; - void resolveOpenProviderByName(builtInKey).then((provider) => { - if (provider) { - setBrowseDialogTarget({ source: 'open', provider }); - } - }); + const openBrowsePage = useCallback( + (target: AddConnectorTarget = null) => { + // Non-local hosts always use Connector Gateway providers — never Built-in. + // Open the dialog immediately and resolve the matching hosted provider in the + // background so the button never feels dead. + if (!IS_LOCAL_MODE && target?.source === 'builtin') { + setBrowseTarget(null); + setConnectorPage('browse'); + if (connectorGatewayEnabled) { + const builtInKey = target.item.key; + void resolveOpenProviderByName(builtInKey).then((provider) => { + if (provider) { + setBrowseTarget({ source: 'open', provider }); + } + }); + } + return; } - return; - } - setBrowseDialogTarget(target); - setBrowseDialogOpen(true); - }; + setBrowseTarget(target); + setConnectorPage('browse'); + }, + [connectorGatewayEnabled] + ); - const openCustomDialog = () => { - setCustomDialogOpen(true); - }; + const openCustomPage = useCallback(() => { + setConnectorPage('custom'); + }, []); + + const closeConnectorSubpage = useCallback(() => { + setBrowseTarget(null); + setConnectorPage('overview'); + }, []); const openRecommendedConnector = (provider: ConnectorProvider) => { const existing = connectorItems.find( @@ -694,9 +727,24 @@ export default function ConnectorGateway() { setSelectedId(existing.id); return; } - openBrowseDialog({ source: 'open', provider }); + openBrowsePage({ source: 'open', provider }); }; + useEffect(() => { + if (connectorPage !== 'overview') return; + setHeaderOverride( + selected + ? { + title: selected.name, + onBack: () => setSelectedId(OVERVIEW_ID), + } + : { + title: connectorHeaderTitle, + } + ); + return () => setHeaderOverride(null); + }, [connectorHeaderTitle, connectorPage, selected, setHeaderOverride]); + const handleInstalled = useCallback( async (hint: ConnectorInstallHint) => { preferredSelectionRef.current = hint; @@ -848,7 +896,14 @@ export default function ConnectorGateway() { } }; - const pageLoading = loadingOpen || loadingCustom || loadingBuiltIns; + const pageLoading = + capabilityStatus === 'idle' || + capabilityStatus === 'loading' || + (connectorGatewayEnabled && !openConnectionsLoaded) || + configsLoading || + loadingOpen || + loadingCustom || + loadingBuiltIns; const renderListIcon = (item: ConnectorListItem) => { if (item.source === 'open') { @@ -914,7 +969,7 @@ export default function ConnectorGateway() { variant="ghost" size="sm" onClick={() => - openBrowseDialog( + openBrowsePage( item.source === 'open' ? { source: 'open', provider: item.provider } : { source: 'builtin', item: item.item } @@ -1142,7 +1197,7 @@ export default function ConnectorGateway() { }; const renderDetailPanel = (item: ConnectorListItem) => ( -
      +
      {renderDetailHeader(item)}
      {item.source === 'open' @@ -1154,130 +1209,204 @@ export default function ConnectorGateway() {
      ); - const renderOverviewPanel = () => { - const count = connectorItems.length; - return ( -
      -
      - - {pageLoading && count === 0 ? '—' : count} - - - {count === 1 - ? t('connectors.count-one') - : t('connectors.count-other')} - + const renderRecommendedConnectors = () => { + if (recommendedLoading && recommendedProviders.length === 0) { + return ( +
      + {Array.from({ length: 6 }).map((_, index) => ( +
      + ))}
      + ); + } -
      - - {t('connectors.recommended')} - - {recommendedLoading && recommendedProviders.length === 0 ? ( -
      - {Array.from({ length: 8 }).map((_, index) => ( -
      - ))} -
      - ) : recommendedProviders.length === 0 ? ( -
      - {connectorGatewayEnabled - ? t('connectors.no-recommended') - : t('connectors.gateway-unavailable')} -
      - ) : ( -
      - {recommendedProviders.map((provider) => { - const liveProvider = - openConnections.find( - (item) => item.service === provider.service - ) || provider; - const connected = isConnectedProvider(liveProvider); - const existing = connectorItems.find( - (item) => - item.source === 'open' && - item.provider.service === provider.service - ); - return ( - - ); - })} -
      - )} + if (recommendedProviders.length === 0) { + return ( +
      + {connectorGatewayEnabled + ? t('connectors.no-recommended') + : t('connectors.gateway-unavailable')}
      + ); + } + + return ( +
      + {recommendedProviders.slice(0, 6).map((provider) => { + const liveProvider = + openConnections.find((item) => item.service === provider.service) || + provider; + return ( + + ); + })}
      ); }; + const renderConnectorCards = () => { + if (pageLoading && connectorItems.length === 0) { + return ( +
      + {Array.from({ length: 4 }).map((_, index) => ( +
      + ))} +
      + ); + } + + if (visibleItems.length === 0) { + return ( +
      + {t('connectors.no-matching')} +
      + ); + } + + return ( +
      + {visibleItems.map((item, index) => { + const cardId = `${connectorCardListId}-connector-${index}`; + const statusLabel = item.active + ? t('connectors.connected') + : t('connectors.not-connected'); + + return ( + + ); + })} +
      + ); + }; + + if (connectorPage === 'browse') { + return ( + + ); + } + + if (connectorPage === 'custom') { + return ( + + + {t('setting.loading', { + defaultValue: 'Loading connector settings', + })} + +
      +
      +
      + } + > + + + ); + } + return ( -
      -
      -

      - {t('connectors.title')} -

      -
      + + {!selected ? ( + setListQuery(event.target.value)} placeholder={t('connectors.search-placeholder')} + searchTooltip={t('connectors.search-placeholder')} /> - - -
      -
      - + + ) : null} {pageError ? ( -
      +
      {pageError} - - {pageLoading && connectorItems.length === 0 ? ( - Array.from({ length: 4 }).map((_, index) => ( -
      - )) - ) : visibleItems.length === 0 ? ( - listQuery.trim() ? ( -
      - - {t('connectors.no-matching')} - -
      - ) : null - ) : ( - visibleItems.map((item) => { - const active = selectedId === item.id; - return ( - - ); - }) - )} -
      - - -
      - {selectedId === OVERVIEW_ID || !selected - ? renderOverviewPanel() - : renderDetailPanel(selected)} -
      -
      - - { - setBrowseDialogOpen(next); - if (!next) setBrowseDialogTarget(null); - }} - onInstalled={handleInstalled} - saveBuiltInValue={saveEnvAndConfig} - refreshBuiltIns={refreshBuiltIns} - /> - - + } + boxClassName="p-3" + > + {renderConnectorCards()} + + + )} -
      + ); } diff --git a/src/pages/Connectors/components/AddCustomConnectorDialog.tsx b/src/components/Settings/Connectors/components/AddCustomConnectorPage.tsx similarity index 55% rename from src/pages/Connectors/components/AddCustomConnectorDialog.tsx rename to src/components/Settings/Connectors/components/AddCustomConnectorPage.tsx index 24f0db45..1bf1dbb7 100644 --- a/src/pages/Connectors/components/AddCustomConnectorDialog.tsx +++ b/src/components/Settings/Connectors/components/AddCustomConnectorPage.tsx @@ -19,13 +19,7 @@ import { proxyFetchPost, proxyFetchPut, } from '@/api/http'; -import { - Dialog, - DialogContent, - DialogContentSection, - DialogFooter, - DialogHeader, -} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import loader from '@monaco-editor/loader'; @@ -35,6 +29,9 @@ import * as monaco from 'monaco-editor'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; +import { useSettingsHeader } from '../../SettingsHeaderContext'; +import SettingsSection from '../../SettingsSection'; +import SettingsSectionPage from '../../SettingsSectionPage'; import type { ConnectorInstallHint, MCPUserItem } from './types'; if (typeof globalThis !== 'undefined') { @@ -69,20 +66,20 @@ const LOCAL_MCP_EXAMPLE = `{ } }`; -interface AddCustomConnectorDialogProps { - open: boolean; +interface AddCustomConnectorPageProps { customMcps: MCPUserItem[]; - onOpenChange: (open: boolean) => void; + onBack: () => void; onInstalled: (hint: ConnectorInstallHint) => void | Promise; } -export default function AddCustomConnectorDialog({ - open, +export default function AddCustomConnectorPage({ customMcps, - onOpenChange, + onBack, onInstalled, -}: AddCustomConnectorDialogProps) { +}: AddCustomConnectorPageProps) { const { t } = useTranslation(); + const customHeaderTitle = t('connectors.custom-title'); + const { setHeaderOverride } = useSettingsHeader(); const [customType, setCustomType] = useState<'local' | 'remote'>('local'); const [localJson, setLocalJson] = useState(LOCAL_MCP_EXAMPLE); const [remoteName, setRemoteName] = useState(''); @@ -91,28 +88,13 @@ export default function AddCustomConnectorDialog({ const [formError, setFormError] = useState(null); const [jsonError, setJsonError] = useState(null); - useEffect(() => { - if (!open) return; - setCustomType('local'); - setRemoteName(''); - setRemoteUrl(''); - setFormError(null); - setJsonError(null); - setSaving(false); - try { - setLocalJson(JSON.stringify(JSON.parse(LOCAL_MCP_EXAMPLE), null, 4)); - } catch { - setLocalJson(LOCAL_MCP_EXAMPLE); - } - }, [open]); - useEffect(() => { setJsonError(null); }, [localJson]); - const closeDialog = useCallback(() => { - onOpenChange(false); - }, [onOpenChange]); + const closePage = useCallback(() => { + onBack(); + }, [onBack]); const installCustom = useCallback(async () => { setSaving(true); @@ -188,7 +170,7 @@ export default function AddCustomConnectorDialog({ } toast.success(t('connectors.custom-installed', { num: names.length })); await onInstalled({ source: 'custom', key: names[0] }); - closeDialog(); + closePage(); return; } @@ -246,14 +228,14 @@ export default function AddCustomConnectorDialog({ } toast.success(t('connectors.installed-toast', { name })); await onInstalled({ source: 'custom', key: name }); - closeDialog(); + closePage(); } catch (error: any) { setFormError(error?.message || t('connectors.install-custom-failed')); } finally { setSaving(false); } }, [ - closeDialog, + closePage, customMcps, customType, localJson, @@ -263,149 +245,142 @@ export default function AddCustomConnectorDialog({ t, ]); + const changeCustomType = useCallback( + (value: string) => { + const next = value as 'local' | 'remote'; + setCustomType(next); + setFormError(null); + if (next !== 'local') return; + try { + setLocalJson(JSON.stringify(JSON.parse(localJson), null, 4)); + setJsonError(null); + } catch (error: any) { + setJsonError( + t('connectors.json-format-error', { + message: error?.message || String(error), + }) + ); + } + }, + [localJson, t] + ); + useEffect(() => { + setHeaderOverride({ + title: customHeaderTitle, + onBack: closePage, + }); + return () => setHeaderOverride(null); + }, [closePage, customHeaderTitle, setHeaderOverride]); + return ( - { - if (!next) closeDialog(); - }} - > - + - - - -
      -
      - { - const next = value as 'local' | 'remote'; - setCustomType(next); - setFormError(null); - if (next !== 'local') return; - try { - setLocalJson( - JSON.stringify(JSON.parse(localJson), null, 4) - ); - setJsonError(null); - } catch (error: any) { - setJsonError( - t('connectors.json-format-error', { - message: error?.message || String(error), - }) - ); - } - }} - > - - - - - {t('connectors.source-local')} - - - - - - {t('connectors.source-remote')} - - - - - -
      - {t('connectors.custom-warning')} -
      - - {customType === 'local' ? ( -
      - - {t('connectors.local-json-desc')}{' '} - - {t('connectors.learn-more')} - +
      +
      + + + + + + {t('connectors.source-local')} - {jsonError ? ( - - {jsonError} - - ) : null} -
      - setLocalJson(value ?? '')} - options={{ - minimap: { enabled: false }, - fontSize: 14, - scrollBeyondLastLine: false, - readOnly: saving, - automaticLayout: true, - }} - /> -
      -
      - ) : ( -
      - setRemoteName(event.target.value)} - placeholder={t('connectors.remote-name-placeholder')} - leadingIcon={} - /> - setRemoteUrl(event.target.value)} - placeholder="https://example.com/mcp" - leadingIcon={} - note={t('connectors.remote-url-note')} - /> -
      - )} - - {formError ? ( -
      - {formError} -
      - ) : null} -
      + + + + + {t('connectors.source-remote')} + + + +
      - void installCustom()} - confirmButtonDisabled={saving} - /> - - -
      +
      + {t('connectors.custom-warning')} +
      + + {customType === 'local' ? ( +
      + + {t('connectors.local-json-desc')}{' '} + + {t('connectors.learn-more')} + + + {jsonError ? ( + + {jsonError} + + ) : null} +
      + setLocalJson(value ?? '')} + options={{ + minimap: { enabled: false }, + fontSize: 14, + scrollBeyondLastLine: false, + readOnly: saving, + automaticLayout: true, + }} + /> +
      +
      + ) : ( +
      + setRemoteName(event.target.value)} + placeholder={t('connectors.remote-name-placeholder')} + leadingIcon={} + /> + setRemoteUrl(event.target.value)} + placeholder="https://example.com/mcp" + leadingIcon={} + note={t('connectors.remote-url-note')} + /> +
      + )} + + {formError ? ( +
      + {formError} +
      + ) : null} +
      + + +
      +
      + + ); } diff --git a/src/pages/Connectors/components/AddConnectorDialog.tsx b/src/components/Settings/Connectors/components/ConnectorBrowserPage.tsx similarity index 89% rename from src/pages/Connectors/components/AddConnectorDialog.tsx rename to src/components/Settings/Connectors/components/ConnectorBrowserPage.tsx index 17e0499a..d98e9ef9 100644 --- a/src/pages/Connectors/components/AddConnectorDialog.tsx +++ b/src/components/Settings/Connectors/components/ConnectorBrowserPage.tsx @@ -29,13 +29,6 @@ import { proxyFetchGet } from '@/api/http'; import SearchInput from '@/components/Dashboard/SearchInput'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogContentSection, - DialogFooter, - DialogHeader, -} from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -63,15 +56,20 @@ import { useMemo, useRef, useState, - type UIEvent, } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; +import { + SettingsHeaderActions, + useSettingsHeader, +} from '../../SettingsHeaderContext'; +import SettingsSection from '../../SettingsSection'; +import SettingsSectionPage from '../../SettingsSectionPage'; import type { ConnectorInstallHint } from './types'; export type { ConnectorInstallHint }; -/** Large enough to fill the dialog viewport and avoid immediate load-more waterfalls. */ +/** Large enough to fill the settings viewport and avoid load-more waterfalls. */ const MARKET_PAGE_SIZE = 60; export type AddConnectorTarget = @@ -86,15 +84,14 @@ interface StoredConfig { config_value?: string; } -interface AddConnectorDialogProps { - open: boolean; +interface ConnectorBrowserPageProps { connectorGatewayEnabled: boolean; localMode: boolean; builtInItems: IntegrationItem[]; builtInInstalled: Record; configs: StoredConfig[]; initialTarget?: AddConnectorTarget; - onOpenChange: (open: boolean) => void; + onBack: () => void; onInstalled: (hint: ConnectorInstallHint) => void | Promise; saveBuiltInValue: ( provider: string, @@ -300,20 +297,21 @@ function CatalogLoadingBanner({ label }: { label: string }) { ); } -export default function AddConnectorDialog({ - open, +export default function ConnectorBrowserPage({ connectorGatewayEnabled, localMode, builtInItems, builtInInstalled, configs, initialTarget = null, - onOpenChange, + onBack, onInstalled, saveBuiltInValue, refreshBuiltIns, -}: AddConnectorDialogProps) { +}: ConnectorBrowserPageProps) { const { t } = useTranslation(); + const browseHeaderTitle = t('connectors.browse'); + const { setHeaderOverride } = useSettingsHeader(); const shouldReduceMotion = useReducedMotion(); const [browseSource, setBrowseSource] = useState<'open' | 'builtin'>( connectorGatewayEnabled || !localMode ? 'open' : 'builtin' @@ -369,7 +367,6 @@ export default function AddConnectorDialog({ useEffect(() => stopOAuthPolling, [stopOAuthPolling]); useEffect(() => { - if (!open) return; // Built-in is only available in local mode. Hosted / non-local always uses // Connector Gateway providers. const allowBuiltin = localMode; @@ -397,7 +394,7 @@ export default function AddConnectorDialog({ setFormError(null); setAuthorizationPending(false); setActionsExpanded(false); - }, [connectorGatewayEnabled, initialTarget, localMode, open]); + }, [connectorGatewayEnabled, initialTarget, localMode]); useEffect(() => { setActionsExpanded(false); @@ -446,7 +443,7 @@ export default function AddConnectorDialog({ append: boolean, options: { soft?: boolean; bypassCache?: boolean } = {} ) => { - if (!open || !connectorGatewayEnabled || browseSource !== 'open') return; + if (!connectorGatewayEnabled || browseSource !== 'open') return; const requestId = ++catalogRequestIdRef.current; const soft = options.soft === true; if (append) { @@ -485,14 +482,7 @@ export default function AddConnectorDialog({ } } }, - [ - applyCatalogPage, - browseSource, - connectorGatewayEnabled, - debouncedQuery, - open, - t, - ] + [applyCatalogPage, browseSource, connectorGatewayEnabled, debouncedQuery, t] ); const loadNextCatalogPage = useCallback(() => { @@ -522,22 +512,9 @@ export default function AddConnectorDialog({ selectedProvider, ]); - const handleBrowseScroll = useCallback( - (event: UIEvent) => { - if (browseSource !== 'open') return; - const element = event.currentTarget; - const distanceToBottom = - element.scrollHeight - element.scrollTop - element.clientHeight; - if (distanceToBottom <= 280) { - loadNextCatalogPage(); - } - }, - [browseSource, loadNextCatalogPage] - ); - // Hydrate from cache before paint to avoid empty-state / skeleton flash on open. useLayoutEffect(() => { - if (!open || !connectorGatewayEnabled || browseSource !== 'open') return; + if (!connectorGatewayEnabled || browseSource !== 'open') return; const cached = getCachedConnectorProviders({ page: 1, pageSize: MARKET_PAGE_SIZE, @@ -553,16 +530,10 @@ export default function AddConnectorDialog({ setPage(1); setCatalogError(null); setCatalogLoading(true); - }, [ - applyCatalogPage, - browseSource, - connectorGatewayEnabled, - debouncedQuery, - open, - ]); + }, [applyCatalogPage, browseSource, connectorGatewayEnabled, debouncedQuery]); useEffect(() => { - if (!open || !connectorGatewayEnabled || browseSource !== 'open') return; + if (!connectorGatewayEnabled || browseSource !== 'open') return; const cached = getCachedConnectorProviders({ page: 1, pageSize: MARKET_PAGE_SIZE, @@ -573,13 +544,7 @@ export default function AddConnectorDialog({ return; } void loadCatalogPage(1, false); - }, [ - browseSource, - connectorGatewayEnabled, - debouncedQuery, - loadCatalogPage, - open, - ]); + }, [browseSource, connectorGatewayEnabled, debouncedQuery, loadCatalogPage]); useEffect(() => { if (browseSource !== 'open') return; @@ -592,7 +557,7 @@ export default function AddConnectorDialog({ if (!entries[0]?.isIntersecting) return; loadNextCatalogPage(); }, - { root, rootMargin: '160px' } + { root: null, rootMargin: '160px' } ); observer.observe(sentinel); return () => observer.disconnect(); @@ -669,18 +634,18 @@ export default function AddConnectorDialog({ }); }, [builtInItems, debouncedQuery]); - const closeDialog = useCallback(() => { + const closePage = useCallback(() => { stopOAuthPolling(); - onOpenChange(false); - }, [onOpenChange, stopOAuthPolling]); + onBack(); + }, [onBack, stopOAuthPolling]); const finishInstall = useCallback( async (hint: ConnectorInstallHint) => { stopOAuthPolling(); await onInstalled(hint); - closeDialog(); + closePage(); }, - [closeDialog, onInstalled, stopOAuthPolling] + [closePage, onInstalled, stopOAuthPolling] ); const startOAuthPolling = useCallback( @@ -852,7 +817,7 @@ export default function AddConnectorDialog({ setFormError(null); }; - const goBackToBrowse = () => { + const goBackToBrowse = useCallback(() => { stopOAuthPolling(); setSelectedProvider(null); setSelectedBuiltIn(null); @@ -866,76 +831,78 @@ export default function AddConnectorDialog({ browseScrollRef.current.scrollTop = savedScrollTopRef.current; } }, 0); - }; + }, [stopOAuthPolling]); const showingDetail = Boolean(selectedProvider || selectedBuiltIn); + const detailOpenedDirectly = initialTarget !== null; + + useEffect(() => { + setHeaderOverride({ + title: selectedProvider + ? providerLabel(selectedProvider) + : selectedBuiltIn + ? selectedBuiltIn.name + : browseHeaderTitle, + onBack: + showingDetail && !detailOpenedDirectly ? goBackToBrowse : closePage, + }); + return () => setHeaderOverride(null); + }, [ + browseHeaderTitle, + closePage, + detailOpenedDirectly, + goBackToBrowse, + selectedBuiltIn, + selectedProvider, + setHeaderOverride, + showingDetail, + ]); return ( - { - if (!next) closeDialog(); - }} - > - + {!showingDetail ? ( + + setQuery(event.target.value)} + placeholder={t('connectors.search-connectors')} + searchTooltip={t('connectors.search-connectors')} + /> + {localMode && connectorGatewayEnabled ? ( + + ) : null} + {localMode ? ( + + ) : null} + + ) : null} + - - {!showingDetail ? ( - -
      - setQuery(event.target.value)} - placeholder={t('connectors.search-connectors')} - /> -
      - - {localMode ? ( -
      - {connectorGatewayEnabled ? ( - - ) : null} - -
      - ) : null} - +
      {browseSource === 'open' ? ( !connectorGatewayEnabled ? ( @@ -994,7 +961,7 @@ export default function AddConnectorDialog({ key={provider.service} type="button" onClick={() => openProvider(provider)} - className="group flex h-20 items-center gap-3 rounded-2xl border border-solid border-transparent bg-ds-bg-neutral-default-default px-4 py-3 text-left transition-colors hover:border-ds-border-neutral-default-default hover:bg-ds-bg-neutral-default-hover" + className="group flex h-20 items-center gap-3 rounded-2xl border border-solid border-transparent bg-ds-bg-neutral-subtle-default px-4 py-3 text-left transition-colors hover:border-ds-border-neutral-default-default hover:bg-ds-bg-neutral-default-hover" >
      @@ -1070,7 +1037,7 @@ export default function AddConnectorDialog({ setSelectedBuiltIn(item); setFormError(null); }} - className="group flex h-20 items-center gap-3 rounded-2xl border border-solid border-transparent bg-ds-bg-neutral-default-default px-4 py-3 text-left transition-colors hover:border-ds-border-neutral-default-default hover:bg-ds-bg-neutral-subtle-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus" + className="group flex h-20 items-center gap-3 rounded-2xl border border-solid border-transparent bg-ds-bg-neutral-subtle-default px-4 py-3 text-left transition-colors hover:border-ds-border-neutral-default-default hover:bg-ds-bg-neutral-subtle-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus" >
      @@ -1101,10 +1068,10 @@ export default function AddConnectorDialog({
      )}
      - +
      ) : selectedProvider ? ( <> - +
      {detailLoading ? (
      @@ -1282,29 +1249,28 @@ export default function AddConnectorDialog({ ) : null}
      )} - - +
      + + +
      ) : selectedBuiltIn ? ( <> - +
      @@ -1430,22 +1396,23 @@ export default function AddConnectorDialog({
      ) : null}
      - - void installBuiltIn()} - confirmButtonDisabled={saving || authorizationPending} - /> +
      +
      + + +
      ) : null} - -
      + + ); } diff --git a/src/pages/Connectors/components/GoogleSearchPanel.tsx b/src/components/Settings/Connectors/components/GoogleSearchPanel.tsx similarity index 100% rename from src/pages/Connectors/components/GoogleSearchPanel.tsx rename to src/components/Settings/Connectors/components/GoogleSearchPanel.tsx diff --git a/src/pages/Connectors/components/MCPConfigDialog.tsx b/src/components/Settings/Connectors/components/MCPConfigDialog.tsx similarity index 100% rename from src/pages/Connectors/components/MCPConfigDialog.tsx rename to src/components/Settings/Connectors/components/MCPConfigDialog.tsx diff --git a/src/components/Settings/Connectors/components/MCPDeleteDialog.tsx b/src/components/Settings/Connectors/components/MCPDeleteDialog.tsx new file mode 100644 index 00000000..00a67260 --- /dev/null +++ b/src/components/Settings/Connectors/components/MCPDeleteDialog.tsx @@ -0,0 +1,100 @@ +// ========= 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 { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/components/ui/dialog'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { MCPUserItem } from './types'; + +interface MCPDeleteDialogProps { + open: boolean; + target: MCPUserItem | null; + onCancel: () => void; + onConfirm: () => void; + loading: boolean; +} + +export default function MCPDeleteDialog({ + open, + target, + onCancel, + onConfirm, + loading, +}: MCPDeleteDialogProps) { + const { t } = useTranslation(); + // Retain the last target so the content stays populated while the dialog + // plays its close animation (target is cleared the moment `open` flips false). + const [displayTarget, setDisplayTarget] = useState(target); + if (target && target !== displayTarget) { + setDisplayTarget(target); + } + + return ( + { + if (!nextOpen && !loading) onCancel(); + }} + > + { + if (loading) event.preventDefault(); + }} + onPointerDownOutside={(event) => { + if (loading) event.preventDefault(); + }} + className="gap-4 p-6" + > + + {t('setting.confirm-delete')} + + + + {t('setting.are-you-sure-you-want-to-delete')}{' '} + {displayTarget?.mcp_name}? + + +
      + + +
      +
      +
      + ); +} diff --git a/src/pages/Connectors/components/MCPEnvDialog.tsx b/src/components/Settings/Connectors/components/MCPEnvDialog.tsx similarity index 88% rename from src/pages/Connectors/components/MCPEnvDialog.tsx rename to src/components/Settings/Connectors/components/MCPEnvDialog.tsx index ca63cba9..61c496f7 100644 --- a/src/pages/Connectors/components/MCPEnvDialog.tsx +++ b/src/components/Settings/Connectors/components/MCPEnvDialog.tsx @@ -54,18 +54,12 @@ export async function google_check(apiKey: string, searchEngineId: string) { if ('items' in data) { return { success: true, - message: 'Google key is valid ✅', sample: data.items[0], }; - } else { - return { - success: false, - message: 'Google key invalid ❌', - error: data.error, - }; } + return { success: false, error: data.error }; } catch (err: any) { - return { success: false, message: `Google check failed: ${err.message}` }; + return { success: false, error: err }; } } @@ -90,14 +84,12 @@ export async function exa_check(apiKey: string) { if ('results' in data) { return { success: true, - message: 'Exa key is valid ✅', sample: data.results[0], }; - } else { - return { success: false, message: 'Exa key invalid ❌', error: data }; } + return { success: false, error: data }; } catch (err: any) { - return { success: false, message: `Exa check failed: ${err.message}` }; + return { success: false, error: err }; } } @@ -194,7 +186,9 @@ export const MCPEnvDialog: FC = ({ if (field?.required && (!field.value || field.value.trim() === '')) { updatedEnvValues[key] = { ...field, - error: `${getTitleContent(key)} is required`, + error: t('connectors.field-required', { + field: getTitleContent(key), + }), }; hasErrors = true; } @@ -209,11 +203,11 @@ export const MCPEnvDialog: FC = ({ const getTitleContent = (key: string) => { if (key === 'SEARCH_ENGINE_ID') { - return 'Search Engine ID'; + return t('connectors.search-engine-id'); } else if (key === 'GOOGLE_API_KEY') { - return 'Google API Key'; + return t('connectors.google-api-key'); } else if (key === 'EXA_API_KEY') { - return 'Exa API Key'; + return t('connectors.exa-api-key'); } return key; }; @@ -242,8 +236,9 @@ export const MCPEnvDialog: FC = ({ env['SEARCH_ENGINE_ID'] ); if (!result.success) { - setFieldError('GOOGLE_API_KEY', result.message); - setFieldError('SEARCH_ENGINE_ID', result.message); + const errorMessage = t('connectors.google-check-failed'); + setFieldError('GOOGLE_API_KEY', errorMessage); + setFieldError('SEARCH_ENGINE_ID', errorMessage); setIsValidating(false); return; } @@ -253,7 +248,7 @@ export const MCPEnvDialog: FC = ({ if (env['EXA_API_KEY']) { const result = await exa_check(env['EXA_API_KEY']); if (!result.success) { - setFieldError('EXA_API_KEY', result.message); + setFieldError('EXA_API_KEY', t('connectors.exa-check-failed')); setIsValidating(false); return; } @@ -308,19 +303,19 @@ export const MCPEnvDialog: FC = ({ })} /> -
      -
      +
      +
      {getCategoryIcon(activeMcp?.category?.name)}
      -
      + {activeMcp?.name} -
      +
      {getGithubRepoName(activeMcp?.home_page) && (
      github = ({ verticalAlign: 'middle', }} /> - + {getGithubRepoName(activeMcp?.home_page)}
      @@ -337,7 +332,7 @@ export const MCPEnvDialog: FC = ({
      -
      +
      {Object.keys(activeMcp?.install_command?.env || {}).map((key) => { const getNoteContent = () => { let noteContent = envValues[key]?.tip || ''; @@ -347,7 +342,7 @@ export const MCPEnvDialog: FC = ({ } else if (key === 'GOOGLE_API_KEY') { noteContent += ` ${t('setting.get-it-from')}: https://console.cloud.google.com/apis/credentials`; } else if (key === 'EXA_API_KEY') { - noteContent += ` ${t('setting.get-it-from')}: https://exa.ai (Optional)`; + noteContent += ` ${t('setting.get-it-from')}: https://exa.ai (${t('connectors.optional')})`; } return noteContent; @@ -362,7 +357,9 @@ export const MCPEnvDialog: FC = ({ required={envValues[key]?.required || false} state={envValues[key]?.error ? 'error' : 'default'} type={showKeys[key] ? 'text' : 'password'} - placeholder={`Enter ${getTitleContent(key)}`} + placeholder={t('connectors.enter-field', { + field: getTitleContent(key), + })} value={envValues[key]?.value || ''} backIcon={ showKeys[key] ? ( @@ -388,7 +385,7 @@ export const MCPEnvDialog: FC = ({ cancelButtonVariant="ghost" showConfirmButton confirmButtonText={ - isValidating ? 'Validating...' : t('setting.connect') + isValidating ? t('setting.verifying') : t('setting.connect') } onConfirm={handleConfigureMcpEnvSetting} confirmButtonVariant="primary" diff --git a/src/pages/Connectors/components/types.ts b/src/components/Settings/Connectors/components/types.ts similarity index 100% rename from src/pages/Connectors/components/types.ts rename to src/components/Settings/Connectors/components/types.ts diff --git a/src/pages/Connectors/components/utils.ts b/src/components/Settings/Connectors/components/utils.ts similarity index 100% rename from src/pages/Connectors/components/utils.ts rename to src/components/Settings/Connectors/components/utils.ts diff --git a/src/pages/Connectors/index.tsx b/src/components/Settings/Connectors/index.tsx similarity index 100% rename from src/pages/Connectors/index.tsx rename to src/components/Settings/Connectors/index.tsx diff --git a/src/pages/Browser/Cookies.tsx b/src/components/Settings/Cookies/index.tsx similarity index 51% rename from src/pages/Browser/Cookies.tsx rename to src/components/Settings/Cookies/index.tsx index d934c0b1..0f6b4a06 100644 --- a/src/pages/Browser/Cookies.tsx +++ b/src/components/Settings/Cookies/index.tsx @@ -18,9 +18,12 @@ import { Button } from '@/components/ui/button'; import { useHost } from '@/host'; import { SITE_URL } from '@/lib'; import { Cookie, Plus, RefreshCw, Trash2 } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; +import SettingsSection from '../SettingsSection'; +import SettingsSectionLoading from '../SettingsSectionLoading'; +import SettingsSectionPage from '../SettingsSectionPage'; interface CookieDomain { domain: string; @@ -39,12 +42,12 @@ export default function Cookies() { const electronAPI = host?.electronAPI; const { t } = useTranslation(); const [loginLoading, setLoginLoading] = useState(false); - const [cookiesLoading, setCookiesLoading] = useState(false); + const [cookiesLoading, setCookiesLoading] = useState(true); const [cookieDomains, setCookieDomains] = useState([]); const [deletingDomain, setDeletingDomain] = useState(null); const [deletingAll, setDeletingAll] = useState(false); const [showRestartDialog, setShowRestartDialog] = useState(false); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [, setHasUnsavedChanges] = useState(false); const getMainDomain = (domain: string): string => { const cleanDomain = domain.startsWith('.') ? domain.substring(1) : domain; @@ -80,10 +83,6 @@ export default function Cookies() { .sort((a, b) => a.mainDomain.localeCompare(b.mainDomain)); }; - useEffect(() => { - handleLoadCookies(); - }, []); - const handleBrowserLogin = async () => { setLoginLoading(true); try { @@ -94,7 +93,7 @@ export default function Cookies() { const response = await fetchPost('/browser/login'); if (response) { - toast.success('Browser opened successfully for login'); + toast.success(t('layout.browser-opened')); const checkInterval = setInterval(async () => { try { const statusResponse = await fetchGet('/browser/status'); @@ -112,7 +111,7 @@ export default function Cookies() { if (newCookieCount > currentCookieCount) { const addedCount = newCookieCount - currentCookieCount; toast.success( - `Added ${addedCount} cookie${addedCount !== 1 ? 's' : ''}` + t('layout.cookies-added', { count: addedCount }) ); setHasUnsavedChanges(true); setShowRestartDialog(true); @@ -130,13 +129,13 @@ export default function Cookies() { }, 500); } } catch (error: any) { - toast.error(error?.message || 'Failed to open browser'); + toast.error(error?.message || t('layout.failed-to-open-browser')); } finally { setLoginLoading(false); } }; - const handleLoadCookies = async () => { + const handleLoadCookies = useCallback(async () => { setCookiesLoading(true); try { const response = await fetchGet('/browser/cookies'); @@ -147,12 +146,16 @@ export default function Cookies() { setCookieDomains([]); } } catch (error: any) { - toast.error(error?.message || 'Failed to load cookies'); + toast.error(error?.message || t('layout.failed-to-load-cookies')); setCookieDomains([]); } finally { setCookiesLoading(false); } - }; + }, [t]); + + useEffect(() => { + void handleLoadCookies(); + }, [handleLoadCookies]); const handleDeleteMainDomain = async ( mainDomain: string, @@ -165,7 +168,9 @@ export default function Cookies() { ); await Promise.all(deletePromises); - toast.success(`Deleted cookies for ${mainDomain} and all subdomains`); + toast.success( + t('layout.deleted-cookies-for-domain', { domain: mainDomain }) + ); const domainsToRemove = new Set(subdomains.map((item) => item.domain)); setCookieDomains((prev) => prev.filter((item) => !domainsToRemove.has(item.domain)) @@ -175,7 +180,10 @@ export default function Cookies() { setShowRestartDialog(true); } catch (error: any) { toast.error( - error?.message || `Failed to delete cookies for ${mainDomain}` + error?.message || + t('layout.failed-to-delete-cookies-for-domain', { + domain: mainDomain, + }) ); } finally { setDeletingDomain(null); @@ -186,13 +194,13 @@ export default function Cookies() { setDeletingAll(true); try { await fetchDelete('/browser/cookies'); - toast.success('Deleted all cookies'); + toast.success(t('layout.deleted-all-cookies')); setCookieDomains([]); setHasUnsavedChanges(true); setShowRestartDialog(true); } catch (error: any) { - toast.error(error?.message || 'Failed to delete all cookies'); + toast.error(error?.message || t('layout.failed-to-delete-all-cookies')); } finally { setDeletingAll(false); } @@ -202,7 +210,7 @@ export default function Cookies() { if (electronAPI?.restartApp) { electronAPI.restartApp(); } else { - toast.error('Restart function not available'); + toast.error(t('layout.restart-not-available')); } }; @@ -212,140 +220,137 @@ export default function Cookies() { }; return ( -
      + setShowRestartDialog(false)} onConfirm={handleConfirmRestart} - title="Cookies Updated" - message="Cookies have been updated. Would you like to restart the application to use the new cookies?" - confirmText="Yes, Restart" - cancelText="No, Add More" + title={t('layout.cookies-updated')} + message={t('layout.cookies-updated-message')} + confirmText={t('layout.yes-restart')} + cancelText={t('layout.no-add-more')} confirmVariant="information" /> -
      -
      - {t('layout.browser-cookie-management')} -
      -
      + + + {t('layout.browser-cookies-description')} + +
      +
      +
      + {cookieDomains.length > 0 && ( + + {groupDomainsByMain(cookieDomains).length} + + )} +
      -
      -
      -
      - {t('layout.browser-cookies-description')} -
      -
      -
      -
      -
      - {t('layout.cookie-domains')} -
      - {cookieDomains.length > 0 && ( -
      - {groupDomainsByMain(cookieDomains).length} -
      - )} -
      - -
      - {cookieDomains.length > 0 && ( - - )} +
      + {cookieDomains.length > 0 && ( - -
      + )} + +
      - - {cookieDomains.length > 0 ? ( -
      - {groupDomainsByMain(cookieDomains).map((group, index) => ( -
      -
      - - {group.mainDomain} - - - {group.totalCookies} Cookie - {group.totalCookies !== 1 ? 's' : ''} - -
      - -
      - ))} -
      - ) : ( -
      - -
      - {t('layout.no-cookies-saved-yet')} -
      -

      - {t('layout.no-cookies-saved-yet-description')} -

      -
      - )}
      -
      -
      - For more information, check out our - - {t('layout.privacy-policy')} - + {cookiesLoading && cookieDomains.length === 0 ? ( + + ) : cookieDomains.length > 0 ? ( +
      + {groupDomainsByMain(cookieDomains).map((group, index) => ( +
      +
      + + {group.mainDomain} + + + {t('layout.cookie-count', { + count: group.totalCookies, + })} + +
      + +
      + ))} +
      + ) : ( +
      + + + {t('layout.no-cookies-saved-yet')} + + + {t('layout.no-cookies-saved-yet-description')} + +
      + )}
      -
      -
      + + + + {t('layout.for-more-info')} + + {t('layout.privacy-policy')} + + + ); } diff --git a/src/components/Settings/Extension/index.tsx b/src/components/Settings/Extension/index.tsx new file mode 100644 index 00000000..82900d75 --- /dev/null +++ b/src/components/Settings/Extension/index.tsx @@ -0,0 +1,39 @@ +// ========= 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 { Puzzle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import SettingsSection from '../SettingsSection'; +import SettingsSectionPage from '../SettingsSectionPage'; + +export default function Extension() { + const { t } = useTranslation(); + + return ( + + + +
      + {t('layout.coming-soon')} +
      + + {t('layout.browser-plugins-description')} + +
      +
      + ); +} diff --git a/src/pages/Setting/General.tsx b/src/components/Settings/General/index.tsx similarity index 75% rename from src/pages/Setting/General.tsx rename to src/components/Settings/General/index.tsx index 3379a9c8..9fec6ffd 100644 --- a/src/pages/Setting/General.tsx +++ b/src/components/Settings/General/index.tsx @@ -34,8 +34,18 @@ import { } from '@/components/ui/select'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { useHost } from '@/host'; +import SettingsSection from '../SettingsSection'; +import SettingsSectionPage from '../SettingsSectionPage'; -export default function SettingGeneral() { +type GeneralSettingsSection = 'all' | 'profile' | 'language' | 'network-proxy'; + +interface SettingGeneralProps { + section?: GeneralSettingsSection; +} + +export default function SettingGeneral({ + section = 'all', +}: SettingGeneralProps) { const { t } = useTranslation(); const host = useHost(); const authStore = useAuthStore(); @@ -57,6 +67,7 @@ export default function SettingGeneral() { // Proxy configuration state const [proxyUrl, setProxyUrl] = useState(''); + const [proxyLoading, setProxyLoading] = useState(true); const [isProxySaving, setIsProxySaving] = useState(false); const [proxyNeedsRestart, setProxyNeedsRestart] = useState(false); @@ -110,18 +121,20 @@ export default function SettingGeneral() { useEffect(() => { // Load proxy configuration from global env const loadProxyConfig = async () => { - if (host?.electronAPI?.readGlobalEnv) { - try { + try { + if (host?.electronAPI?.readGlobalEnv) { const result = await host.electronAPI.readGlobalEnv('HTTP_PROXY'); if (result?.value) { setProxyUrl(result.value); } - } catch (_error) { - console.log('No proxy configured'); } + } catch (_error) { + console.log('No proxy configured'); + } finally { + setProxyLoading(false); } }; - loadProxyConfig(); + void loadProxyConfig(); }, [host]); // Save proxy configuration @@ -180,25 +193,15 @@ export default function SettingGeneral() { }; return ( -
      - {/* Header Section */} -
      -
      -
      -
      - {t('setting.general')} -
      -
      -
      -
      - {/* Content Section */} -
      - {/* Profile Section */} -
      + + {/* Profile Section */} + {(section === 'all' || section === 'profile') && ( +
      -
      - {t('setting.profile')} -
      -
      +
      + )} - {/* Language Section */} -
      -
      -
      - {t('setting.language')} -
      -
      + {/* Language Section */} + {(section === 'all' || section === 'language') && ( + -
      + + )} - {/* Network Proxy Section */} -
      + {/* Network Proxy Section */} + {(section === 'all' || section === 'network-proxy') && ( +
      -
      - {t('setting.network-proxy')} -
      -
      +
      {t('setting.network-proxy-description')}
      @@ -294,6 +310,7 @@ export default function SettingGeneral() { }} className="flex-1" size="default" + disabled={proxyLoading} note={ proxyNeedsRestart ? t('setting.proxy-restart-hint') : undefined } @@ -306,18 +323,20 @@ export default function SettingGeneral() { ? () => host?.electronAPI?.restartApp() : handleSaveProxy } - disabled={!proxyNeedsRestart && isProxySaving} + disabled={proxyLoading || (!proxyNeedsRestart && isProxySaving)} > - {proxyNeedsRestart - ? t('setting.restart-to-apply') - : isProxySaving - ? t('setting.saving') - : t('setting.save')} + {proxyLoading + ? t('setting.loading') + : proxyNeedsRestart + ? t('setting.restart-to-apply') + : isProxySaving + ? t('setting.saving') + : t('setting.save')} } /> -
      -
      -
      + + )} + ); } diff --git a/src/components/Settings/Memory/index.tsx b/src/components/Settings/Memory/index.tsx new file mode 100644 index 00000000..829bb1ee --- /dev/null +++ b/src/components/Settings/Memory/index.tsx @@ -0,0 +1,41 @@ +// ========= 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 { Brain } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import SettingsSection from '../SettingsSection'; +import SettingsSectionPage from '../SettingsSectionPage'; + +export default function Memory() { + const { t } = useTranslation(); + + return ( + + +
      + +
      + + {t('layout.coming-soon')} + + + {t('agents.memory-coming-soon-description')} + +
      +
      + ); +} diff --git a/src/pages/Agents/ConfigModelCard.tsx b/src/components/Settings/Models/ConfigModelCard.tsx similarity index 100% rename from src/pages/Agents/ConfigModelCard.tsx rename to src/components/Settings/Models/ConfigModelCard.tsx diff --git a/src/pages/Agents/components/ProviderModelCombobox.tsx b/src/components/Settings/Models/components/ProviderModelCombobox.tsx similarity index 83% rename from src/pages/Agents/components/ProviderModelCombobox.tsx rename to src/components/Settings/Models/components/ProviderModelCombobox.tsx index 9f01c737..04accc95 100644 --- a/src/pages/Agents/components/ProviderModelCombobox.tsx +++ b/src/components/Settings/Models/components/ProviderModelCombobox.tsx @@ -90,25 +90,20 @@ export function ProviderModelCombobox({ const selectDisabled = disabled || (!hasAnyModels && !orphanValue); const emptyMessage = loading - ? t('setting.loading', { defaultValue: 'Loading...' }) + ? t('setting.loading-models') : disabled - ? (disabledReason ?? - t('setting.enter-api-key-first', { - defaultValue: 'Enter API Key first.', - })) - : t('setting.click-refresh-to-load-models', { - defaultValue: 'Click refresh to load models.', - }); + ? (disabledReason ?? t('setting.enter-api-key-first')) + : t('setting.click-refresh-to-load-models'); return (
      {title ? ( -
      + {title} -
      + ) : null} -
      +