Merge codex/refactor-session-side-panel into refactor-dashboard

This commit is contained in:
Douglas 2026-08-13 12:58:58 +01:00
commit 7abbfcacf5
203 changed files with 8777 additions and 6958 deletions

View file

@ -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<string>('');
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')}
</Button>

View file

@ -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';

View file

@ -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}
>
<Hammer />
<Cable />
</Button>
</TooltipSimple>
)}

View file

@ -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;
}

View file

@ -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<IntegrationItem[]>([]);
const [openItems, setOpenItems] = useState<PickerItem[]>([]);
const [yourMcps, setYourMcps] = useState<PickerItem[]>([]);
@ -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')}
/>
);
}

View file

@ -70,12 +70,12 @@ export function FeedbackCard({
return (
<div
key={id}
className={`group gap-4 rounded-xl px-4 py-3 bg-ds-bg-neutral-default-default relative flex w-full flex-col items-center justify-center overflow-hidden border ${className || ''}`}
className={`group relative flex w-full flex-col items-center justify-center gap-4 overflow-hidden rounded-xl border bg-ds-bg-neutral-default-default px-4 py-3 ${className || ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Copy button - appears on hover */}
<div className="bottom-1 right-1 absolute opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<div className="absolute bottom-1 right-1 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Button
onClick={handleCopy}
variant="ghost"
@ -91,17 +91,17 @@ export function FeedbackCard({
</div>
{/* Title */}
<p className="font-inter text-sm font-bold leading-normal w-full text-ds-text-neutral-default-default">
<p className="w-full font-inter text-sm font-bold leading-normal text-ds-text-neutral-default-default">
{title}
</p>
{/* Content */}
<p className="font-inter text-sm font-medium leading-normal w-full text-ds-text-neutral-default-default">
<p className="w-full font-inter text-sm font-medium leading-normal text-ds-text-neutral-default-default">
{content}
</p>
{/* Action buttons */}
<div className="gap-1 flex w-full items-center">
<div className="flex w-full items-center gap-1">
<Button
onClick={onConfirm}
variant="primary"

View file

@ -67,7 +67,7 @@ export const SummaryMarkDown = ({
.trim();
return (
<div className="prose prose-sm max-w-none">
<pre className="mb-3 rounded-lg p-3 font-mono text-xs overflow-x-auto border border-ds-border-status-completed-default-default bg-ds-bg-completed-subtle-default whitespace-pre-wrap text-ds-text-neutral-default-default">
<pre className="mb-3 overflow-x-auto whitespace-pre-wrap rounded-lg border border-ds-border-status-completed-default-default bg-ds-bg-completed-subtle-default p-3 font-mono text-xs text-ds-text-neutral-default-default">
<code>{formattedHtml}</code>
</pre>
</div>
@ -79,12 +79,12 @@ export const SummaryMarkDown = ({
<ReactMarkdown
components={{
h1: ({ children }) => (
<h1 className="mb-3 gap-2 pb-2 text-xl font-bold flex items-center border-b border-ds-border-status-completed-default-default text-ds-text-success-default-default">
<h1 className="mb-3 flex items-center gap-2 border-b border-ds-border-status-completed-default-default pb-2 text-xl font-bold text-ds-text-success-default-default">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="mb-3 mt-4 gap-2 text-lg font-semibold flex items-center text-ds-text-status-completed-muted-default">
<h2 className="mb-3 mt-4 flex items-center gap-2 text-lg font-semibold text-ds-text-status-completed-muted-default">
{children}
</h2>
),
@ -94,17 +94,17 @@ export const SummaryMarkDown = ({
</h3>
),
p: ({ children }) => (
<p className="m-0 mb-3 text-sm font-normal leading-relaxed whitespace-pre-wrap text-ds-text-neutral-default-default">
<p className="m-0 mb-3 whitespace-pre-wrap text-sm font-normal leading-relaxed text-ds-text-neutral-default-default">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-3 ml-2 space-y-1 text-sm list-inside list-disc text-ds-text-neutral-default-default">
<ul className="mb-3 ml-2 list-inside list-disc space-y-1 text-sm text-ds-text-neutral-default-default">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-3 ml-2 space-y-1 text-sm list-inside list-decimal text-ds-text-neutral-default-default">
<ol className="mb-3 ml-2 list-inside list-decimal space-y-1 text-sm text-ds-text-neutral-default-default">
{children}
</ol>
),
@ -114,17 +114,17 @@ export const SummaryMarkDown = ({
</li>
),
code: ({ children }) => (
<code className="rounded px-2 py-1 font-mono text-xs bg-ds-bg-completed-subtle-default text-ds-text-success-default-default">
<code className="rounded bg-ds-bg-completed-subtle-default px-2 py-1 font-mono text-xs text-ds-text-success-default-default">
{children}
</code>
),
pre: ({ children }) => (
<pre className="mb-3 rounded-lg p-3 font-mono text-xs overflow-x-auto border border-ds-border-status-completed-default-default bg-ds-bg-completed-subtle-default whitespace-pre-wrap text-ds-text-neutral-default-default">
<pre className="mb-3 overflow-x-auto whitespace-pre-wrap rounded-lg border border-ds-border-status-completed-default-default bg-ds-bg-completed-subtle-default p-3 font-mono text-xs text-ds-text-neutral-default-default">
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote className="mb-3 rounded-r-lg py-2 pl-4 border-l-4 border-ds-border-status-completed-default-default bg-ds-bg-completed-subtle-default text-ds-text-status-completed-muted-default italic">
<blockquote className="mb-3 rounded-r-lg border-l-4 border-ds-border-status-completed-default-default bg-ds-bg-completed-subtle-default py-2 pl-4 italic text-ds-text-status-completed-muted-default">
{children}
</blockquote>
),
@ -134,7 +134,7 @@ export const SummaryMarkDown = ({
</strong>
),
em: ({ children }) => (
<em className="text-ds-text-status-completed-muted-default italic">
<em className="italic text-ds-text-status-completed-muted-default">
{children}
</em>
),

View file

@ -41,7 +41,7 @@ export const TaskCompletionCard: React.FC<TaskCompletionCardProps> = ({
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 && (
<Button
@ -53,14 +53,14 @@ export const TaskCompletionCard: React.FC<TaskCompletionCardProps> = ({
buttonRadius="full"
buttonContent="icon-only"
onClick={onDismiss}
className="-top-2 -right-2 pointer-events-none absolute z-10 shrink-0 opacity-0 transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
className="pointer-events-none absolute -right-2 -top-2 z-10 shrink-0 opacity-0 transition-opacity duration-200 focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
aria-label={t('chat.close')}
>
<X />
</Button>
)}
<div
className={`min-w-0 gap-0.5 flex w-full flex-col ${onDismiss ? 'pr-10' : ''}`}
className={`flex w-full min-w-0 flex-col gap-0.5 ${onDismiss ? 'pr-10' : ''}`}
>
<div className="text-label-sm font-bold leading-normal text-ds-text-neutral-default-default">
{t('chat.task-completed-card-title')}
@ -73,7 +73,7 @@ export const TaskCompletionCard: React.FC<TaskCompletionCardProps> = ({
variant="primary"
size="sm"
onClick={handleAddTrigger}
className="rounded-lg h-fit"
className="h-fit rounded-lg"
>
<Plus className="h-4 w-4" />
{t('triggers.add-trigger')}

View file

@ -119,7 +119,7 @@ export function ExpandedOverlay({
</div>
)}
<div className="scrollbar scrollbar-always-visible border-t-1 min-h-0 flex-1 overflow-y-auto overflow-x-hidden border border-x-0 border-b-0 border-solid border-ds-border-neutral-subtle-disabled bg-transparent px-2">
<div className="scrollbar scrollbar-always-visible min-h-0 flex-1 overflow-y-auto overflow-x-hidden border border-x-0 border-b-0 border-solid border-ds-border-neutral-subtle-disabled bg-transparent px-2">
{hasTaskInfo ? (
<SubtaskEditor
taskInfo={taskInfo}

View file

@ -37,7 +37,7 @@ export const TaskType = ({ type }: { type: 1 | 2 | 3 }) => {
};
return (
<div
className={`h-6 gap-1 px-2 py-1 flex items-center rounded-full ${typeMap[type].bgColor} ${typeMap[type].textColor} text-xs font-medium leading-17`}
className={`flex h-6 items-center gap-1 rounded-full px-2 py-1 ${typeMap[type].bgColor} ${typeMap[type].textColor} text-xs font-medium leading-17`}
>
<div className={`h-2 w-2 ${typeMap[type].dotColor} rounded-full`}></div>
<span>{typeMap[type].label}</span>

View file

@ -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<string | null>(null);
const hydratedFollowUpProjectsRef = useRef<Set<string>>(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;
}

View file

@ -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: <Blocks />, iconAnimateOnHover: 'default' },
{ id: 'agents', icon: <Bot />, iconAnimateOnHover: 'default' },
{ id: 'channels', icon: <Radio />, iconAnimateOnHover: 'default' },
{ id: 'connectors', icon: <Hammer />, iconAnimateOnHover: 'default' },
{ id: 'browser', icon: <Compass />, iconAnimateOnHover: 'default' },
{ id: 'settings', icon: <Settings />, 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<HTMLDivElement>(null);
const [hoveredTab, setHoveredTab] = useState<HistoryTabId | null>(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<HTMLElement>(
`[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<HTMLElement>(
`[data-history-tab="${hoveredTab}"]`
);
if (el) updateHoverRect(el);
}, [activeTab, hoveredTab, updateHoverRect]);
useLayoutEffect(() => {
if (!hoveredTab) return;
const el = navRef.current?.querySelector<HTMLElement>(
`[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 (
<div
ref={navRef}
className={cn(
'relative flex flex-row flex-wrap items-center gap-2 pb-2',
className
)}
onMouseLeave={() => setHoveredTab(null)}
role="tablist"
>
<motion.div
aria-hidden
className="pointer-events-none absolute z-0 rounded-lg bg-ds-bg-neutral-subtle-default shadow-sm ring-1 ring-ds-border-neutral-default-default"
initial={false}
animate={{
left: hoverRect.left,
top: hoverRect.top,
width: hoverRect.width,
height: hoverRect.height,
opacity: hoveredTab ? 1 : 0,
}}
transition={{
left: { type: 'spring', stiffness: 440, damping: 36, mass: 0.55 },
top: { type: 'spring', stiffness: 440, damping: 36, mass: 0.55 },
width: { type: 'spring', stiffness: 440, damping: 36, mass: 0.55 },
height: { type: 'spring', stiffness: 440, damping: 36, mass: 0.55 },
opacity: { duration: 0.18, ease: 'easeOut' },
}}
style={{ position: 'absolute' }}
/>
{activeLine.width > 0 && (
<motion.div
aria-hidden
className="pointer-events-none absolute z-[11] h-0.5 rounded-full bg-ds-bg-brand-default-default"
initial={false}
animate={{
left: activeLine.left,
top: activeLine.top,
width: activeLine.width,
opacity: underlineEntered ? 1 : 0,
}}
transition={{
left: underlineEntered
? underlineSlideTransition
: underlineInstantTransition,
top: underlineEntered
? underlineSlideTransition
: underlineInstantTransition,
width: underlineEntered
? underlineSlideTransition
: underlineInstantTransition,
opacity: { duration: 0.2, ease: 'easeOut' },
}}
style={{ position: 'absolute' }}
/>
)}
{HISTORY_TABS.map(({ id, icon, iconAnimateOnHover }) => (
<AnimateIcon key={id} animateOnHover={iconAnimateOnHover} asChild>
<button
type="button"
role="tab"
data-history-tab={id}
aria-selected={activeTab === id}
onClick={() => onChange(id)}
onMouseEnter={(e) => {
setHoveredTab(id);
updateHoverRect(e.currentTarget);
}}
className={cn(
tabButtonClass,
'flex flex-row gap-2 border-0 bg-transparent !text-body-sm outline-none focus-visible:ring-2 focus-visible:ring-ds-border-brand-default-focus focus-visible:ring-offset-2 focus-visible:ring-offset-ds-bg-neutral-default-default',
activeTab === id
? 'text-ds-text-neutral-default-default'
: 'text-ds-text-neutral-muted-default hover:text-ds-text-neutral-default-default'
)}
>
<span className={iconSlotClass}>{icon}</span>
{t(`layout.${id}`)}
</button>
</AnimateIcon>
))}
</div>
);
}

View file

@ -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 ? (
<div className="gap-2 min-w-0 min-h-0 flex flex-1 items-center">
<div className="flex min-h-0 min-w-0 flex-1 items-center gap-2">
{selectWithCheckbox && (
<Checkbox
disabled={checkboxDisabled}
@ -380,8 +380,8 @@ export default function IntegrationList({
<span className={titleClassName}>{item.name}</span>
</div>
) : (
<div className="gap-xs flex w-full flex-row items-center justify-between">
<div className="gap-xs flex flex-row items-center">
<div className="flex w-full flex-row items-center justify-between gap-xs">
<div className="flex flex-row items-center gap-xs">
{showStatusDot && (
<img
src={ellipseIcon}
@ -406,7 +406,7 @@ export default function IntegrationList({
</Tooltip>
</div>
</div>
<div className="gap-md flex flex-row items-center">
<div className="flex flex-row items-center gap-md">
{showConfigButton && (
<Button
type="button"
@ -474,8 +474,8 @@ export default function IntegrationList({
</div>
{!isSelectMode && showSelect && (
<div className="mt-6 gap-md border-ds-border-neutral-default-default pt-6 flex w-full flex-row items-center border-x-0 border-b-0 border-solid">
<div className="gap-md flex w-full flex-row items-center justify-between">
<div className="mt-6 flex w-full flex-row items-center gap-md border-x-0 border-b-0 border-solid border-ds-border-neutral-default-default pt-6">
<div className="flex w-full flex-row items-center justify-between gap-md">
<div className="text-body-md text-ds-text-neutral-default-default">
{' '}
Default {item.name}

View file

@ -102,7 +102,11 @@ export default function SearchInput({
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<TooltipSimple content={searchLabel} variant="instant">
<TooltipSimple
content={searchLabel}
variant="instant"
side="bottom"
>
<Button
type="button"
variant="ghost"
@ -143,7 +147,11 @@ export default function SearchInput({
}}
className="h-6 min-w-0 flex-1 bg-transparent pl-2 text-label-sm text-ds-text-neutral-default-default outline-none placeholder:text-ds-text-neutral-muted-default"
/>
<TooltipSimple content={clearLabel} variant="instant">
<TooltipSimple
content={clearLabel}
variant="instant"
side="bottom"
>
<Button
type="button"
variant="ghost"

View file

@ -1,102 +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 * as React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
/** Sticky left nav wrapper for History tab pages (Agents, Channels, Browser, Settings). */
export const HISTORY_VERTICAL_SIDEBAR_CLASSNAME =
'sticky top-[var(--home-hub-history-tabs-offset,49px)] z-10 flex w-40 shrink-0 grow-0 flex-col self-start pr-6 pt-8';
export type VerticalNavItem = {
value: string;
label: React.ReactNode;
icon?: React.ReactNode;
content?: React.ReactNode;
disabled?: boolean;
};
export type VerticalNavigationProps = {
items: VerticalNavItem[];
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
className?: string;
listClassName?: string;
triggerClassName?: string;
contentClassName?: string;
};
export function VerticalNavigation({
items,
defaultValue,
value,
onValueChange,
className,
listClassName,
triggerClassName,
contentClassName,
}: VerticalNavigationProps) {
const initial = React.useMemo(() => {
if (value) return undefined;
if (defaultValue) return defaultValue;
return items[0]?.value;
}, [value, defaultValue, items]);
return (
<Tabs
orientation="vertical"
value={value}
defaultValue={initial}
onValueChange={onValueChange}
className={cn('w-full flex-1', className)}
>
<TabsList
appearance="ghost"
className={cn('flex w-full flex-col', listClassName)}
>
{items.map((item) => (
<TabsTrigger
key={item.value}
value={item.value}
disabled={item.disabled}
appearance="ghost"
className={triggerClassName}
>
{item.icon ? (
<span className="inline-flex h-4 w-4 items-center justify-center">
{item.icon}
</span>
) : null}
<span className="w-full min-w-0 truncate text-left">
{item.label}
</span>
</TabsTrigger>
))}
</TabsList>
<div className={cn('flex-1', contentClassName)}>
{items.map((item) => (
<TabsContent key={item.value} value={item.value} className="mt-0">
{item.content}
</TabsContent>
))}
</div>
</Tabs>
);
}
export default VerticalNavigation;

View file

@ -94,12 +94,12 @@ export function SearchHistoryDialog() {
};
const handleDelete = (taskId: string) => {
// TODO: Implement delete functionality similar to HistorySidebar
// TODO: Implement delete functionality
console.log('Delete task:', taskId);
};
const handleShare = (taskId: string) => {
// TODO: Implement share functionality similar to HistorySidebar
// TODO: Implement share functionality
console.log('Share task:', taskId);
};

View file

@ -16,6 +16,7 @@ import larkIcon from '@/assets/icon/lark.png';
import telegramIcon from '@/assets/icon/telegram.svg';
import whatsappIcon from '@/assets/icon/whatsapp.svg';
import { isDesktop } from '@/client/platform';
import ContentHeader from '@/components/Layout/ContentHeader';
import { SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS } from '@/components/Session/SidePanel/layout';
import { Button } from '@/components/ui/button';
import {
@ -650,11 +651,11 @@ export function WorkspaceDispatch() {
return (
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden">
{/* Header */}
<div className="border-b-1 box-border flex h-[45.5px] w-full shrink-0 items-center gap-2 border-x-0 border-t-0 border-solid border-ds-border-neutral-subtle-default px-3">
<span className="text-body-md font-bold text-ds-text-neutral-muted-default">
{t('layout.workspace-work-with-title', { defaultValue: 'Work with' })}
</span>
</div>
<ContentHeader
title={t('layout.workspace-work-with-title', {
defaultValue: 'Work with',
})}
/>
{/* Body */}
<div className="relative flex min-h-0 w-full flex-1 overflow-hidden">

View file

@ -60,12 +60,12 @@ export class ErrorBoundary extends Component<Props, State> {
// Default error UI
return (
<div className="bg-ds-bg-neutral-subtle-default p-4 flex h-screen w-full items-center justify-center">
<div className="max-w-md gap-6 rounded-xl border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default p-8 shadow-lg flex flex-col items-center border border-solid text-center">
<div className="bg-warning/10 h-16 w-16 flex items-center justify-center rounded-full">
<div className="flex h-screen w-full items-center justify-center bg-ds-bg-neutral-subtle-default p-4">
<div className="flex max-w-md flex-col items-center gap-6 rounded-xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default p-8 text-center shadow-lg">
<div className="bg-warning/10 flex h-16 w-16 items-center justify-center rounded-full">
<AlertTriangle className="text-warning h-8 w-8" />
</div>
<div className="gap-2 flex flex-col">
<div className="flex flex-col gap-2">
<h1 className="text-xl font-bold text-ds-text-neutral-default-default">
Something went wrong
</h1>
@ -74,16 +74,16 @@ export class ErrorBoundary extends Component<Props, State> {
</p>
</div>
{this.state.error && (
<div className="rounded-lg bg-ds-bg-neutral-strong-default p-4 w-full text-left">
<div className="w-full rounded-lg bg-ds-bg-neutral-strong-default p-4 text-left">
<p className="mb-2 text-xs font-medium text-ds-text-neutral-muted-default">
Error details:
</p>
<p className="max-h-32 font-mono text-xs text-ds-text-neutral-default-default overflow-y-auto">
<p className="max-h-32 overflow-y-auto font-mono text-xs text-ds-text-neutral-default-default">
{this.state.error.toString()}
</p>
</div>
)}
<div className="gap-3 flex">
<div className="flex gap-3">
<Button
variant="outline"
size="md"

View file

@ -14,6 +14,10 @@
import cursorIcon from '@/assets/icon/cursor.svg';
import vsCodeIcon from '@/assets/icon/vs-code.svg';
import {
CONTENT_HEADER_BORDER_CLASS,
CONTENT_HEADER_CLASS,
} from '@/components/Layout/ContentHeader';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@ -77,6 +81,7 @@ import {
injectPreviewContentSecurityPolicy,
} from '@/lib/htmlSanitization';
import { isLocalWorkspaceSpace } from '@/lib/spaceLabel';
import { cn } from '@/lib/utils';
import {
formatFileSize,
type FilePreviewPayload,
@ -1528,7 +1533,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
return (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* header */}
<div className="border-b-1 flex w-full shrink-0 items-center gap-2 border-x-0 border-t-0 border-solid border-ds-border-neutral-subtle-default p-2">
<div className={cn(CONTENT_HEADER_CLASS, CONTENT_HEADER_BORDER_CLASS)}>
<div className="flex min-w-0 max-w-[min(20rem,45%)] items-center">
<Button
type="button"

View file

@ -1,54 +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 type { InputSize } from '@/components/ui/input';
import { Input } from '@/components/ui/input';
import { Search } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
interface SearchInputProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
size?: InputSize;
leadingIcon?: ReactNode;
}
export default function SearchInput({
value,
onChange,
placeholder,
size = 'sm',
leadingIcon,
}: SearchInputProps) {
const { t } = useTranslation();
return (
<div className="relative w-full">
<Input
size={size}
value={value}
className="w-full rounded-full"
onChange={onChange}
placeholder={placeholder ?? t('layout.search')}
leadingIcon={
leadingIcon ?? (
<Search className="h-5 w-5 text-ds-icon-neutral-muted-default" />
)
}
/>
</div>
);
}

View file

@ -1,783 +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 { proxyFetchDelete } from '@/api/http';
import tokenDarkIcon from '@/assets/custom/token-dark.svg';
import tokenLightIcon from '@/assets/custom/token-light.svg';
import { formatTokenCount } from '@/components/ChatBox/MessageItem/TokenUtils';
import { Button } from '@/components/ui/button';
import { useHost } from '@/host';
import {
buildTaskQuestionsById,
computeProjectFreshnessAnchor,
loadProjectFromHistory,
} from '@/lib';
import { share } from '@/lib/share';
import { fetchGroupedHistoryTasks } from '@/service/historyApi';
import { getAuthStore, useAuthStore } from '@/store/authStore';
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useSpaceStore } from '@/store/spaceStore';
import { ChatTaskStatus } from '@/types/constants';
import { HistoryTask, ProjectGroup } from '@/types/history';
import { AnimatePresence, motion } from 'framer-motion';
import {
Ellipsis,
FolderCheck,
FolderClock,
ListChecks,
Plus,
Share,
Trash2,
Zap,
} from 'lucide-react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import AlertDialog from '../ui/alertDialog';
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from '../ui/popover';
import { Tag } from '../ui/tag';
import { TooltipSimple } from '../ui/tooltip';
import SearchInput from './SearchInput';
const compactCountFormatter = new Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 1,
minimumFractionDigits: 0,
});
const formatCompactCount = (value?: number) =>
compactCountFormatter.format(value || 0);
const toFiniteNumber = (value: unknown): number | null => {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string') {
const parsed = Number(value.replace(/,/g, '').trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
const resolveProjectTokenCount = (
project: Pick<ProjectGroup, 'total_tokens' | 'tasks'>
): number => {
const direct = toFiniteNumber(project.total_tokens as unknown);
if (direct !== null) {
return direct;
}
return (project.tasks || []).reduce(
(sum, task) =>
sum + (toFiniteNumber((task as { tokens?: unknown }).tokens) ?? 0),
0
);
};
export default function HistorySidebar() {
const { t } = useTranslation();
const host = useHost();
const ipcRenderer = host?.ipcRenderer;
const { appearance } = useAuthStore();
const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon;
const { isOpen, close } = useSidebarStore();
const navigate = useNavigate();
const projectStore = useProjectRuntimeStore();
const activeChatStore = projectStore.getActiveChatStore();
// History needs a refresh only when a turn reaches its END boundary. The
// old useChatStoreAdapter subscription rebuilt the full chat snapshot for
// every SSE event, which re-ran the grouped-history request continuously
// even while this sidebar was closed.
const subscribeToHistoryRevision = useCallback(
(listener: () => void) =>
activeChatStore?.subscribe(listener) ?? (() => undefined),
[activeChatStore]
);
const getHistoryRevision = useCallback(
() => activeChatStore?.getState().updateCount ?? 0,
[activeChatStore]
);
const historyRevision = useSyncExternalStore(
subscribeToHistoryRevision,
getHistoryRevision,
getHistoryRevision
);
const chatStore = activeChatStore?.getState() ?? null;
const [searchValue, setSearchValue] = useState('');
const [_historyOpen, setHistoryOpen] = useState(true);
const [historyTasks, setHistoryTasks] = useState<ProjectGroup[]>([]);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [anchorStyle, setAnchorStyle] = useState<{
left: number;
top: number;
} | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [currentProjectId, setCurrentProjectId] = useState('');
const activeSpaceId = useSpaceStore((s) => s.activeSpaceId);
useEffect(() => {
if (!isOpen || !activeChatStore) return;
void fetchGroupedHistoryTasks(setHistoryTasks, {
spaceId: activeSpaceId,
});
}, [activeChatStore, activeSpaceId, historyRevision, isOpen]);
// Group ongoing tasks by project
const ongoingProjects = useMemo(() => {
if (!chatStore) return [];
const projectMap = new Map<string, any>();
// Iterate through all projects
const allProjects = projectStore.getAllProjects(activeSpaceId ?? undefined);
allProjects.forEach((project) => {
// Get all chat stores for this project
const chatStores = projectStore.getAllChatStores(project.id);
let hasOngoingTasks = false;
let totalTokens = 0;
let taskCount = 0;
let lastPrompt = '';
// Check all chat stores for ongoing tasks
chatStores.forEach(({ chatStore: cs }) => {
const csState = cs.getState();
Object.keys(csState.tasks || {}).forEach((taskId) => {
const task = csState.tasks[taskId];
// Only include ongoing tasks
if (task.status !== ChatTaskStatus.FINISHED && !task.type) {
hasOngoingTasks = true;
taskCount++;
if (task.tokens) {
totalTokens += task.tokens;
}
if (!lastPrompt && task.messages?.[0]?.content) {
lastPrompt = task.messages[0].content;
}
}
});
});
// Only add project if it has ongoing tasks
if (hasOngoingTasks) {
projectMap.set(project.id, {
project_id: project.id,
project_name: project.name,
tasks: [],
task_count: taskCount,
total_tokens: totalTokens,
total_triggers:
historyTasks.find((item) => item.project_id === project.id)
?.total_triggers || 0,
space_id: project.spaceId,
last_prompt: lastPrompt,
isOngoing: true,
});
}
});
return Array.from(projectMap.values());
}, [projectStore, chatStore, historyTasks, activeSpaceId]);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value) {
setHistoryOpen(true);
}
setSearchValue(e.target.value);
};
const createChat = () => {
close();
//Create a new project
//Handles refocusing id & non duplicate logic internally
projectStore.createProject('new project');
navigate('/');
};
const handleLoadProject = async (
projectId: string,
question: string,
historyId: string
) => {
close();
const project = historyTasks.find((p) => p.project_id === projectId);
const taskIdsList = project?.tasks.map(
(task: HistoryTask) => task.task_id
) || [projectId];
// If no tasks to replay, create an empty project
if (!taskIdsList || taskIdsList.length === 0) {
projectStore.createProject(
project?.project_name || 'Project',
'Project with triggers but no tasks',
projectId
);
navigate('/');
return;
}
await loadProjectFromHistory(
projectStore,
navigate,
projectId,
question,
historyId,
taskIdsList,
project?.project_name,
project?.space_id,
buildTaskQuestionsById(project?.tasks),
computeProjectFreshnessAnchor(project)
);
};
const handleDelete = (id: string) => {
console.log('Delete task:', id);
setCurrentProjectId(id);
setDeleteModalOpen(true);
};
// Deletes whole Project
const confirmDelete = async () => {
await deleteWholeProject(currentProjectId);
setHistoryTasks((list) =>
list.filter((item) => item.project_id !== currentProjectId)
);
setCurrentProjectId('');
setDeleteModalOpen(false);
};
const _deleteHistoryTask = async (
project: ProjectGroup,
historyId: string
) => {
try {
const res = await proxyFetchDelete(`/api/v1/chat/history/${historyId}`);
console.log(res);
// also delete local files for this task if available (via Electron IPC)
const { email } = getAuthStore();
const history = project.tasks.find(
(item: HistoryTask) => String(item.id) === historyId
);
if (history?.task_id && ipcRenderer) {
try {
//TODO(file): rename endpoint to use project_id
//TODO(history): make sure to sync to projectId when updating endpoint
await ipcRenderer.invoke(
'delete-task-files',
email,
history.task_id,
history.project_id ?? undefined
);
} catch (error) {
console.warn('Local file cleanup failed:', error);
}
}
} catch (error) {
console.error('Failed to delete history task:', error);
}
};
// Deletes whole project by using the tasks from historyTasks state
const deleteWholeProject = async (projectId: string) => {
try {
// Find the project in our existing data
const targetProject = historyTasks.find(
(project) => project.project_id === projectId
);
if (targetProject && targetProject.tasks) {
console.log(
`Found project ${projectId} with ${targetProject.tasks.length} tasks to delete`
);
// Delete each task one by one
for (const history of targetProject.tasks) {
console.log(
`Deleting task: ${history.task_id} (history ID: ${history.id})`
);
try {
const deleteRes = await proxyFetchDelete(
`/api/v1/chat/history/${history.id}`
);
console.log(
`Successfully deleted task ${history.task_id}:`,
deleteRes
);
// Also delete local files for this task if available (via Electron IPC)
const { email } = getAuthStore();
if (history.task_id && ipcRenderer) {
try {
await ipcRenderer.invoke(
'delete-task-files',
email,
history.task_id,
history.project_id ?? undefined
);
console.log(
`Successfully cleaned up local files for task ${history.task_id}`
);
} catch (error) {
console.warn(
`Local file cleanup failed for task ${history.task_id}:`,
error
);
}
}
} catch (error) {
console.error(`Failed to delete task ${history.task_id}:`, error);
}
}
projectStore.removeProject(projectId);
console.log(`Completed deletion of project ${projectId}`);
} else {
console.warn(`Project ${projectId} not found or has no tasks`);
}
} catch (error) {
console.error('Failed to delete whole project:', error);
}
};
const handleShare = async (taskId: string) => {
close();
share(taskId);
};
const handleSetActive = (
projectId: string,
question: string,
historyId: string
) => {
const project = projectStore.getProjectById(projectId);
//If project exists
if (project) {
// if there is record, show result
projectStore.setHistoryId(projectId, historyId);
projectStore.setActiveProject(projectId);
navigate(`/`);
close();
} else {
// if there is no record, load final state (no replay animation)
handleLoadProject(projectId, question, historyId);
}
};
useLayoutEffect(() => {
const PANEL_WIDTH = 360;
const GAP = 8;
const MARGIN = 8;
const updateAnchor = () => {
const sidebarTitleEl = document.getElementById(
'sidebar-active-task-title-btn'
);
const topBarTitleEl = document.getElementById('active-task-title-btn');
let anchorEl: HTMLElement | null = null;
if (sidebarTitleEl) {
const r = sidebarTitleEl.getBoundingClientRect();
if (r.width > 0 && r.height > 0) {
anchorEl = sidebarTitleEl;
}
}
if (!anchorEl && topBarTitleEl) {
anchorEl = topBarTitleEl;
}
if (anchorEl) {
const rect = anchorEl.getBoundingClientRect();
let left = rect.left;
if (left + PANEL_WIDTH > window.innerWidth - MARGIN) {
left = window.innerWidth - MARGIN - PANEL_WIDTH;
}
if (left < MARGIN) {
left = MARGIN;
}
const top = rect.bottom + GAP;
setAnchorStyle({ left, top });
} else {
setAnchorStyle(null);
}
};
if (isOpen) {
updateAnchor();
window.addEventListener('resize', updateAnchor);
}
return () => {
window.removeEventListener('resize', updateAnchor);
};
}, [isOpen]);
if (!chatStore) {
return <div>Loading...</div>;
}
return (
<AnimatePresence>
{isOpen && anchorStyle && (
<>
{/* alert dialog */}
<AlertDialog
isOpen={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
onConfirm={confirmDelete}
title={t('layout.delete-task')}
message={t('layout.are-you-sure-you-want-to-delete')}
confirmText={t('layout.delete')}
cancelText={t('layout.cancel')}
/>
{/* background cover */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-40 bg-transparent"
onClick={close}
/>
{/* History panel below project title (sidebar when expanded, else TopBar) */}
<motion.div
initial={false}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -8, opacity: 0 }}
transition={{ type: 'spring', damping: 22, stiffness: 220 }}
onMouseLeave={close}
ref={panelRef}
className="fixed z-50 flex max-h-[80vh] w-[360px] flex-col overflow-hidden rounded-xl bg-ds-bg-neutral-subtle-default p-2 shadow-perfect"
style={{
left: anchorStyle.left,
top: anchorStyle.top,
}}
>
<div className="flex items-center justify-between py-2 pl-2">
{/* Search */}
<SearchInput value={searchValue} onChange={handleSearch} />
<Button variant="ghost" size="md" onClick={createChat}>
<Plus className="duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] h-8 w-8 text-ds-icon-neutral-muted-default transition-colors group-hover:text-ds-icon-neutral-default-default" />
</Button>
</div>
<div className="scrollbar-hide mt-2 min-h-0 flex-1 overflow-y-auto">
<div className="flex flex-col gap-3 px-sm">
{/* Ongoing Projects */}
{ongoingProjects
.filter(
(project) =>
project.last_prompt
?.toLowerCase()
.includes(searchValue.toLowerCase()) ||
project.project_name
?.toLowerCase()
.includes(searchValue.toLowerCase())
)
.map((project) => (
<div
key={project.project_id}
onClick={() => {
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"
>
<FolderClock className="h-5 w-5 flex-shrink-0 text-ds-icon-status-running-default-default" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<TooltipSimple
align="start"
className="pointer-events-auto w-[300px] select-text text-wrap break-words bg-ds-bg-neutral-default-default p-2 text-label-xs shadow-perfect"
content={
<div>
{project.project_name || t('layout.new-project')}
</div>
}
>
<span className="block overflow-hidden text-ellipsis whitespace-nowrap text-body-sm font-semibold text-ds-text-neutral-default-default">
{project.project_name || t('layout.new-project')}
</span>
</TooltipSimple>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<TooltipSimple content={t('chat.token')}>
<Tag
variant="primary"
tone="information"
emphasis="default"
size="xs"
className="gap-1.5"
>
<img src={tokenIcon} alt="" className="h-3 w-3" />
<span className="text-label-xs">
{formatTokenCount(
resolveProjectTokenCount(project)
)}
</span>
</Tag>
</TooltipSimple>
<TooltipSimple content={t('layout.tasks')}>
<Tag
variant="primary"
tone="default"
emphasis="default"
size="xs"
className="gap-1.5"
>
<ListChecks className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.task_count)}
</span>
</Tag>
</TooltipSimple>
<TooltipSimple content="Triggers">
<Tag
variant="primary"
tone="warning"
emphasis="default"
size="xs"
className="gap-1.5"
>
<Zap className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.total_triggers)}
</span>
</Tag>
</TooltipSimple>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
onClick={(e) => e.stopPropagation()}
variant="ghost"
className="flex-shrink-0"
>
<Ellipsis
size={16}
className="text-ds-text-neutral-default-default"
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[98px] rounded-[12px] border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default p-sm">
<div className="space-y-1">
<PopoverClose asChild>
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={(e) => {
e.stopPropagation();
handleShare(project.project_id);
}}
>
<Share size={16} />
{t('layout.share')}
</Button>
</PopoverClose>
<PopoverClose asChild>
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={(e) => {
e.stopPropagation();
handleDelete(project.project_id);
}}
>
<Trash2
size={16}
className="text-ds-icon-neutral-default-default group-hover:text-ds-icon-status-error-default-default"
/>
{t('layout.delete')}
</Button>
</PopoverClose>
</div>
</PopoverContent>
</Popover>
</div>
))}
{/* History Projects */}
{historyTasks
.filter(
(project) =>
project.last_prompt
?.toLowerCase()
.includes(searchValue.toLowerCase()) ||
project.project_name
?.toLowerCase()
.includes(searchValue.toLowerCase())
)
.map((project) => (
<div
onClick={() => {
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"
>
<FolderCheck className="h-5 w-5 flex-shrink-0 text-ds-icon-neutral-subtle-default" />
<div className="min-w-0 flex-1">
<TooltipSimple
align="start"
className="pointer-events-auto w-[300px] select-text text-wrap break-words bg-ds-bg-neutral-default-default p-2 text-label-xs shadow-perfect"
content={
<div>
{project.last_prompt ||
project.project_name ||
t('layout.new-project')}
</div>
}
>
<span className="block overflow-hidden text-ellipsis whitespace-nowrap text-body-sm font-semibold text-ds-text-neutral-default-default">
{project.last_prompt ||
project.project_name ||
t('layout.new-project')}
</span>
</TooltipSimple>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<TooltipSimple content={t('chat.token')}>
<Tag
variant="primary"
tone="information"
emphasis="default"
size="xs"
className="gap-1.5"
>
<img src={tokenIcon} alt="" className="h-3 w-3" />
<span className="text-label-xs">
{formatTokenCount(
resolveProjectTokenCount(project)
)}
</span>
</Tag>
</TooltipSimple>
<TooltipSimple content={t('layout.tasks')}>
<Tag
variant="primary"
tone="default"
emphasis="default"
size="xs"
className="gap-1.5"
>
<ListChecks className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.task_count)}
</span>
</Tag>
</TooltipSimple>
<TooltipSimple content="Triggers">
<Tag
variant="primary"
tone="warning"
emphasis="default"
size="xs"
className="gap-1.5"
>
<Zap className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.total_triggers)}
</span>
</Tag>
</TooltipSimple>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
onClick={(e) => e.stopPropagation()}
variant="ghost"
className="flex-shrink-0"
>
<Ellipsis
size={16}
className="text-ds-text-neutral-default-default"
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[98px] rounded-[12px] border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default p-sm">
<div className="space-y-1">
<PopoverClose asChild>
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={(e) => {
e.stopPropagation();
handleShare(project.project_id);
}}
>
<Share size={16} />
{t('layout.share')}
</Button>
</PopoverClose>
<PopoverClose asChild>
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={(e) => {
e.stopPropagation();
handleDelete(project.project_id);
}}
>
<Trash2
size={16}
className="text-ds-icon-neutral-default-default group-hover:text-ds-icon-status-error-default-default"
/>
{t('layout.delete')}
</Button>
</PopoverClose>
</div>
</PopoverContent>
</Popover>
</div>
))}
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View file

@ -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<HomeSection, string> = {
spaces: 'layout.search-spaces',
projects: 'layout.search-projects',
tasks: 'layout.search-tasks',
triggers: 'layout.search-triggers',
};
const SECTION_TITLE_KEYS: Record<HomeSection, string> = {
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 (
<ContentHeader
title={capitalizeLabel(t(SECTION_TITLE_KEYS[activeSection]))}
actions={
<>
<SearchInput
variant="icon"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t(SEARCH_PLACEHOLDER_KEYS[activeSection])}
/>
<TooltipSimple
content={t('layout.home-filter-disabled-tooltip')}
side="bottom"
>
<span className="inline-flex">
<Button
type="button"
variant="ghost"
buttonContent="icon-only"
size="sm"
className="rounded-lg"
disabled
aria-label={t('layout.home-filter-disabled-tooltip')}
>
<Filter className="h-4 w-4" />
</Button>
</span>
</TooltipSimple>
<DropdownMenu>
<TooltipSimple content={sortLabel} variant="instant" side="bottom">
<span className="inline-flex">
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
buttonContent="icon-only"
size="sm"
className="rounded-lg"
aria-label={sortLabel}
>
<ArrowUpDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</span>
</TooltipSimple>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleSortChange('created')}>
{t('layout.home-sort-created')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSortChange('updated')}>
{t('layout.home-sort-updated')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSortChange('name')}>
{t('layout.home-sort-name')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tabs
value={viewMode}
onValueChange={(value) =>
setViewMode(value as 'grid' | 'list' | 'board')
}
>
<TabsList appearance="default">
<TabsTrigger value="grid" aria-label={t('dashboard.grid')}>
<TooltipSimple
content={t('dashboard.grid')}
variant="instant"
side="bottom"
>
<div className="inline-flex h-5 w-5 items-center justify-center">
<LayoutGrid size={16} />
</div>
</TooltipSimple>
</TabsTrigger>
<TabsTrigger value="list" aria-label={t('dashboard.list')}>
<TooltipSimple
content={t('dashboard.list')}
variant="instant"
side="bottom"
>
<div className="inline-flex h-5 w-5 items-center justify-center">
<List size={16} />
</div>
</TooltipSimple>
</TabsTrigger>
<TabsTrigger value="board" aria-label={t('dashboard.board')}>
<TooltipSimple
content={t('dashboard.board')}
variant="instant"
side="bottom"
>
<div className="inline-flex h-5 w-5 items-center justify-center">
<Columns2 size={16} />
</div>
</TooltipSimple>
</TabsTrigger>
</TabsList>
</Tabs>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="primary"
size="sm"
buttonContent="text"
className="rounded-lg"
>
{t('layout.spaces-new-space')}
<ChevronDown className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 p-1">
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={(event) => {
event.preventDefault();
void handleCreateBlankSpace();
}}
>
<PlusCircle className="h-4 w-4 shrink-0" aria-hidden />
{t('layout.workspace-start-from-scratch')}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={(event) => {
event.preventDefault();
void handleCreateSpaceFromFolder();
}}
>
<FolderOpen className="h-4 w-4 shrink-0" aria-hidden />
{t('layout.workspace-use-local-folder')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
);
}

View file

@ -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 (
<div className="w-full min-w-0 flex-1 pb-12 pl-3 pr-2">
{section === 'spaces' && <Spaces />}
{section === 'projects' && <Projects />}
{section === 'tasks' && <Tasks />}
{section === 'triggers' && <Triggers />}
</div>
);
}

View file

@ -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<HomeSection, LucideIcon> = {
spaces: Folder,
projects: MessageCircle,
tasks: ListChecks,
triggers: Zap,
};
const SECTION_LABEL_KEYS: Record<HomeSection, string> = {
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 (
<div className="flex shrink-0 flex-col items-center rounded-xl bg-ds-bg-neutral-muted-default px-1.5">
<span className="!text-label-xs font-medium tabular-nums text-ds-text-neutral-muted-default">
{count}
</span>
</div>
);
}
/** 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 (
<SidebarShell className={className} ariaLabel={t('layout.home')}>
<SidebarBrandHeader />
<SidebarSection>
<SidebarNavGroup>
{items.map(({ id, label, icon: Icon, count }) => {
const active = activeSection === id;
return (
<NavTab
key={id}
active={active}
onClick={() => setSection(id)}
leading={<Icon className="h-4 w-4 shrink-0" aria-hidden />}
label={label}
trailing={<HomeSidebarCountBadge count={count} />}
ariaLabel={label}
ariaCurrentPage={active}
/>
);
})}
</SidebarNavGroup>
</SidebarSection>
</SidebarShell>
);
}

View file

@ -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 (
<div className="min-w-0 flex w-full flex-col">
<div className="flex w-full min-w-0 flex-col">
<div className="pb-12 text-body-sm text-ds-text-neutral-muted-default">
{t('layout.loading')}
</div>
@ -167,17 +167,17 @@ export default function Projects() {
}
return (
<div className="min-w-0 flex w-full flex-col">
<div className="mb-12 min-w-0 w-full">
<div className="flex w-full min-w-0 flex-col">
<div className="mb-12 w-full min-w-0">
{projects.length === 0 ? (
<div className="p-8 flex flex-col items-center justify-center text-center">
<FolderOpen className="mb-4 h-12 w-12 text-ds-icon-neutral-muted-default" />
<div className="flex flex-col items-center justify-center p-8 text-center">
<MessageCircle className="mb-4 h-12 w-12 text-ds-icon-neutral-muted-default" />
<div className="text-sm text-ds-text-neutral-muted-default">
{t('dashboard.no-projects-found')}
</div>
</div>
) : filteredProjects.length === 0 ? (
<div className="p-8 flex flex-col items-center justify-center text-center">
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="text-sm text-ds-text-neutral-muted-default">
{t('layout.search-no-results')}
</div>

View file

@ -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() {
<div className="mb-12 w-full min-w-0">
{spaceSections.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 text-center">
<FolderKanban className="mb-4 h-12 w-12 text-ds-icon-neutral-muted-default" />
<Folder className="mb-4 h-12 w-12 text-ds-icon-neutral-muted-default" />
<div className="text-sm text-ds-text-neutral-muted-default">
{t('layout.spaces-hub-empty-title')}
</div>

View file

@ -155,7 +155,7 @@ export default function Tasks() {
if (projectsLoading) {
return (
<div className="min-w-0 flex w-full flex-col">
<div className="flex w-full min-w-0 flex-col">
<div className="pb-12 text-body-sm text-ds-text-neutral-muted-default">
{t('layout.loading')}
</div>
@ -164,17 +164,17 @@ export default function Tasks() {
}
return (
<div className="min-w-0 flex w-full flex-col">
<div className="mb-12 min-w-0 w-full">
<div className="flex w-full min-w-0 flex-col">
<div className="mb-12 w-full min-w-0">
{tasks.length === 0 ? (
<div className="p-8 flex flex-col items-center justify-center text-center">
<div className="flex flex-col items-center justify-center p-8 text-center">
<ListChecks className="mb-4 h-12 w-12 text-ds-icon-neutral-muted-default" />
<div className="text-sm text-ds-text-neutral-muted-default">
{t('dashboard.no-tasks-found') || t('layout.total-tasks')}
</div>
</div>
) : filteredTasks.length === 0 ? (
<div className="p-8 flex flex-col items-center justify-center text-center">
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="text-sm text-ds-text-neutral-muted-default">
{t('layout.search-no-results')}
</div>

View file

@ -183,7 +183,7 @@ export default function Triggers() {
if (triggersLoading) {
return (
<div className="min-w-0 flex w-full flex-col">
<div className="flex w-full min-w-0 flex-col">
<div className="pb-12 text-body-sm text-ds-text-neutral-muted-default">
{t('layout.loading')}
</div>
@ -192,17 +192,17 @@ export default function Triggers() {
}
return (
<div className="min-w-0 flex w-full flex-col">
<div className="mb-12 min-w-0 w-full">
<div className="flex w-full min-w-0 flex-col">
<div className="mb-12 w-full min-w-0">
{triggers.length === 0 ? (
<div className="p-8 flex flex-col items-center justify-center text-center">
<div className="flex flex-col items-center justify-center p-8 text-center">
<Zap className="mb-4 h-12 w-12 text-ds-icon-neutral-muted-default" />
<div className="text-sm text-ds-text-neutral-muted-default">
{t('triggers.no-triggers') || t('layout.triggers')}
</div>
</div>
) : filteredTriggers.length === 0 ? (
<div className="p-8 flex flex-col items-center justify-center text-center">
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="text-sm text-ds-text-neutral-muted-default">
{t('layout.search-no-results')}
</div>

View file

@ -58,7 +58,7 @@ export default function HomeHubBoard({
return (
<div
className={cn(
'gap-2 lg:grid-cols-3 grid min-h-[420px] grid-cols-1',
'grid min-h-[420px] grid-cols-1 gap-2 lg:grid-cols-3',
className
)}
>
@ -70,21 +70,20 @@ export default function HomeHubBoard({
<section
key={columnId}
className={cn(
'min-w-0 rounded-2xl px-3 pb-3 !bg-ds-bg-neutral-default-default flex min-h-[320px] flex-col',
'flex min-h-[320px] min-w-0 flex-col rounded-2xl !bg-ds-bg-neutral-default-default px-3 pb-3',
styles.column
)}
>
<header
className={cn(
'-mx-3 mb-3 gap-2 px-3 pb-3 pt-3 rounded-t-2xl sticky z-[9] flex items-center',
'top-[calc(var(--home-hub-history-tabs-offset,49px)+var(--home-hub-toolbar-sticky-height,5.25rem))]',
'sticky top-0 z-[9] -mx-3 mb-3 flex items-center gap-2 rounded-t-2xl px-3 pb-3 pt-3',
'!bg-ds-bg-neutral-default-default',
styles.column
)}
>
<span
className={cn(
'px-2.5 py-1 !text-body-sm !font-semibold rounded-full',
'rounded-full px-2.5 py-1 !text-body-sm !font-semibold',
styles.pill
)}
>
@ -92,7 +91,7 @@ export default function HomeHubBoard({
</span>
<span
className={cn(
'!text-label-sm !font-medium text-ds-text-neutral-muted-default tabular-nums',
'!text-label-sm !font-medium tabular-nums text-ds-text-neutral-muted-default',
styles.count
)}
>
@ -100,11 +99,11 @@ export default function HomeHubBoard({
</span>
</header>
<div className="gap-3 flex w-full flex-col">
<div className="flex w-full flex-col gap-3">
{items.length > 0 ? (
items
) : (
<div className="rounded-2xl border-ds-border-neutral-muted-default px-3 py-8 !text-label-sm text-ds-text-neutral-muted-default border border-dashed text-center">
<div className="rounded-2xl border border-dashed border-ds-border-neutral-muted-default px-3 py-8 text-center !text-label-sm text-ds-text-neutral-muted-default">
{t('layout.home-board-column-empty')}
</div>
)}

View file

@ -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' ? (
<HomeHubItemBody
title={title}
nameIcon={<FolderKanban className="h-4 w-4" />}
nameIcon={<Folder className="h-4 w-4" />}
listCells={[
{ id: 'type', content: spaceKindLabel },
{
@ -325,14 +325,14 @@ function ProjectItemContent({
className="relative"
>
{loading ? (
<div className="inset-0 absolute z-10 flex items-center justify-center">
<div className="absolute inset-0 z-10 flex items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-ds-icon-neutral-default-default" />
</div>
) : null}
{layout === 'list' ? (
<HomeHubItemBody
title={title}
nameIcon={<Folder className="h-4 w-4" />}
nameIcon={<MessageCircle className="h-4 w-4" />}
listCells={[
{ id: 'space', content: spaceLabel || '—' },
{
@ -417,7 +417,7 @@ function TaskItemContent({
className={loading ? 'relative' : undefined}
>
{loading ? (
<div className="inset-0 absolute z-10 flex items-center justify-center">
<div className="absolute inset-0 z-10 flex items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-ds-icon-neutral-default-default" />
</div>
) : null}

View file

@ -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 (
<HomeHubHubCardBody
title={title}
icon={<Folder />}
icon={<MessageCircle />}
menuItems={menuItems}
statItems={statItems}
updatedAt={updatedAt}
@ -587,7 +593,7 @@ export function HomeHubProjectBoardCardBody({
return (
<HomeHubBoardCardBody
title={title}
icon={<Folder />}
icon={<MessageCircle />}
menuItems={menuItems}
statRows={[
{ label: t('layout.tasks'), value: taskCount },

View file

@ -70,13 +70,13 @@ export default function HomeHubListTable({
const gridClass = HOME_HUB_LIST_GRID_CLASS[kind];
return (
<div className={cn('min-w-0 w-full', className)}>
<div className={cn('gap-x-4 px-3 py-2.5 grid items-center', gridClass)}>
<div className={cn('w-full min-w-0', className)}>
<div className={cn('grid items-center gap-x-4 px-3 py-2.5', gridClass)}>
{columns.map((column) => (
<span
key={column.id}
className={cn(
'!text-label-sm font-normal text-ds-text-neutral-muted-default truncate leading-none',
'truncate !text-label-sm font-normal leading-none text-ds-text-neutral-muted-default',
column.align === 'right' ? 'text-right' : 'text-left'
)}
>
@ -84,7 +84,7 @@ export default function HomeHubListTable({
</span>
))}
</div>
<div className="gap-1 flex flex-col">{children}</div>
<div className="flex flex-col gap-1">{children}</div>
</div>
);
}

View file

@ -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;

View file

@ -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 };
}

View file

@ -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<void>) | 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<HomeViewMode>(
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&section=${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')}
/>
<div className="flex w-full min-w-0 flex-1 flex-col [--home-hub-history-tabs-offset:49px]">
<HomeHubToolbar
activeTab={activeTab}
onTabChange={handleTabChange}
menuItems={menuItems}
/>
<div className="w-full min-w-0 flex-1">
{activeTab === 'spaces' && <Spaces />}
{activeTab === 'projects' && <Projects />}
{activeTab === 'tasks' && <Tasks />}
{activeTab === 'triggers' && <Triggers />}
</div>
</div>
{children}
</HomeHubProvider>
);
}

View file

@ -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 `<ContentHeader/>` 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<HTMLDivElement>(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 (
<div
className={cn(
'flex h-full min-h-0 flex-row overflow-hidden pt-10',
className
)}
>
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-row overflow-hidden rounded-2xl bg-ds-bg-neutral-default-default">
<motion.div
ref={sidebarRailRef}
className="h-full min-h-0 shrink-0 overflow-hidden"
initial={false}
animate={{
width: sidebarHidden
? 0
: APP_SHELL_SIDEBAR_WIDTH_PX + SIDEBAR_CONTENT_GAP_PX,
}}
transition={SIDEBAR_FOLD_SPRING}
aria-hidden={sidebarHidden}
style={{ pointerEvents: sidebarHidden ? 'none' : undefined }}
>
{/* Fixed inner width so rail content doesn't reflow mid-animation. */}
<div
className="h-full min-h-0"
style={{ width: APP_SHELL_SIDEBAR_WIDTH_PX }}
>
{sidebar}
</div>
</motion.div>
<motion.div
layout
transition={{ layout: SIDEBAR_FOLD_SPRING }}
className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
>
{contentSurface ? (
<div
className={cn(APP_SHELL_CONTENT_SURFACE_CLASS, contentClassName)}
>
{children}
</div>
) : (
children
)}
</motion.div>
</div>
{overlay}
</div>
);
}

View file

@ -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 (
<TooltipSimple content={reconnectLabel} side="top" sideOffset={8}>
<button
type="button"
className={cn(
'no-drag flex h-8 w-8 shrink-0 items-center justify-center rounded-xl text-ds-icon-neutral-muted-default outline-none transition-colors hover:bg-ds-bg-neutral-strong-default',
'focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-neutral-subtle-default'
)}
aria-label={reconnectLabel}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onReconnect();
}}
>
<RefreshCw
className={cn(
'h-3.5 w-3.5',
wsConnectionStatus === 'connecting' && 'animate-spin'
)}
aria-hidden
/>
</button>
</TooltipSimple>
);
}
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 }}
>
<span className={WORKSPACE_TAB_LABEL_CLASS}>{label}</span>
<span
className={cn(
SIDEBAR_TAB_LABEL_CLASS,
active
? 'text-ds-text-neutral-muted-default'
: 'text-ds-text-neutral-subtle-default'
)}
>
{label}
</span>
{trailing}
{showNotificationDot && (
<span
@ -188,7 +158,7 @@ function tabMainInner({
}
/**
* Project page sidebar rail tab: leading icon, label, optional trailing chip, optional dot, optional split suffix.
* Sidebar rail tab: leading icon, label, optional trailing chip, optional dot, optional split suffix.
* Add new tabs by composing `leading` / `trailing` / `suffix`; use `layout="split"` when the row needs a separate end control.
*/
export function NavTab({
@ -205,6 +175,7 @@ export function NavTab({
tooltip,
tooltipEnabledWhenCollapsed = false,
ariaLabel,
ariaDescribedBy,
ariaCurrentPage,
className,
mainButtonClassName,
@ -212,8 +183,11 @@ export function NavTab({
endAction,
endActionMaxWidthClass,
disabled = false,
onPointerEnter,
onFocus,
}: NavTabProps) {
const inner = tabMainInner({
active,
leading,
label,
trailing,
@ -223,7 +197,8 @@ export function NavTab({
folded,
});
const tooltipEnabled = folded || !tooltipEnabledWhenCollapsed;
const tooltipEnabled =
Boolean(tooltip) && (folded || !tooltipEnabledWhenCollapsed);
if (layout === 'split') {
return (
@ -236,7 +211,7 @@ export function NavTab({
>
<div
className={cn(
workspaceTabButtonClass(active),
sidebarTabButtonClass(active),
SPLIT_OUTER_EXTRA_CLASS,
'group',
className
@ -254,7 +229,10 @@ export function NavTab({
disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent',
mainButtonClassName
)}
onPointerEnter={onPointerEnter}
onFocus={onFocus}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-current={ariaCurrentPage ? 'page' : undefined}
aria-disabled={disabled || undefined}
>
@ -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}
>

View file

@ -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 (
<div
className={cn(
'-mt-1 box-border flex h-[44px] min-h-[44px] w-full shrink-0 items-center',
className
)}
>
<div className="flex h-8 w-full min-w-0 items-center gap-3 px-3">
{/* Fixed 16px slot matches NavTab icons; larger mark is centered so layout stays put. */}
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
<img
src={
appearance === 'dark' ? eigentAppIconWhite : eigentAppIconBlack
}
alt=""
className="h-5 max-h-none w-5 max-w-none select-none"
width={20}
height={20}
draggable={false}
/>
</span>
<span
className={cn(
SIDEBAR_TAB_LABEL_CLASS,
'font-bold text-ds-text-neutral-muted-default'
)}
>
Eigent
</span>
</div>
</div>
);
}

View file

@ -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<HTMLDivElement>(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 (
<div
ref={ref}
role={role}
aria-label={ariaLabel}
className={cn(
'flex min-h-0 min-w-0 flex-1 flex-col',
overflowing ? 'scrollbar overflow-y-auto' : 'overflow-hidden',
className
)}
>
{children}
</div>
);
}

View file

@ -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 (
<aside
aria-label={ariaLabel}
className={cn(
'box-border flex h-full min-h-0 w-full min-w-0 shrink-0 flex-col items-start overflow-hidden rounded-2xl bg-ds-bg-neutral-default-default p-1',
className
)}
>
<div className="flex h-full min-h-0 w-full min-w-0 max-w-full flex-col overflow-x-hidden">
{children}
</div>
</aside>
);
}
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 (
<div
className={cn(
'flex w-full min-w-0 flex-col',
grow === 'fill' ? 'min-h-0 flex-1 overflow-hidden' : 'shrink-0 gap-1',
className
)}
>
{children}
</div>
);
}
/** Hairline divider between sidebar sections. */
export function SidebarSeparator({ className }: { className?: string }) {
return (
<div className={cn('my-2 px-3', className)}>
<div className="h-px w-full bg-ds-border-neutral-default-default" />
</div>
);
}
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 (
<div className={cn('flex w-full min-w-0 flex-col', className)}>
{label ? (
<div className="px-3 pb-1 text-label-xs font-bold uppercase tracking-wide text-ds-text-neutral-subtle-default">
{label}
</div>
) : null}
<div className="flex w-full min-w-0 flex-col gap-1">{children}</div>
</div>
);
}

View file

@ -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';

View file

@ -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';

View file

@ -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 (
<header
className={cn(
CONTENT_HEADER_CLASS,
border && CONTENT_HEADER_BORDER_CLASS,
className
)}
>
{leading}
{title ? (
<span className="min-w-0 shrink truncate text-body-md font-bold text-ds-text-neutral-muted-default">
{title}
</span>
) : null}
{children}
{actions ? (
<div className="ml-auto flex shrink-0 items-center gap-2">
{actions}
</div>
) : null}
</header>
);
}

View file

@ -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 (
<div className="relative flex h-full flex-col overflow-hidden bg-ds-bg-neutral-muted-default">
<div className="relative flex h-full flex-col overflow-hidden bg-ds-bg-neutral-strong-default">
<div
className={
actualShouldShowInstallScreen
@ -124,17 +150,13 @@ const Layout = () => {
>
<TopBar />
</div>
<SettingsRouteBridge />
<div className="relative h-full min-h-0 flex-1 overflow-hidden">
{/* Installation screen */}
{actualShouldShowInstallScreen && <InstallDependencies />}
{/* Main app content */}
{shouldShowMainContent && (
<>
<Outlet />
<HistorySidebar />
</>
)}
{shouldShowMainContent && <Outlet />}
{(backendError || (error && installationState === 'error')) && (
<InstallationErrorDialog

View file

@ -27,7 +27,7 @@ export function mergeLayoutAliasStyles(
return style ? ({ ...base, ...style } as React.CSSProperties) : base;
}
// Shared layout-level aliases for TopBar, HistorySidebar, and ProjectPageSidebar.
// Shared layout-level aliases for TopBar and ProjectPageSidebar.
export const productLayoutTokenAliases = asCssVarMap({
'--border-secondary': 'var(--ds-border-neutral-default-default)',
'--border-disabled': 'var(--ds-border-neutral-subtle-default)',

View file

@ -18,10 +18,11 @@ import { motion } from 'framer-motion';
import { Power } from 'lucide-react';
import {
PROJECT_SIDEBAR_FOLD_SPRING,
SIDEBAR_FOLD_SPRING,
SIDEBAR_TAB_LABEL_CLASS,
SIDEBAR_TOOLTIP_CONTENT_CLASS,
} from './constants';
import { WORKSPACE_TAB_LABEL_CLASS, workspaceTabButtonClass } from './NavTab';
sidebarTabButtonClass,
} from '@/components/Layout/AppSidebar';
export interface BottomActionProps {
/** When false, the bottom rail is omitted entirely. */
@ -66,7 +67,7 @@ export function BottomAction({
type="button"
onClick={onEndProjectClick}
className={cn(
workspaceTabButtonClass(false),
sidebarTabButtonClass(false),
'bg-ds-bg-error-subtle-default hover:bg-ds-bg-status-error-subtle-hover active:bg-ds-bg-status-error-subtle-active',
folded && 'gap-0'
)}
@ -83,12 +84,12 @@ export function BottomAction({
opacity: folded ? 0 : 1,
maxWidth: folded ? 0 : 1600,
}}
transition={PROJECT_SIDEBAR_FOLD_SPRING}
transition={SIDEBAR_FOLD_SPRING}
aria-hidden={folded}
>
<span
className={cn(
WORKSPACE_TAB_LABEL_CLASS,
SIDEBAR_TAB_LABEL_CLASS,
'text-body-sm font-medium !text-ds-text-error-default-default'
)}
>

View file

@ -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 (
<div className="mt-3 flex flex-col">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className={cn(
'group/section-header flex w-full items-center gap-1 rounded-lg px-3 py-0.5 text-left'
)}
aria-expanded={expanded}
>
<span className="text-label-sm font-normal text-ds-text-neutral-subtle-default">
{label}
</span>
<ChevronDown
className={cn(
'h-4 w-4 shrink-0 !text-ds-icon-neutral-muted-default transition-[opacity,transform] duration-200',
!expanded && '-rotate-90',
expanded && 'opacity-0 group-hover/section-header:opacity-100'
)}
aria-hidden
/>
</button>
<motion.div
initial={false}
animate={{ height: expanded ? 'auto' : 0 }}
transition={{ duration: 0.18, ease: [0.4, 0, 0.2, 1] }}
style={{ overflow: 'hidden' }}
>
<div className="flex flex-col gap-0.5 pt-0.5">{children}</div>
</motion.div>
</div>
);
}
/** 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<HTMLDivElement>(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={<Plus className="h-4 w-4 shrink-0" aria-hidden />}
label={newProjectLabel}
tooltip={newProjectLabel}
tooltipEnabledWhenCollapsed={!folded}
folded={folded}
ariaLabel={newProjectLabel}
ariaCurrentPage={newProjectActive}
/>
</div>
{/* Scrollable section list */}
<div
ref={projectListRef}
className={cn(
'm-0 mt-1 flex min-h-0 min-w-0 flex-1 flex-col p-0 pb-1',
folded
? projectListOverflow
? 'scrollbar-hide gap-0.5 overflow-y-auto'
: 'gap-0.5 overflow-hidden'
: projectListOverflow
? 'scrollbar overflow-y-auto'
: 'overflow-hidden'
<SidebarScrollArea className="m-0 mt-1 p-0 pb-1">
{hasPinned && (
<SidebarAccordionSection label={pinnedLabel}>
<ProjectNavListRows {...sharedRowProps} projects={pinnedProjects} />
</SidebarAccordionSection>
)}
>
{folded ? (
// Icon-only rail: flat list, no section headers
<>
{hasPinned && (
<ProjectNavListRows
{...sharedRowProps}
projects={pinnedProjects}
/>
)}
{hasUnpinned && (
<ProjectNavListRows
{...sharedRowProps}
projects={unpinnedProjects}
/>
)}
</>
) : (
// Expanded: accordion sections
<>
{hasPinned && (
<AccordionSection label={pinnedLabel}>
<ProjectNavListRows
{...sharedRowProps}
projects={pinnedProjects}
/>
</AccordionSection>
)}
{hasUnpinned && (
<AccordionSection label={projectsLabel}>
<ProjectNavListRows
{...sharedRowProps}
projects={unpinnedProjects}
/>
</AccordionSection>
)}
</>
{hasUnpinned && (
<SidebarAccordionSection label={projectsLabel}>
<ProjectNavListRows
{...sharedRowProps}
projects={unpinnedProjects}
/>
</SidebarAccordionSection>
)}
</div>
</SidebarScrollArea>
</div>
);
}
@ -229,7 +119,6 @@ export interface NavListProps {
onDeleteSession?: (sessionId: string) => void;
onNewSession: () => void;
newSessionActive?: boolean;
folded: boolean;
className?: string;
}

View file

@ -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 (
<div
className={cn(
'shrink-0 items-center',
open ? 'flex' : 'hidden group-hover/session-item:flex'
)}
>
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
buttonRadius="full"
buttonContent="icon-only"
className={cn(
'no-drag shrink-0',
'data-[state=open]:bg-ds-bg-neutral-subtle-selected data-[state=open]:hover:bg-ds-bg-neutral-subtle-selected'
)}
aria-label={moreLabel}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal
className="h-3.5 w-3.5 text-ds-icon-neutral-muted-default"
aria-hidden
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
className="gap-2"
disabled={!onPinProject}
onSelect={() => onPinProject?.(projectId)}
>
<Pin
className={cn(
'h-4 w-4',
pinned && 'fill-current text-ds-icon-brand-default-default'
)}
aria-hidden
/>
{pinLabel}
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2"
disabled={!onAchieveProject || achieved}
onSelect={() => onAchieveProject?.(projectId)}
>
<Archive className="h-4 w-4" aria-hidden />
{achieveLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-2 text-ds-text-error-default-default focus:text-ds-text-error-strong-default data-[highlighted]:text-ds-text-error-default-default [&>svg]:text-ds-icon-error-default-default focus:[&>svg]:text-ds-icon-error-default-default data-[highlighted]:[&>svg]:text-ds-icon-error-default-default"
disabled={!onDeleteProject}
onSelect={() => onDeleteProject?.(projectId)}
>
<Trash2
className="h-4 w-4 text-ds-icon-error-default-default"
aria-hidden
/>
{deleteLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
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 (
<div className="min-w-0">
{/* Tooltip trigger is the whole tab so it anchors to the rows right edge,
not the inner title button. avoidCollisions={false} stops Floating UI
from treating the sidebars overflow clip as the boundary and shifting
the tip back over the row. */}
<TooltipSimple
content={project.title}
side="right"
align="start"
sideOffset={4}
avoidCollisions={false}
variant="instant"
className={SIDEBAR_TAB_TOOLTIP_CONTENT_CLASS}
>
<div
className={cn(
'group/session-item relative flex h-8 w-full min-w-0 items-center overflow-hidden rounded-xl pl-3 pr-3',
'transition-colors duration-150',
selected
? panelListHover
? 'bg-ds-bg-neutral-muted-default hover:bg-ds-bg-neutral-default-default'
: 'bg-ds-bg-neutral-subtle-default hover:bg-ds-bg-neutral-subtle-default'
: !panelListHover
? 'bg-transparent hover:bg-ds-bg-neutral-subtle-default'
: 'bg-transparent hover:bg-ds-bg-neutral-default-default'
)}
>
<button
type="button"
onClick={() => onProjectClick?.(project.id)}
className={cn(
'no-drag relative z-0 flex min-h-0 min-w-0 flex-1 items-center gap-3 overflow-hidden px-0 py-1 text-left outline-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-neutral-subtle-default'
)}
>
<LeadIcon className={leadClassName} aria-hidden />
<span className="min-w-0 flex-1 truncate text-body-sm font-medium text-ds-text-neutral-muted-default">
{project.title}
</span>
{project.source === 'trigger' ? (
<Zap
className="h-3.5 w-3.5 shrink-0 text-ds-icon-warning-default-default"
aria-label={triggerSourceLabel}
/>
) : null}
{!showRowMenu && project.trailing ? (
<span className="shrink-0 pl-1 text-body-xs tabular-nums text-ds-text-neutral-muted-default">
{project.trailing}
</span>
) : null}
</button>
{showRowMenu ? (
<ProjectNavRowMenu
projectId={project.id}
pinned={project.pinned}
achieved={project.achieved}
open={menuOpen}
onOpenChange={setMenuOpen}
onPinProject={onPinProject}
onAchieveProject={onAchieveProject}
onDeleteProject={onDeleteProject}
/>
) : null}
</div>
</TooltipSimple>
</div>
);
}
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({
<TooltipSimple
content={project.title}
side="right"
align="center"
align="start"
sideOffset={4}
avoidCollisions={false}
enabled
variant="instant"
className={SIDEBAR_TOOLTIP_CONTENT_CLASS}
className={SIDEBAR_TAB_TOOLTIP_CONTENT_CLASS}
>
<button
type="button"
onClick={() => onProjectClick?.(project.id)}
className={cn(
workspaceTabButtonClass(active),
sidebarTabButtonClass(active),
'w-full min-w-0 gap-0'
)}
aria-label={project.title}
@ -119,112 +327,18 @@ export function ProjectNavListRows({
}
return (
<div key={project.id} className="min-w-0">
<div
className={cn(
'group/session-item relative flex h-8 w-full min-w-0 items-center overflow-hidden rounded-xl pl-3 pr-3',
'transition-colors duration-150',
active
? panelListHover
? 'bg-ds-bg-neutral-muted-default hover:bg-ds-bg-neutral-default-default'
: 'bg-ds-bg-neutral-subtle-default hover:bg-ds-bg-neutral-subtle-default'
: !panelListHover
? 'bg-transparent hover:bg-ds-bg-neutral-subtle-default'
: 'bg-transparent hover:bg-ds-bg-neutral-default-default'
)}
>
{/* Main click area — always full width */}
<button
type="button"
onClick={() => onProjectClick?.(project.id)}
className={cn(
'no-drag relative z-0 flex min-h-0 min-w-0 flex-1 items-center gap-3 overflow-hidden px-0 py-1 text-left outline-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-neutral-subtle-default'
)}
>
<LeadIcon className={leadClassName} aria-hidden />
<span
className="min-w-0 flex-1 truncate text-body-sm font-medium text-ds-text-neutral-muted-default"
title={project.title}
>
{project.title}
</span>
{project.source === 'trigger' ? (
<Zap
className="h-3.5 w-3.5 shrink-0 text-ds-icon-warning-default-default"
aria-label={triggerSourceLabel}
/>
) : null}
{!showRowMenu && project.trailing ? (
<span className="shrink-0 pl-1 text-body-xs tabular-nums text-ds-text-neutral-muted-default">
{project.trailing}
</span>
) : null}
</button>
{/* Pin + archive buttons — in-flow so title truncates; snap visible on hover, no animation */}
{showRowMenu && (
<div className="hidden shrink-0 items-center group-hover/session-item:flex">
<TooltipSimple
content={project.pinned ? unpinLabel : pinLabel}
side="top"
sideOffset={6}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonRadius="full"
buttonContent="icon-only"
className="no-drag shrink-0"
aria-label={project.pinned ? unpinLabel : pinLabel}
onClick={(e) => {
e.stopPropagation();
onPinProject?.(project.id);
}}
>
<Pin
className={cn(
'h-3.5 w-3.5 transition-colors',
project.pinned
? 'fill-current text-ds-icon-brand-default-default'
: 'text-ds-icon-neutral-muted-default'
)}
aria-hidden
/>
</Button>
</TooltipSimple>
<TooltipSimple
content={project.achieved ? achievedLabel : achieveLabel}
side="top"
sideOffset={6}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
buttonRadius="full"
className="no-drag shrink-0"
aria-label={
project.achieved ? achievedLabel : achieveLabel
}
disabled={!onAchieveProject || project.achieved}
onClick={(e) => {
e.stopPropagation();
onAchieveProject?.(project.id);
}}
>
<Archive
className="h-3.5 w-3.5 text-ds-icon-neutral-muted-default"
aria-hidden
/>
</Button>
</TooltipSimple>
</div>
)}
</div>
</div>
<ProjectNavRow
key={project.id}
project={project}
active={active}
panelListHover={panelListHover}
showRowMenu={showRowMenu}
triggerSourceLabel={triggerSourceLabel}
onProjectClick={onProjectClick}
onPinProject={onPinProject}
onAchieveProject={onAchieveProject}
onDeleteProject={onDeleteProject}
/>
);
})}
</>

View file

@ -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 (
<div className={cn('mt-3 flex flex-col', className)}>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="group/section-header flex w-full items-center gap-1 rounded-lg px-3 py-0.5 text-left"
aria-expanded={expanded}
>
<span className="text-label-sm font-normal text-ds-text-neutral-subtle-default">
{label}
</span>
<ChevronDown
className={cn(
'h-4 w-4 shrink-0 !text-ds-icon-neutral-muted-default transition-[opacity,transform] duration-200',
!expanded && '-rotate-90',
expanded && 'opacity-0 group-hover/section-header:opacity-100'
)}
aria-hidden
/>
</button>
<motion.div
initial={false}
animate={{ height: expanded ? 'auto' : 0 }}
transition={{ duration: 0.18, ease: [0.4, 0, 0.2, 1] }}
style={{ overflow: 'hidden' }}
>
<div className="flex flex-col gap-0.5 pt-0.5">{children}</div>
</motion.div>
</div>
);
}

View file

@ -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';

View file

@ -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 (
<TooltipSimple content={reconnectLabel} side="top" sideOffset={8}>
<button
type="button"
className={cn(
'no-drag flex h-8 w-8 shrink-0 items-center justify-center rounded-xl text-ds-icon-neutral-muted-default outline-none transition-colors hover:bg-ds-bg-neutral-strong-default',
'focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-neutral-subtle-default'
)}
aria-label={reconnectLabel}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onReconnect();
}}
>
<RefreshCw
className={cn(
'h-3.5 w-3.5',
wsConnectionStatus === 'connecting' && 'animate-spin'
)}
aria-hidden
/>
</button>
</TooltipSimple>
);
}

View file

@ -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';

View file

@ -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}
/>
<aside
className={cn(
'box-border flex h-full min-h-0 w-full min-w-0 shrink-0 flex-col items-start overflow-hidden rounded-2xl bg-ds-bg-neutral-default-default p-1',
className
)}
>
<div className="flex h-full min-h-0 w-full min-w-0 max-w-full flex-col overflow-x-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex w-full shrink-0 flex-col gap-1">
<div className="flex w-full min-w-0 flex-col gap-1">
<NavTab
active={activeWorkspaceTab === 'workforce'}
onClick={() => setActiveWorkspaceTab('workforce')}
leading={
<LayoutGrid className="h-4 w-4 shrink-0" aria-hidden />
}
label={t('layout.workspace-tab')}
tooltip={t('layout.workspace-tab')}
tooltipEnabledWhenCollapsed={!projectSidebarFolded}
folded={projectSidebarFolded}
ariaLabel={t('layout.workspace-tab')}
ariaCurrentPage={activeWorkspaceTab === 'workforce'}
/>
<NavTab
active={activeWorkspaceTab === 'inbox'}
onClick={openInboxTab}
disabled={isActiveSpaceUnbound}
leading={
<span className="relative inline-flex h-4 w-4 shrink-0">
<Inbox className="h-4 w-4 shrink-0" aria-hidden />
{folderTabHasUnviewedFiles && !isActiveSpaceUnbound ? (
<span
className="absolute -right-1 -top-1 h-2 w-2 shrink-0 rounded-full bg-ds-text-error-default-default ease-in-out"
aria-hidden
/>
) : null}
</span>
}
label={t('layout.context-tab')}
trailing={
contextTabBinding ? (
<div
className={cn(
'flex shrink-0 flex-col items-center rounded-xl bg-ds-bg-neutral-muted-default px-1.5',
contextTabBinding.tooltip && 'pointer-events-auto'
)}
onClick={
contextTabBinding.tooltip
? (e) => e.stopPropagation()
: undefined
}
>
{contextTabBinding.tooltip ? (
<TooltipSimple
content={contextTabBinding.tooltip}
side="top"
sideOffset={8}
>
<span className="text-label-xs font-medium text-ds-text-neutral-muted-default">
{contextTabBinding.label}
</span>
</TooltipSimple>
) : (
<span className="text-label-xs font-medium text-ds-text-neutral-muted-default">
{contextTabBinding.label}
</span>
)}
</div>
) : undefined
}
tooltip={
isActiveSpaceUnbound
? t('layout.context-tab-unbound-tooltip')
: (contextTabBinding?.tooltip ?? t('layout.context-tab'))
}
// Render the tooltip even when disabled so users get a hint
// instead of relying on the toast that only fires on click.
tooltipEnabledWhenCollapsed={!projectSidebarFolded}
folded={projectSidebarFolded}
ariaLabel={t('layout.context-tab')}
ariaCurrentPage={activeWorkspaceTab === 'inbox'}
/>
<NavTab
layout="split"
active={activeWorkspaceTab === 'triggers'}
onClick={() => setActiveWorkspaceTab('triggers')}
leading={
triggersListenerConnected ? (
<Zap
className={cn(
'h-4 w-4 shrink-0',
triggerListenerLeadIconClass(wsConnectionStatus)
)}
aria-hidden
/>
) : (
<ZapOff
className={cn(
'h-4 w-4 shrink-0',
triggerListenerLeadIconClass(wsConnectionStatus)
)}
aria-hidden
/>
)
}
label={scheduledTabLabel}
showNotificationDot={unviewedTabs.has('triggers')}
notificationDotTone="attention"
notificationDotClassName="h-2 w-2"
endAction={
triggersListenerConnected ? (
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
className={cn(
'no-drag mr-1 shrink-0 rounded-xl hover:bg-ds-bg-neutral-strong-default',
'focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-border-neutral-default-default'
)}
aria-label={t('triggers.add-trigger')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
requestOpenTriggerAddDialog();
}}
>
<Plus
className="h-4 w-4 text-ds-icon-neutral-muted-default"
aria-hidden
/>
</Button>
) : (
<NavTabReconnectSuffix
wsConnectionStatus={wsConnectionStatus}
onReconnect={triggerReconnect}
/>
)
}
tooltip={triggersTabTooltip}
tooltipEnabledWhenCollapsed={!projectSidebarFolded}
folded={projectSidebarFolded}
ariaLabel={triggersTabAriaLabel}
ariaCurrentPage={activeWorkspaceTab === 'triggers'}
/>
<NavTab
active={activeWorkspaceTab === 'dispatch'}
onClick={() => setActiveWorkspaceTab('dispatch')}
leading={<Cast className="h-4 w-4 shrink-0" aria-hidden />}
label={t('layout.dispatch-tab')}
tooltip={t('layout.dispatch-tab')}
tooltipEnabledWhenCollapsed={!projectSidebarFolded}
folded={projectSidebarFolded}
ariaLabel={t('layout.dispatch-tab')}
ariaCurrentPage={activeWorkspaceTab === 'dispatch'}
/>
</div>
</div>
<div className="my-2 px-3">
<div className="h-px w-full bg-ds-border-neutral-default-default" />
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ProjectNavList
className="flex min-h-0 flex-1 flex-col"
projects={navProjects}
activeProjectId={
isProjectNavSelectionActive ? activeProjectId : null
<SidebarShell className={className} ariaLabel={t('layout.workspace-tab')}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<SidebarSection>
<SidebarNavGroup>
<NavTab
active={activeWorkspaceTab === 'workforce'}
onClick={() => setActiveWorkspaceTab('workforce')}
leading={
<LayoutGrid className="h-4 w-4 shrink-0" aria-hidden />
}
onProjectClick={selectProject}
onDeleteProject={requestDeleteProject}
onAchieveProject={requestAchieveProject}
onPinProject={handlePinProject}
onNewProject={handleNewProject}
newProjectActive={activeWorkspaceTab === 'new-project'}
folded={projectSidebarFolded}
label={t('layout.workspace-tab')}
ariaLabel={t('layout.workspace-tab')}
ariaCurrentPage={activeWorkspaceTab === 'workforce'}
/>
</div>
</div>
<NavTab
active={activeWorkspaceTab === 'inbox'}
onClick={openInboxTab}
disabled={isActiveSpaceUnbound}
leading={
<span className="relative inline-flex h-4 w-4 shrink-0">
<Inbox className="h-4 w-4 shrink-0" aria-hidden />
{folderTabHasUnviewedFiles && !isActiveSpaceUnbound ? (
<span
className="absolute -right-1 -top-1 h-2 w-2 shrink-0 rounded-full bg-ds-text-error-default-default ease-in-out"
aria-hidden
/>
) : null}
</span>
}
label={t('layout.context-tab')}
tooltip={contextTabBinding?.tooltip}
trailing={
contextTabBinding ? (
<>
<div className="flex shrink-0 flex-col items-center rounded-xl bg-ds-bg-neutral-muted-default px-1.5">
<span className="text-label-xs font-medium text-ds-text-neutral-muted-default">
{contextTabBinding.label}
</span>
</div>
{contextTabBinding.tooltip ? (
<span id={contextTabDescriptionId} className="sr-only">
{contextTabBinding.tooltip}
</span>
) : null}
</>
) : undefined
}
ariaLabel={t('layout.context-tab')}
ariaDescribedBy={
contextTabBinding?.tooltip
? contextTabDescriptionId
: undefined
}
ariaCurrentPage={activeWorkspaceTab === 'inbox'}
/>
<NavTab
layout="split"
active={activeWorkspaceTab === 'triggers'}
onClick={() => setActiveWorkspaceTab('triggers')}
leading={
triggersListenerConnected ? (
<Zap
className={cn(
'h-4 w-4 shrink-0',
triggerListenerLeadIconClass(wsConnectionStatus)
)}
aria-hidden
/>
) : (
<ZapOff
className={cn(
'h-4 w-4 shrink-0',
triggerListenerLeadIconClass(wsConnectionStatus)
)}
aria-hidden
/>
)
}
label={scheduledTabLabel}
showNotificationDot={unviewedTabs.has('triggers')}
notificationDotTone="attention"
notificationDotClassName="h-2 w-2"
endAction={
triggersListenerConnected ? (
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
className={cn(
'no-drag mr-1 shrink-0 rounded-xl hover:bg-ds-bg-neutral-strong-default',
'focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-border-neutral-default-default'
)}
aria-label={t('triggers.add-trigger')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
requestOpenTriggerAddDialog();
}}
>
<Plus
className="h-4 w-4 text-ds-icon-neutral-muted-default"
aria-hidden
/>
</Button>
) : (
<NavTabReconnectSuffix
wsConnectionStatus={wsConnectionStatus}
onReconnect={triggerReconnect}
/>
)
}
ariaLabel={triggersTabAriaLabel}
ariaCurrentPage={activeWorkspaceTab === 'triggers'}
/>
<NavTab
active={activeWorkspaceTab === 'dispatch'}
onClick={() => setActiveWorkspaceTab('dispatch')}
leading={<Cast className="h-4 w-4 shrink-0" aria-hidden />}
label={t('layout.dispatch-tab')}
ariaLabel={t('layout.dispatch-tab')}
ariaCurrentPage={activeWorkspaceTab === 'dispatch'}
/>
<NavTab
active={false}
onClick={() => openSettings('connectors')}
leading={<ToolCase className="h-4 w-4 shrink-0" aria-hidden />}
label={t('layout.customise-tab')}
ariaLabel={t('layout.customise-tab')}
/>
</SidebarNavGroup>
</SidebarSection>
<SidebarSeparator />
<SidebarSection grow="fill">
<ProjectNavList
className="flex min-h-0 flex-1 flex-col"
projects={navProjects}
activeProjectId={
isProjectNavSelectionActive ? activeProjectId : null
}
onProjectClick={selectProject}
onDeleteProject={requestDeleteProject}
onAchieveProject={requestAchieveProject}
onPinProject={handlePinProject}
onNewProject={handleNewProject}
newProjectActive={activeWorkspaceTab === 'new-project'}
/>
</SidebarSection>
</div>
</aside>
</SidebarShell>
</>
);
}

View file

@ -84,16 +84,16 @@ function TaskQueryScrollLabel({
<div
ref={outerRef}
className={cn(
'min-w-0 text-ds-text-neutral-muted-default w-full overflow-hidden'
'w-full min-w-0 overflow-hidden text-ds-text-neutral-muted-default'
)}
>
<span
ref={innerRef}
title={queryLabel}
className={cn(
'text-body-sm font-normal inline-block whitespace-nowrap first-letter:uppercase',
'inline-block whitespace-nowrap text-body-sm font-normal first-letter:uppercase',
'transition-[transform]',
slide ? 'ease-linear' : 'ease-out duration-300'
slide ? 'ease-linear' : 'duration-300 ease-out'
)}
style={{
transform: slide ? `translateX(-${scrollPx}px)` : 'translateX(0)',
@ -171,7 +171,7 @@ function ChatTimelineRow({
onMouseEnter={() => 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 (
<div
className={cn(
'min-h-0 min-w-0 p-1 flex w-full max-w-[200px] flex-col overflow-hidden',
collapsed ? 'max-h-0 pointer-events-none flex-none' : 'min-h-0 flex-1'
'flex min-h-0 w-full min-w-0 max-w-[200px] flex-col overflow-hidden p-1',
collapsed ? 'pointer-events-none max-h-0 flex-none' : 'min-h-0 flex-1'
)}
style={{ minHeight: 0 }}
>
<div
className={cn(
'gap-2 pl-3 pr-3 py-2 flex w-full shrink-0 items-center',
'flex w-full shrink-0 items-center gap-2 py-2 pl-3 pr-3',
collapsed && 'hidden'
)}
>
<span className="min-w-0 text-xs font-semibold text-ds-text-neutral-muted-default truncate">
<span className="min-w-0 truncate text-xs font-semibold text-ds-text-neutral-muted-default">
{title}
</span>
</div>
<div className="min-h-0 min-w-0 w-full flex-1 overflow-x-hidden overflow-y-auto">
<div className="min-h-0 w-full min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
{entries.length === 0 ? (
<p className="px-3 text-xs text-ds-text-neutral-muted-default w-full">
<p className="w-full px-3 text-xs text-ds-text-neutral-muted-default">
{emptyLabel}
</p>
) : (
<div className="gap-2 min-w-0 flex w-full flex-col">
<div className="flex w-full min-w-0 flex-col gap-2">
{entries.map(({ chatId, taskId, task, firstUserMessageId }) => (
<ChatTimelineRow
key={`${chatId}-${taskId}`}

View file

@ -15,6 +15,7 @@
import tokenDarkIcon from '@/assets/custom/token-dark.svg';
import tokenLightIcon from '@/assets/custom/token-light.svg';
import { AnimatedTokenNumber } from '@/components/ChatBox/MessageItem/TokenUtils';
import { CONTENT_HEADER_CLASS } from '@/components/Layout/ContentHeader';
import { Button } from '@/components/ui/button';
import { TooltipSimple } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
@ -48,31 +49,27 @@ export function HeaderBox({
);
const toggleSessionPreview = usePageTabStore((s) => 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 (
<div
className={`flex h-[44px] w-full shrink-0 flex-row items-center justify-between px-3 ${className || ''}`}
className={cn(CONTENT_HEADER_CLASS, 'justify-between', className)}
aria-hidden
/>
);
}
return (
<div
className={`flex h-[44px] w-full flex-row items-center justify-between pl-3 pr-1.5 ${className || ''}`}
>
<div className={cn(CONTENT_HEADER_CLASS, 'justify-between', className)}>
{/* Left: return to workspace + display-only Project identity. */}
<div className="flex min-w-0 items-center gap-2">
<TooltipSimple content={backToWorkspaceTooltip} variant="instant">
<TooltipSimple content={backTooltip} variant="instant" side="bottom">
<Button
type="button"
variant="ghost"
@ -80,7 +77,7 @@ export function HeaderBox({
buttonContent="icon-only"
onClick={() => setActiveWorkspaceTab('workforce')}
className="no-drag shrink-0 text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-strong-default"
aria-label={backToWorkspaceTooltip}
aria-label={backTooltip}
>
<ArrowLeft className="h-4 w-4" aria-hidden />
</Button>
@ -104,13 +101,25 @@ export function HeaderBox({
<AnimatedTokenNumber value={totalTokens} />
</span>
</div>
<TooltipSimple content={windowPreviewTooltip} variant="instant">
<TooltipSimple
content={windowPreviewTooltip}
variant="instant"
side="bottom"
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
onClick={toggleSessionPreview}
onClick={(event) => {
const wasOpen = sessionPreviewOpen;
toggleSessionPreview();
// Closing leaves :focus on the ghost button, which keeps the
// hover/selected fill until the next click elsewhere.
if (wasOpen) {
event.currentTarget.blur();
}
}}
className={cn(
'no-drag shrink-0 text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-strong-default',
sessionPreviewOpen &&

View file

@ -12,6 +12,7 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import ContentHeader from '@/components/Layout/ContentHeader';
import {
NavListSessionRows,
type NavListSession,
@ -77,24 +78,28 @@ export default function Sessions({
className
)}
>
<div className="border-b-1 flex w-full shrink-0 items-center gap-2 border-x-0 border-t-0 border-solid border-ds-border-neutral-subtle-default px-2 py-2">
<TooltipSimple content={backToWorkspaceTooltip} variant="instant">
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
onClick={() => setActiveWorkspaceTab('workforce')}
className="no-drag shrink-0 text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-strong-default"
aria-label={backToWorkspaceTooltip}
<ContentHeader
leading={
<TooltipSimple
content={backToWorkspaceTooltip}
variant="instant"
side="bottom"
>
<ArrowLeft className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
<div className="flex min-w-0 flex-1 items-center gap-2 px-1 text-body-md font-bold text-ds-text-neutral-default-default">
<span className="truncate">{t('layout.sessions-full-title')}</span>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
onClick={() => setActiveWorkspaceTab('workforce')}
className="no-drag shrink-0 rounded-lg text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-strong-default"
aria-label={backToWorkspaceTooltip}
>
<ArrowLeft className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
}
title={t('layout.sessions-full-title')}
/>
<div className="scrollbar-always-visible m-0 mx-auto flex min-h-0 w-full max-w-[800px] flex-1 flex-col gap-0.5 overflow-y-auto p-2">
{sessions.length === 0 ? (
<p className="m-0 px-3 py-6 text-center text-body-sm text-ds-text-neutral-muted-default">

View file

@ -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 (
<div className="gap-2 border-ds-border-neutral-subtle-disabled py-4 px-6 flex flex-row items-center justify-between border-x-0 border-t-0 border-b border-solid">
<div className="text-body-md font-semibold text-ds-text-neutral-default-default w-24">
<div className="flex flex-row items-center justify-between gap-2 border-x-0 border-b border-t-0 border-solid border-ds-border-neutral-subtle-disabled px-6 py-4">
<span className="w-24 text-body-md font-semibold text-ds-text-neutral-default-default">
{label}
</div>
<div className="w-56 gap-2 flex flex-row items-center">
</span>
<div className="flex w-56 flex-row items-center gap-2">
<Input
size="sm"
value={value}
onChange={(e) => onChange(e.target.value)}
note={
normalizeHexColor(value)
? ''
: 'Hex format: six digits (e.g. 1a2b3c)'
}
note={normalizeHexColor(value) ? '' : t('setting.hex-color-format')}
/>
<Popover open={open} onOpenChange={handleOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="h-8 w-10 rounded-md border-ds-border-neutral-default-default flex-shrink-0 cursor-pointer border border-solid focus-visible:ring-2 focus-visible:outline-none"
className="h-8 w-10 flex-shrink-0 cursor-pointer rounded-md border border-solid border-ds-border-neutral-default-default focus-visible:outline-none focus-visible:ring-2"
style={{ backgroundColor: normalizedPreview }}
title={`Pick ${label} color`}
aria-label={`Pick ${label} color`}
title={t('setting.pick-color', { color: label })}
aria-label={t('setting.pick-color', { color: label })}
/>
</PopoverTrigger>
<PopoverContent
className="w-64 p-4 gap-3 bg-ds-bg-neutral-subtle-default rounded-xl flex flex-col"
className="flex w-64 flex-col gap-3 rounded-xl bg-ds-bg-neutral-subtle-default p-4"
side="top"
align="end"
sideOffset={8}
>
<div className="text-body-sm font-semibold text-ds-text-neutral-default-default">
<span className="block text-body-sm font-semibold text-ds-text-neutral-default-default">
{label}
</div>
</span>
<ColorPicker key={openKey} value={pending} onChange={setPending} />
<div className="gap-2 flex flex-row justify-end">
<div className="flex flex-row justify-end gap-2">
<PopoverClose asChild>
<Button
variant="outline"
@ -161,7 +150,7 @@ function ColorSeedEditor({
buttonRadius="full"
textWeight="semibold"
>
Cancel
{t('layout.cancel')}
</Button>
</PopoverClose>
<Button
@ -173,7 +162,7 @@ function ColorSeedEditor({
disabled={!normalizeHexColor(pending)}
onClick={handleApply}
>
Apply
{t('setting.apply')}
</Button>
</div>
</PopoverContent>
@ -190,12 +179,13 @@ function ContrastSlider({
value: number;
onChange: (v: number) => void;
}) {
const { t } = useTranslation();
return (
<div className="gap-2 py-4 px-6 flex w-full flex-row items-center justify-between">
<div className="text-body-md font-semibold text-ds-text-neutral-default-default w-24">
Contrast
</div>
<div className="gap-2 w-80 flex flex-row items-center">
<div className="flex w-full flex-row items-center justify-between gap-2 px-6 py-4">
<span className="w-24 text-body-md font-semibold text-ds-text-neutral-default-default">
{t('setting.theme-contrast')}
</span>
<div className="flex w-80 flex-row items-center gap-2">
<input
type="range"
min={0}
@ -203,12 +193,12 @@ function ContrastSlider({
step={1}
value={value}
onChange={(e) => 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')}
/>
<div className="w-10 text-body-sm font-semibold text-ds-text-neutral-muted-default text-center">
<span className="w-10 text-center text-body-sm font-semibold text-ds-text-neutral-muted-default">
{value}
</div>
</span>
</div>
</div>
);
@ -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 (
<div className="m-auto h-auto w-full flex-1">
<div className="px-6 pb-6 pt-8 mx-auto flex w-full max-w-[900px] items-center justify-between">
<div className="gap-4 flex w-full flex-row items-center justify-between">
<div className="flex flex-col">
<div className="text-heading-sm font-bold text-ds-text-neutral-default-default">
{t('setting.appearance-tab')}
</div>
</div>
</div>
</div>
<SettingsSectionPage>
<SettingsSection
title={t('setting.color-mode')}
variant="horizontal"
boxClassName="items-center justify-end"
>
<Tabs
value={appearanceMode}
onValueChange={(value) =>
setAppearanceMode(value as 'light' | 'dark' | 'system')
}
>
<TabsList appearance="default">
<TabsTrigger value="light">
<span className="flex items-center gap-1 text-label-sm">
<Sun size={16} />
<span>{t('setting.light')}</span>
</span>
</TabsTrigger>
<TabsTrigger value="dark">
<span className="flex items-center gap-1 text-label-sm">
<Moon size={16} />
<span>{t('setting.dark')}</span>
</span>
</TabsTrigger>
<TabsTrigger value="system">
<span className="flex items-center gap-1 text-label-sm">
<Monitor size={16} />
<span>{t('setting.system-default')}</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
</SettingsSection>
<div className="mb-xl gap-6 flex flex-col">
<div className="item-center gap-4 rounded-2xl bg-ds-bg-neutral-default-default px-6 py-4 h-18 flex flex-row items-center justify-between">
<div className="text-body-base font-bold text-ds-text-neutral-default-default">
Mode
<SettingsSection title={t('setting.accent-palette')} boxClassName="gap-4">
<div className="flex w-full items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Tabs value={activeThemeId} onValueChange={handleThemeChange}>
<TabsList appearance="default" className="min-w-max">
{themeOptions.map((option) => (
<TabsTrigger
key={option.id}
value={option.id}
appearance="default"
>
<span className="flex items-center gap-1 text-label-sm">
{option.label}
</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<Tabs
value={appearanceMode}
onValueChange={(value) =>
setAppearanceMode(value as 'light' | 'dark' | 'system')
}
<Button
variant="outline"
size="sm"
buttonContent="text"
buttonRadius="full"
textWeight="semibold"
onClick={resetActiveTheme}
>
<TabsList appearance="default">
<TabsTrigger value="light">
<div className="gap-1 text-label-sm flex items-center">
<Sun size={16} />
<span>{t('setting.light')}</span>
</div>
</TabsTrigger>
<TabsTrigger value="dark">
<div className="gap-1 text-label-sm flex items-center">
<Moon size={16} />
<span>{t('setting.dark')}</span>
</div>
</TabsTrigger>
<TabsTrigger value="system">
<div className="gap-1 text-label-sm flex items-center">
<Monitor size={16} />
<span>{t('setting.system-default')}</span>
</div>
</TabsTrigger>
</TabsList>
</Tabs>
<span className="flex items-center gap-1 text-label-sm">
<RotateCcw />
<span>{t('setting.reset')}</span>
</span>
</Button>
</div>
<div className="item-center gap-4 rounded-2xl bg-ds-bg-neutral-default-default px-6 py-4 flex flex-col">
<div className="gap-1 flex flex-col">
<div className="text-body-base font-bold text-ds-text-neutral-default-default">
Theme Customization
</div>
</div>
<div className="gap-3 flex w-full items-center justify-between">
<div className="min-w-0 flex-1 overflow-x-auto">
<Tabs value={activeThemeId} onValueChange={handleThemeChange}>
<TabsList appearance="default" className="min-w-max">
{themeOptions.map((option) => (
<TabsTrigger
key={option.id}
value={option.id}
appearance="default"
>
<div className="gap-1 text-label-sm flex items-center">
{option.label}
</div>
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<Button
variant="outline"
size="sm"
buttonContent="text"
buttonRadius="full"
textWeight="semibold"
onClick={resetActiveTheme}
>
<div className="gap-1 text-label-sm flex items-center">
<RotateCcw />
<span>Reset</span>
</div>
</Button>
</div>
<div className="bg-ds-bg-neutral-subtle-default rounded-2xl flex flex-col">
<ColorSeedEditor
label="Accent"
value={accent}
onChange={handleAccentChange}
/>
<ColorSeedEditor
label="Background"
value={background}
onChange={handleBackgroundChange}
/>
<ColorSeedEditor
label="Ink"
value={ink}
onChange={handleInkChange}
/>
<ContrastSlider value={themeContrast} onChange={setThemeContrast} />
</div>
<div className="flex flex-col rounded-2xl bg-ds-bg-neutral-subtle-default">
<ColorSeedEditor
label={t('setting.theme-accent')}
value={accent}
onChange={handleAccentChange}
/>
<ColorSeedEditor
label={t('setting.theme-background')}
value={background}
onChange={handleBackgroundChange}
/>
<ColorSeedEditor
label={t('setting.theme-ink')}
value={ink}
onChange={handleInkChange}
/>
<ContrastSlider value={themeContrast} onChange={setThemeContrast} />
</div>
</SettingsSection>
<div className="item-center rounded-2xl bg-ds-bg-neutral-default-default px-6 py-4 h-18 flex flex-row items-center justify-between">
<div className="gap-1 flex max-w-[55%] flex-col">
<div className="text-body-base font-bold text-ds-text-neutral-default-default">
{t('setting.workspace-main-background')}
</div>
<div className="text-body-sm text-ds-text-neutral-muted-default">
{t('setting.workspace-main-background-description')}
</div>
</div>
<Select
value={workspaceMainBackground ?? 'empty'}
onValueChange={(v) =>
setWorkspaceMainBackground(v as WorkspaceMainBackground)
}
>
<SelectTrigger variant="secondary" className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="empty">
{t('setting.workspace-main-background-empty')}
</SelectItem>
<SelectItem value="dots">
{t('setting.workspace-main-background-dots')}
</SelectItem>
<SelectItem value="ruled">
{t('setting.workspace-main-background-ruled')}
</SelectItem>
<SelectItem value="dotted">
{t('setting.workspace-main-background-dotted')}
</SelectItem>
<SelectItem value="dashed">
{t('setting.workspace-main-background-dashed')}
</SelectItem>
<SelectItem value="blocks">
{t('setting.workspace-main-background-blocks')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<SettingsSection
title={t('setting.workspace-main-background')}
variant="horizontal"
boxClassName="items-center justify-between gap-4"
>
<div className="flex max-w-[55%] flex-col gap-1">
<span className="text-body-sm text-ds-text-neutral-muted-default">
{t('setting.workspace-main-background-description')}
</span>
</div>
</div>
</div>
<Select
value={workspaceMainBackground ?? 'empty'}
onValueChange={(v) =>
setWorkspaceMainBackground(v as WorkspaceMainBackground)
}
>
<SelectTrigger variant="secondary" className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="empty">
{t('setting.workspace-main-background-empty')}
</SelectItem>
<SelectItem value="dots">
{t('setting.workspace-main-background-dots')}
</SelectItem>
<SelectItem value="ruled">
{t('setting.workspace-main-background-ruled')}
</SelectItem>
<SelectItem value="dotted">
{t('setting.workspace-main-background-dotted')}
</SelectItem>
<SelectItem value="dashed">
{t('setting.workspace-main-background-dashed')}
</SelectItem>
<SelectItem value="blocks">
{t('setting.workspace-main-background-blocks')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</SettingsSection>
</SettingsSectionPage>
);
}

View file

@ -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<CdpBrowser[]>([]);
const [browsersLoading, setBrowsersLoading] = useState(true);
const [browsersError, setBrowsersError] = useState<string | null>(null);
const [deletingBrowser, setDeletingBrowser] = useState<string | null>(null);
const [browserToRemove, setBrowserToRemove] = useState<CdpBrowser | null>(
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 (
<div className="m-auto flex h-full w-full flex-1 flex-col">
<SettingsSectionPage>
<AlertDialog
isOpen={!!browserToRemove}
onClose={() => 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 */}
<div className="px-6 pb-6 pt-8 z-10 flex w-full items-center justify-between">
<div className="gap-4 flex w-full flex-col items-start justify-between">
<div className="flex flex-col">
<div className="text-heading-sm font-bold text-ds-text-neutral-default-default">
{t('layout.cdp-browser-connection')}
</div>
</div>
</div>
</div>
<div className="mb-8 gap-6 flex flex-col">
<div className="gap-4 rounded-2xl bg-ds-bg-neutral-default-default px-6 py-4 flex w-full flex-col items-end justify-between">
{/* Header Section */}
<div className="flex w-full flex-row items-center justify-between">
<div className="text-body-base font-bold text-ds-text-neutral-default-default">
{t('layout.cdp-browser-pool')}
</div>
<div className="gap-2 flex flex-row">
<Button
variant="primary"
size="sm"
buttonContent="text"
buttonRadius="lg"
tone="neutral"
textWeight="semibold"
onClick={handleOpenNewBrowser}
>
<Plus className="h-4 w-4" />
{t('layout.open-new-browser')}
</Button>
<Button
variant="outline"
textWeight="semibold"
buttonContent="text"
buttonRadius="lg"
tone="neutral"
size="sm"
onClick={handleConnectExistingBrowser}
>
<Link2 />
{t('layout.connect-existing-browser')}
</Button>
</div>
</div>
{/* Content Section */}
<div className="gap-2 mt-4 flex min-h-[200px] w-full flex-col">
{cdpBrowsers.length > 0 ? (
<div className="gap-2 flex flex-col">
{cdpBrowsers.map((browser) => (
<div
key={browser.id}
className="rounded-xl bg-ds-bg-neutral-subtle-default px-4 py-2 flex items-center justify-between"
>
<div className="gap-3 flex w-full flex-row items-center">
<div className="h-2 w-2 bg-ds-text-success-default-default shrink-0 rounded-full" />
<div className="flex flex-col items-start justify-start">
<span className="text-body-sm font-bold text-ds-text-neutral-default-default">
{browser.name || `Browser ${browser.port}`}
</span>
<span className="text-label-xs text-ds-text-neutral-muted-default">
{t('layout.port')} {browser.port}
</span>
</div>
</div>
<Button
variant="ghost"
size="xs"
buttonContent="icon-only"
onClick={() => setBrowserToRemove(browser)}
disabled={deletingBrowser === browser.id}
className="ml-3 flex-shrink-0"
>
<Trash2 className="h-4 w-4 text-ds-text-error-default-default" />
</Button>
</div>
))}
</div>
) : (
<div className="px-4 py-8 flex flex-col items-center justify-center">
<Globe className="mb-4 h-12 w-12 text-ds-icon-neutral-muted-default opacity-50" />
<div className="text-body-base font-bold text-ds-text-neutral-muted-default text-center">
{t('layout.no-browsers-in-pool')}
</div>
<p className="text-label-xs font-medium text-ds-text-neutral-muted-default text-center">
{t('layout.add-browsers-hint')}
</p>
</div>
)}
</div>
</div>
</div>
{showConnectDialog && (
<div className="bg-dialog-overlay-scrim inset-0 fixed z-50 flex items-center justify-center">
<div className="max-w-md rounded-xl bg-ds-bg-neutral-subtle-default p-6 shadow-lg w-full">
<div className="text-body-base mb-2 font-bold text-ds-text-neutral-default-default">
<SettingsSection
title={t('layout.cdp-browser-pool')}
action={
<div className="flex flex-row gap-2">
<Button
variant="primary"
size="sm"
buttonContent="text"
buttonRadius="lg"
tone="neutral"
textWeight="semibold"
onClick={handleOpenNewBrowser}
>
<Plus className="h-4 w-4" />
{t('layout.open-new-browser')}
</Button>
<Button
variant="outline"
textWeight="semibold"
buttonContent="text"
buttonRadius="lg"
tone="neutral"
size="sm"
onClick={handleConnectExistingBrowser}
>
<Link2 />
{t('layout.connect-existing-browser')}
</div>
<p className="mb-4 text-label-xs text-ds-text-neutral-muted-default">
{t('layout.connect-existing-browser-description')}
</p>
<input
type="text"
value={connectPort}
onChange={(event) => {
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 && (
<p className="mt-2 text-label-xs text-ds-text-status-error-strong-default">
{connectError}
</p>
)}
<div className="mt-4 gap-2 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => setShowConnectDialog(false)}
>
{t('layout.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleCheckAndConnect}
disabled={connectChecking}
>
{connectChecking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Link2 className="h-4 w-4" />
)}
{t('layout.check-and-connect')}
</Button>
</div>
</Button>
</div>
}
boxClassName="min-h-[200px] gap-2"
>
<div className="flex w-full flex-col gap-2">
{browsersLoading && cdpBrowsers.length === 0 ? (
<SettingsSectionLoading
label={t('layout.loading-browser-connections')}
rows={2}
className="py-0"
/>
) : browsersError && cdpBrowsers.length === 0 ? (
<div
role="alert"
className="flex flex-col items-center justify-center gap-3 px-4 py-8 text-center text-body-sm text-ds-text-error-strong-default"
>
<span>{browsersError}</span>
<Button variant="outline" size="sm" onClick={loadCdpBrowsers}>
{t('layout.retry', { defaultValue: 'Retry' })}
</Button>
</div>
) : cdpBrowsers.length > 0 ? (
<div className="flex flex-col gap-2">
{cdpBrowsers.map((browser) => (
<div
key={browser.id}
className="flex items-center justify-between rounded-xl bg-ds-bg-neutral-subtle-default px-4 py-2"
>
<div className="flex w-full flex-row items-center gap-3">
<div className="h-2 w-2 shrink-0 rounded-full bg-ds-text-success-default-default" />
<div className="flex flex-col items-start justify-start">
<span className="text-body-sm font-bold text-ds-text-neutral-default-default">
{browser.name ||
t('layout.browser-name', { port: browser.port })}
</span>
<span className="text-label-xs text-ds-text-neutral-muted-default">
{t('layout.port')} {browser.port}
</span>
</div>
</div>
<Button
variant="ghost"
size="xs"
buttonContent="icon-only"
onClick={() => setBrowserToRemove(browser)}
disabled={deletingBrowser === browser.id}
className="ml-3 flex-shrink-0"
aria-label={t('layout.remove-browser')}
>
<Trash2
className="h-4 w-4 text-ds-text-error-default-default"
aria-hidden
/>
</Button>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center px-4 py-8">
<Globe className="mb-4 h-12 w-12 text-ds-icon-neutral-muted-default opacity-50" />
<span className="text-body-base text-center font-bold text-ds-text-neutral-muted-default">
{t('layout.no-browsers-in-pool')}
</span>
<span className="block text-center text-label-xs font-medium text-ds-text-neutral-muted-default">
{t('layout.add-browsers-hint')}
</span>
</div>
)}
</div>
)}
</div>
</SettingsSection>
<Dialog
open={showConnectDialog}
onOpenChange={(open) => {
if (!open && connectChecking) return;
setShowConnectDialog(open);
}}
>
<DialogContent
size="sm"
showCloseButton={false}
overlayVariant="dimmed"
className="p-6"
onEscapeKeyDown={(event) => {
if (connectChecking) event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (connectChecking) event.preventDefault();
}}
>
<DialogTitle asChild>
<span className="text-body-base mb-2 block font-bold text-ds-text-neutral-default-default">
{t('layout.connect-existing-browser')}
</span>
</DialogTitle>
<DialogDescription asChild>
<span className="mb-4 block text-label-xs text-ds-text-neutral-muted-default">
{t('layout.connect-existing-browser-description')}
</span>
</DialogDescription>
<input
type="text"
value={connectPort}
onChange={(event) => {
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 && (
<span className="mt-2 block text-label-xs text-ds-text-status-error-strong-default">
{connectError}
</span>
)}
<div className="mt-4 flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowConnectDialog(false)}
disabled={connectChecking}
>
{t('layout.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleCheckAndConnect}
disabled={connectChecking}
>
{connectChecking ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<Link2 className="h-4 w-4" aria-hidden />
)}
{t('layout.check-and-connect')}
</Button>
</div>
</DialogContent>
</Dialog>
</SettingsSectionPage>
);
}

View file

@ -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 (
<SettingsSectionPage>
<SettingsSection
title={t('layout.coming-soon')}
boxClassName="items-center justify-between"
>
<div className="flex h-16 w-16 items-center justify-center">
<MessageSquare className="h-8 w-8 text-ds-icon-neutral-muted-default" />
</div>
<span className="mb-2 block text-body-md font-bold text-ds-text-neutral-default-default">
{t('layout.coming-soon')}
</span>
<span className="block max-w-md text-center text-body-sm text-ds-text-neutral-muted-default">
{t('layout.channels-overview-coming-soon-description')}
</span>
</SettingsSection>
</SettingsSectionPage>
);
}

View file

@ -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<ConnectorProvider[]>(
[]
);
const [openConnectionsLoaded, setOpenConnectionsLoaded] =
useState(IS_LOCAL_MODE);
const [selectedId, setSelectedId] = useState<string>(OVERVIEW_ID);
const [recommendedProviders, setRecommendedProviders] = useState<
ConnectorProvider[]
>([]);
const [recommendedLoading, setRecommendedLoading] = useState(false);
const [recommendedLoading, setRecommendedLoading] = useState(!IS_LOCAL_MODE);
const [openDetail, setOpenDetail] = useState<ConnectorProvider | null>(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<string | null>(null);
const [browseDialogOpen, setBrowseDialogOpen] = useState(false);
const [browseDialogTarget, setBrowseDialogTarget] =
useState<AddConnectorTarget>(null);
const [customDialogOpen, setCustomDialogOpen] = useState(false);
const [connectorPage, setConnectorPage] = useState<ConnectorPage>('overview');
const [browseTarget, setBrowseTarget] = useState<AddConnectorTarget>(null);
const [showConfig, setShowConfig] = useState<MCPUserItem | null>(null);
const [configForm, setConfigForm] = useState<MCPConfigForm | null>(null);
const [configError, setConfigError] = useState<string | null>(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) => (
<div className="flex h-full w-full flex-col rounded-2xl bg-ds-bg-neutral-subtle-default">
<div className="flex min-h-0 w-full flex-1 flex-col rounded-2xl bg-ds-bg-neutral-subtle-default">
{renderDetailHeader(item)}
<div className="space-y-5 px-6 py-4">
{item.source === 'open'
@ -1154,130 +1209,204 @@ export default function ConnectorGateway() {
</div>
);
const renderOverviewPanel = () => {
const count = connectorItems.length;
return (
<div className="flex h-full w-full flex-col rounded-2xl bg-ds-bg-neutral-subtle-default">
<div className="flex flex-col items-center px-6 pb-2 pt-8 text-center">
<span className="text-heading-sm font-bold text-ds-text-neutral-default-default">
{pageLoading && count === 0 ? '—' : count}
</span>
<span className="mt-1 text-body-sm text-ds-text-neutral-muted-default">
{count === 1
? t('connectors.count-one')
: t('connectors.count-other')}
</span>
const renderRecommendedConnectors = () => {
if (recommendedLoading && recommendedProviders.length === 0) {
return (
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="h-16 animate-pulse rounded-2xl bg-ds-bg-neutral-subtle-default"
/>
))}
</div>
);
}
<div className="space-y-3 px-6 py-5">
<span className="block text-center text-body-sm text-ds-text-neutral-muted-default">
{t('connectors.recommended')}
</span>
{recommendedLoading && recommendedProviders.length === 0 ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="h-20 animate-pulse rounded-2xl bg-ds-bg-neutral-default-default"
/>
))}
</div>
) : recommendedProviders.length === 0 ? (
<div className="flex min-h-24 items-center justify-center text-body-sm text-ds-text-neutral-muted-default">
{connectorGatewayEnabled
? t('connectors.no-recommended')
: t('connectors.gateway-unavailable')}
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{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 (
<button
key={provider.service}
type="button"
onClick={() => {
if (existing) {
setSelectedId(existing.id);
return;
}
openRecommendedConnector(liveProvider);
}}
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"
>
<ProviderIcon provider={liveProvider} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-body-base truncate font-bold text-ds-text-neutral-default-default">
{providerLabel(liveProvider)}
</span>
<BadgeCheck
className={`h-3.5 w-3.5 shrink-0 ${
connected || existing
? 'text-ds-icon-success-default-default'
: 'text-ds-icon-neutral-muted-default'
}`}
/>
</div>
{liveProvider.description?.trim() ? (
<span className="mt-1 line-clamp-1 block text-body-xs text-ds-text-neutral-muted-default">
{liveProvider.description.trim()}
</span>
) : null}
</div>
{connected || existing ? (
<Settings className="h-4 w-4 shrink-0 text-ds-icon-neutral-muted-default" />
) : (
<Plus className="h-4 w-4 shrink-0 text-ds-icon-neutral-muted-default" />
)}
</button>
);
})}
</div>
)}
if (recommendedProviders.length === 0) {
return (
<div className="flex min-h-20 w-full items-center justify-center text-body-sm text-ds-text-neutral-muted-default">
{connectorGatewayEnabled
? t('connectors.no-recommended')
: t('connectors.gateway-unavailable')}
</div>
);
}
return (
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{recommendedProviders.slice(0, 6).map((provider) => {
const liveProvider =
openConnections.find((item) => item.service === provider.service) ||
provider;
return (
<button
key={provider.service}
type="button"
onClick={() => openRecommendedConnector(liveProvider)}
className={`group flex h-16 min-w-0 items-center gap-3 px-3 text-left ${CONNECTOR_ITEM_SURFACE_CLASS}`}
>
<ProviderIcon provider={liveProvider} />
<span className="min-w-0 flex-1 truncate text-body-sm font-bold text-ds-text-neutral-default-default">
{providerLabel(liveProvider)}
</span>
<Plus className="h-4 w-4 shrink-0 text-ds-icon-neutral-muted-default transition-colors group-hover:text-ds-icon-neutral-default-default" />
</button>
);
})}
</div>
);
};
const renderConnectorCards = () => {
if (pageLoading && connectorItems.length === 0) {
return (
<div className="flex w-full flex-col gap-2">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-16 animate-pulse rounded-2xl bg-ds-bg-neutral-subtle-default"
/>
))}
</div>
);
}
if (visibleItems.length === 0) {
return (
<div className="flex min-h-32 w-full items-center justify-center text-body-sm text-ds-text-neutral-muted-default">
{t('connectors.no-matching')}
</div>
);
}
return (
<div className="flex w-full flex-col gap-2">
{visibleItems.map((item, index) => {
const cardId = `${connectorCardListId}-connector-${index}`;
const statusLabel = item.active
? t('connectors.connected')
: t('connectors.not-connected');
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedId(item.id)}
aria-labelledby={`${cardId}-name`}
aria-describedby={`${cardId}-source ${cardId}-status`}
className={`group flex min-h-16 w-full min-w-0 items-center gap-3 px-4 py-3 text-left ${CONNECTOR_ITEM_SURFACE_CLASS}`}
>
{renderListIcon(item)}
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
id={`${cardId}-name`}
className="truncate text-body-sm font-medium text-ds-text-neutral-default-default"
>
{item.name}
</span>
<span
id={`${cardId}-source`}
className="truncate text-body-sm text-ds-text-neutral-muted-default"
>
{sourceLabel(item, t)}
</span>
</span>
<Badge
id={`${cardId}-status`}
size="sm"
variant="secondary"
tone={item.active ? 'success' : 'neutral'}
className="shrink-0 whitespace-nowrap"
>
{statusLabel}
</Badge>
</button>
);
})}
</div>
);
};
if (connectorPage === 'browse') {
return (
<ConnectorBrowserPage
connectorGatewayEnabled={connectorGatewayEnabled}
localMode={IS_LOCAL_MODE}
builtInItems={dialogBuiltInItems}
builtInInstalled={builtInInstalled}
configs={configs}
initialTarget={browseTarget}
onBack={closeConnectorSubpage}
onInstalled={handleInstalled}
saveBuiltInValue={saveEnvAndConfig}
refreshBuiltIns={refreshBuiltIns}
/>
);
}
if (connectorPage === 'custom') {
return (
<Suspense
fallback={
<div
role="status"
aria-live="polite"
className="flex min-h-[420px] w-full flex-col gap-4 py-4"
>
<span className="sr-only">
{t('setting.loading', {
defaultValue: 'Loading connector settings',
})}
</span>
<div
aria-hidden
className="h-12 animate-pulse rounded-2xl bg-ds-bg-neutral-subtle-default motion-reduce:animate-none"
/>
<div
aria-hidden
className="h-80 animate-pulse rounded-2xl bg-ds-bg-neutral-subtle-default motion-reduce:animate-none"
/>
</div>
}
>
<AddCustomConnectorPage
customMcps={customMcps}
onBack={closeConnectorSubpage}
onInstalled={handleInstalled}
/>
</Suspense>
);
}
return (
<div className="m-auto flex h-full w-full flex-1 flex-col pb-12">
<div className="flex w-full flex-wrap items-center justify-between gap-3 px-6 pb-6 pt-8">
<h1 className="text-heading-sm font-bold text-ds-text-neutral-default-default">
{t('connectors.title')}
</h1>
<div className="flex items-center gap-2">
<SettingsSectionPage className={selected ? 'min-h-full' : undefined}>
{!selected ? (
<SettingsHeaderActions>
<SearchInput
variant="icon"
value={listQuery}
onChange={(event) => setListQuery(event.target.value)}
placeholder={t('connectors.search-placeholder')}
searchTooltip={t('connectors.search-placeholder')}
/>
<Button
variant="primary"
size="sm"
onClick={() => openBrowseDialog()}
>
<Button variant="primary" size="sm" onClick={() => openBrowsePage()}>
{t('connectors.browse')}
</Button>
<Button variant="secondary" size="sm" onClick={openCustomDialog}>
<Plus className="h-4 w-4" />
<Button
variant="secondary"
size="sm"
onClick={openCustomPage}
onFocus={preloadAddCustomConnectorPage}
onPointerEnter={preloadAddCustomConnectorPage}
>
<Plus />
{t('connectors.add-custom')}
</Button>
</div>
</div>
</SettingsHeaderActions>
) : null}
{pageError ? (
<div className="mx-6 mb-4 flex items-center justify-between gap-3 rounded-xl bg-ds-bg-error-subtle-default px-4 py-3 text-body-sm text-ds-text-error-strong-default">
<div className="flex items-center justify-between gap-3 rounded-xl bg-ds-bg-error-subtle-default px-4 py-3 text-body-sm text-ds-text-error-strong-default">
<span>{pageError}</span>
<Button
variant="ghost"
@ -1291,116 +1420,38 @@ export default function ConnectorGateway() {
</div>
) : null}
<div className="mb-12 flex min-h-[54vh] w-full flex-col items-start gap-4 rounded-2xl bg-ds-bg-neutral-default-default px-3 py-2 lg:flex-row">
<aside className="w-full shrink-0 lg:sticky lg:top-[var(--home-hub-history-tabs-offset,49px)] lg:w-[240px]">
<div className="scrollbar-always-visible max-h-[calc(100vh-var(--home-hub-history-tabs-offset,49px)-10rem)] space-y-1 overflow-y-auto py-1">
<button
type="button"
onClick={() => setSelectedId(OVERVIEW_ID)}
className={`flex w-full items-center gap-3 rounded-xl border-0 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus ${
selectedId === OVERVIEW_ID
? 'bg-ds-bg-neutral-subtle-default'
: 'bg-transparent hover:bg-ds-bg-neutral-default-hover'
}`}
>
<Hammer className="h-5 w-5 shrink-0 text-ds-icon-neutral-muted-default" />
<span
className={`min-w-0 flex-1 truncate text-body-sm font-medium ${
selectedId === OVERVIEW_ID
? 'text-ds-text-neutral-default-default'
: 'text-ds-text-neutral-muted-default'
}`}
>
{t('connectors.your-connectors')}
</span>
<Badge size="xs" variant="secondary" className="shrink-0">
{selected ? (
<SettingsSection
titleVariant="hidden"
className="min-h-0 flex-1"
boxClassName="min-h-[54vh] flex-1 overflow-hidden p-0"
>
<div className="flex min-h-0 w-full min-w-0 flex-1">
{renderDetailPanel(selected)}
</div>
</SettingsSection>
) : (
<>
<SettingsSection
title={t('connectors.recommended')}
boxClassName="p-3"
>
{renderRecommendedConnectors()}
</SettingsSection>
<SettingsSection
title={t('connectors.your-connectors')}
action={
<Badge size="xs" variant="secondary">
{connectorItems.length}
</Badge>
</button>
{pageLoading && connectorItems.length === 0 ? (
Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="mx-1 h-9 animate-pulse rounded-xl bg-ds-bg-neutral-strong-default"
/>
))
) : visibleItems.length === 0 ? (
listQuery.trim() ? (
<div className="px-3 py-6 text-center">
<span className="text-body-sm text-ds-text-neutral-muted-default">
{t('connectors.no-matching')}
</span>
</div>
) : null
) : (
visibleItems.map((item) => {
const active = selectedId === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedId(item.id)}
className={`flex w-full items-center gap-3 rounded-xl border-0 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus ${
active
? 'bg-ds-bg-neutral-subtle-default'
: 'bg-transparent hover:bg-ds-bg-neutral-default-hover'
}`}
>
{renderListIcon(item)}
<span
className={`min-w-0 flex-1 truncate text-body-sm font-medium ${
active
? 'text-ds-text-neutral-default-default'
: 'text-ds-text-neutral-muted-default'
}`}
>
{item.name}
</span>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
item.active
? 'bg-ds-text-success-default-default'
: 'bg-ds-icon-neutral-muted-default'
}`}
/>
</button>
);
})
)}
</div>
</aside>
<main className="flex h-full w-full min-w-0 flex-1">
{selectedId === OVERVIEW_ID || !selected
? renderOverviewPanel()
: renderDetailPanel(selected)}
</main>
</div>
<AddConnectorDialog
open={browseDialogOpen}
connectorGatewayEnabled={connectorGatewayEnabled}
localMode={IS_LOCAL_MODE}
builtInItems={dialogBuiltInItems}
builtInInstalled={builtInInstalled}
configs={configs}
initialTarget={browseDialogTarget}
onOpenChange={(next) => {
setBrowseDialogOpen(next);
if (!next) setBrowseDialogTarget(null);
}}
onInstalled={handleInstalled}
saveBuiltInValue={saveEnvAndConfig}
refreshBuiltIns={refreshBuiltIns}
/>
<AddCustomConnectorDialog
open={customDialogOpen}
customMcps={customMcps}
onOpenChange={setCustomDialogOpen}
onInstalled={handleInstalled}
/>
}
boxClassName="p-3"
>
{renderConnectorCards()}
</SettingsSection>
</>
)}
<MCPConfigDialog
open={Boolean(showConfig)}
@ -1420,6 +1471,6 @@ export default function ConnectorGateway() {
onConfirm={handleDelete}
loading={deleteLoading}
/>
</div>
</SettingsSectionPage>
);
}

View file

@ -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<void>;
}
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<string | null>(null);
const [jsonError, setJsonError] = useState<string | null>(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 (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) closeDialog();
}}
>
<DialogContent
size="lg"
showCloseButton
onClose={closeDialog}
overlayVariant="dimmed"
className="h-[min(640px,90vh)] !max-w-[720px]"
<SettingsSectionPage className="min-h-full">
<SettingsSection
titleVariant="hidden"
className="min-h-0 flex-1"
boxClassName="min-h-[54vh] flex-1 gap-5"
>
<DialogHeader
title={t('connectors.custom-title')}
subtitle={t('connectors.custom-subtitle')}
className="pr-12"
/>
<DialogContentSection className="flex min-h-0 flex-col overflow-hidden p-0">
<div className="scrollbar-always-visible min-h-0 flex-1 overflow-y-auto p-5">
<div className="mx-auto flex max-w-3xl flex-col gap-5">
<Tabs
value={customType}
onValueChange={(value) => {
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),
})
);
}
}}
>
<TabsList appearance="default">
<TabsTrigger value="local">
<Wrench className="h-3.5 w-3.5" />
<span className="!text-body-sm font-bold text-ds-text-neutral-default-default">
{t('connectors.source-local')}
</span>
</TabsTrigger>
<TabsTrigger value="remote">
<Server className="h-3.5 w-3.5" />
<span className="!text-body-sm font-bold text-ds-text-neutral-default-default">
{t('connectors.source-remote')}
</span>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="rounded-xl border border-solid border-ds-border-warning-default-default bg-ds-bg-warning-subtle-default p-4 text-body-sm text-ds-text-warning-strong-default">
{t('connectors.custom-warning')}
</div>
{customType === 'local' ? (
<div className="space-y-2">
<span className="block text-body-sm text-ds-text-neutral-muted-default">
{t('connectors.local-json-desc')}{' '}
<a
href="https://modelcontextprotocol.io/docs/getting-started/intro"
target="_blank"
rel="noopener noreferrer"
className="text-ds-text-information-strong-default underline underline-offset-2"
>
{t('connectors.learn-more')}
</a>
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-5">
<div className="flex w-full justify-center">
<Tabs value={customType} onValueChange={changeCustomType}>
<TabsList appearance="default">
<TabsTrigger value="local">
<Wrench className="h-3.5 w-3.5" />
<span className="!text-body-sm !font-bold !text-ds-text-neutral-default-default">
{t('connectors.source-local')}
</span>
{jsonError ? (
<span className="block text-label-sm text-ds-text-error-strong-default">
{jsonError}
</span>
) : null}
<div className="overflow-hidden rounded-xl border border-solid border-ds-border-neutral-strong-default">
<MonacoEditor
height="300px"
width="100%"
language="json"
theme="vs-dark"
value={localJson}
onChange={(value) => setLocalJson(value ?? '')}
options={{
minimap: { enabled: false },
fontSize: 14,
scrollBeyondLastLine: false,
readOnly: saving,
automaticLayout: true,
}}
/>
</div>
</div>
) : (
<div className="space-y-4">
<Input
title={t('connectors.connector-name')}
required
value={remoteName}
onChange={(event) => setRemoteName(event.target.value)}
placeholder={t('connectors.remote-name-placeholder')}
leadingIcon={<Wrench className="h-4 w-4" />}
/>
<Input
title={t('connectors.remote-url')}
required
value={remoteUrl}
onChange={(event) => setRemoteUrl(event.target.value)}
placeholder="https://example.com/mcp"
leadingIcon={<Server className="h-4 w-4" />}
note={t('connectors.remote-url-note')}
/>
</div>
)}
{formError ? (
<div className="rounded-xl bg-ds-bg-error-subtle-default p-3 text-body-sm text-ds-text-error-strong-default">
{formError}
</div>
) : null}
</div>
</TabsTrigger>
<TabsTrigger value="remote">
<Server className="h-3.5 w-3.5" />
<span className="!text-body-sm !font-bold !text-ds-text-neutral-default-default">
{t('connectors.source-remote')}
</span>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<DialogFooter
showCancelButton
cancelButtonText={t('connectors.cancel')}
onCancel={closeDialog}
showConfirmButton
confirmButtonText={
saving ? t('connectors.installing') : t('connectors.install')
}
onConfirm={() => void installCustom()}
confirmButtonDisabled={saving}
/>
</DialogContentSection>
</DialogContent>
</Dialog>
<div className="rounded-xl border border-solid border-ds-border-warning-default-default bg-ds-bg-warning-subtle-default p-4 text-body-sm text-ds-text-warning-strong-default">
{t('connectors.custom-warning')}
</div>
{customType === 'local' ? (
<div className="space-y-2">
<span className="block text-body-sm text-ds-text-neutral-muted-default">
{t('connectors.local-json-desc')}{' '}
<a
href="https://modelcontextprotocol.io/docs/getting-started/intro"
target="_blank"
rel="noopener noreferrer"
className="text-ds-text-information-strong-default underline underline-offset-2"
>
{t('connectors.learn-more')}
</a>
</span>
{jsonError ? (
<span className="block text-label-sm text-ds-text-error-strong-default">
{jsonError}
</span>
) : null}
<div className="overflow-hidden rounded-xl border border-solid border-ds-border-neutral-strong-default">
<MonacoEditor
height="300px"
width="100%"
language="json"
theme="vs-dark"
value={localJson}
onChange={(value) => setLocalJson(value ?? '')}
options={{
minimap: { enabled: false },
fontSize: 14,
scrollBeyondLastLine: false,
readOnly: saving,
automaticLayout: true,
}}
/>
</div>
</div>
) : (
<div className="space-y-4">
<Input
title={t('connectors.connector-name')}
required
value={remoteName}
onChange={(event) => setRemoteName(event.target.value)}
placeholder={t('connectors.remote-name-placeholder')}
leadingIcon={<Wrench className="h-4 w-4" />}
/>
<Input
title={t('connectors.remote-url')}
required
value={remoteUrl}
onChange={(event) => setRemoteUrl(event.target.value)}
placeholder="https://example.com/mcp"
leadingIcon={<Server className="h-4 w-4" />}
note={t('connectors.remote-url-note')}
/>
</div>
)}
{formError ? (
<div className="rounded-xl bg-ds-bg-error-subtle-default p-3 text-body-sm text-ds-text-error-strong-default">
{formError}
</div>
) : null}
<div className="mt-auto flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={closePage}>
{t('connectors.cancel')}
</Button>
<Button
variant="primary"
size="sm"
disabled={saving}
onClick={() => void installCustom()}
>
{saving ? t('connectors.installing') : t('connectors.install')}
</Button>
</div>
</div>
</SettingsSection>
</SettingsSectionPage>
);
}

View file

@ -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<string, boolean>;
configs: StoredConfig[];
initialTarget?: AddConnectorTarget;
onOpenChange: (open: boolean) => void;
onBack: () => void;
onInstalled: (hint: ConnectorInstallHint) => void | Promise<void>;
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<HTMLDivElement>) => {
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 (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) closeDialog();
}}
>
<DialogContent
size="lg"
showCloseButton
onClose={closeDialog}
overlayVariant="dimmed"
className="h-[min(760px,90vh)] !max-w-[960px]"
<SettingsSectionPage className="min-h-full w-full">
{!showingDetail ? (
<SettingsHeaderActions>
<SearchInput
variant="icon"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('connectors.search-connectors')}
searchTooltip={t('connectors.search-connectors')}
/>
{localMode && connectorGatewayEnabled ? (
<Button
type="button"
variant={browseSource === 'open' ? 'secondary' : 'ghost'}
size="xs"
onClick={() => setBrowseSource('open')}
>
<PlugZap className="h-3.5 w-3.5" />
{t('connectors.gateway-connectors')}
</Button>
) : null}
{localMode ? (
<Button
type="button"
variant={browseSource === 'builtin' ? 'secondary' : 'ghost'}
size="xs"
onClick={() => setBrowseSource('builtin')}
>
<Server className="h-3.5 w-3.5" />
{t('connectors.source-built-in')}
</Button>
) : null}
</SettingsHeaderActions>
) : null}
<SettingsSection
titleVariant="hidden"
className="min-h-0 flex-1"
boxClassName="min-h-[54vh] flex-1 overflow-hidden p-0"
>
<DialogHeader
title={
selectedProvider
? providerLabel(selectedProvider)
: selectedBuiltIn
? selectedBuiltIn.name
: t('connectors.browse')
}
showBackButton={showingDetail}
onBackClick={goBackToBrowse}
className="pr-12"
/>
{!showingDetail ? (
<DialogContentSection className="flex min-h-0 flex-col overflow-hidden p-0">
<div className="px-4 py-3">
<SearchInput
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('connectors.search-connectors')}
/>
</div>
{localMode ? (
<div className="flex items-center gap-2 px-4 pt-3">
{connectorGatewayEnabled ? (
<Button
type="button"
variant={browseSource === 'open' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setBrowseSource('open')}
>
<PlugZap className="h-4 w-4" />
{t('connectors.gateway-connectors')}
</Button>
) : null}
<Button
type="button"
variant={browseSource === 'builtin' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setBrowseSource('builtin')}
>
<Server className="h-4 w-4" />
{t('connectors.source-built-in')}
</Button>
</div>
) : null}
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden p-0">
<div
ref={browseScrollRef}
onScroll={handleBrowseScroll}
className="scrollbar-always-visible min-h-0 flex-1 overflow-y-auto py-4 pl-4 pr-2"
className="min-h-0 flex-1 overflow-y-auto p-4"
>
{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"
>
<ProviderIcon provider={provider} />
<div className="min-w-0 flex-1">
@ -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"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default">
<Server className="h-5 w-5 text-ds-icon-neutral-muted-default" />
@ -1101,10 +1068,10 @@ export default function AddConnectorDialog({
</div>
)}
</div>
</DialogContentSection>
</div>
) : selectedProvider ? (
<>
<DialogContentSection className="scrollbar-always-visible overflow-y-auto p-6">
<div className="min-h-0 flex-1 overflow-y-auto p-6">
{detailLoading ? (
<div className="space-y-3">
<div className="h-24 animate-pulse rounded-xl bg-ds-bg-neutral-strong-default" />
@ -1282,29 +1249,28 @@ export default function AddConnectorDialog({
) : null}
</div>
)}
</DialogContentSection>
<DialogFooter
showCancelButton
cancelButtonText={t('connectors.cancel')}
cancelButtonVariant="ghost"
onCancel={closeDialog}
showConfirmButton
confirmButtonText={
saving
</div>
<div className="flex items-center justify-end gap-2 border-x-0 border-b-0 border-t border-solid border-ds-border-neutral-muted-default p-4">
<Button variant="ghost" size="sm" onClick={closePage}>
{t('connectors.cancel')}
</Button>
<Button
variant="primary"
size="sm"
disabled={!canInstallProvider || saving || authorizationPending}
onClick={() => void installOpenProvider()}
>
{saving
? t('connectors.installing')
: isConnectedProvider(selectedProvider)
? t('connectors.save')
: t('connectors.install')
}
onConfirm={() => void installOpenProvider()}
confirmButtonDisabled={
!canInstallProvider || saving || authorizationPending
}
/>
: t('connectors.install')}
</Button>
</div>
</>
) : selectedBuiltIn ? (
<>
<DialogContentSection className="scrollbar-always-visible overflow-y-auto p-6">
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="mx-auto flex max-w-2xl flex-col gap-5">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default">
@ -1430,22 +1396,23 @@ export default function AddConnectorDialog({
</div>
) : null}
</div>
</DialogContentSection>
<DialogFooter
showCancelButton
cancelButtonText={t('connectors.cancel')}
cancelButtonVariant="ghost"
onCancel={closeDialog}
showConfirmButton
confirmButtonText={
saving ? t('connectors.installing') : t('connectors.install')
}
onConfirm={() => void installBuiltIn()}
confirmButtonDisabled={saving || authorizationPending}
/>
</div>
<div className="flex items-center justify-end gap-2 border-x-0 border-b-0 border-t border-solid border-ds-border-neutral-muted-default p-4">
<Button variant="ghost" size="sm" onClick={closePage}>
{t('connectors.cancel')}
</Button>
<Button
variant="primary"
size="sm"
disabled={saving || authorizationPending}
onClick={() => void installBuiltIn()}
>
{saving ? t('connectors.installing') : t('connectors.install')}
</Button>
</div>
</>
) : null}
</DialogContent>
</Dialog>
</SettingsSection>
</SettingsSectionPage>
);
}

View file

@ -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 (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !loading) onCancel();
}}
>
<DialogContent
size="sm"
overlayVariant="dimmed"
showCloseButton={false}
aria-busy={loading}
onEscapeKeyDown={(event) => {
if (loading) event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (loading) event.preventDefault();
}}
className="gap-4 p-6"
>
<DialogTitle
asChild
className="text-body-md text-ds-text-error-strong-default"
>
<span className="block">{t('setting.confirm-delete')}</span>
</DialogTitle>
<DialogDescription
asChild
className="text-body-base text-ds-text-neutral-default-default"
>
<span className="block">
{t('setting.are-you-sure-you-want-to-delete')}{' '}
<span className="font-bold">{displayTarget?.mcp_name}</span>?
</span>
</DialogDescription>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel} disabled={loading}>
{t('setting.cancel')}
</Button>
<Button
variant="primary"
tone="error"
onClick={onConfirm}
disabled={loading}
>
{loading ? t('setting.deleting') : t('setting.delete')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -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<MCPEnvDialogProps> = ({
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<MCPEnvDialogProps> = ({
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<MCPEnvDialogProps> = ({
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<MCPEnvDialogProps> = ({
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<MCPEnvDialogProps> = ({
})}
/>
<div className="gap-3 p-md flex flex-col">
<div className="gap-md flex items-center">
<div className="flex flex-col gap-3 p-md">
<div className="flex items-center gap-md">
{getCategoryIcon(activeMcp?.category?.name)}
<div>
<div className="text-base font-bold leading-9 text-ds-text-brand-default-default">
<span className="block text-base font-bold leading-9 text-ds-text-brand-default-default">
{activeMcp?.name}
</div>
</span>
<div className="text-sm font-bold leading-normal text-ds-text-neutral-default-default">
{getGithubRepoName(activeMcp?.home_page) && (
<div className="flex items-center">
<img
src={githubIcon}
alt="github"
alt=""
style={{
width: 14.7,
height: 14.7,
@ -329,7 +324,7 @@ export const MCPEnvDialog: FC<MCPEnvDialogProps> = ({
verticalAlign: 'middle',
}}
/>
<span className="text-xs font-medium leading-normal line-clamp-1 items-center justify-center self-stretch overflow-hidden break-words text-ellipsis">
<span className="line-clamp-1 items-center justify-center self-stretch overflow-hidden text-ellipsis break-words text-xs font-medium leading-normal">
{getGithubRepoName(activeMcp?.home_page)}
</span>
</div>
@ -337,7 +332,7 @@ export const MCPEnvDialog: FC<MCPEnvDialogProps> = ({
</div>
</div>
</div>
<div className="gap-md flex flex-col">
<div className="flex flex-col gap-md">
{Object.keys(activeMcp?.install_command?.env || {}).map((key) => {
const getNoteContent = () => {
let noteContent = envValues[key]?.tip || '';
@ -347,7 +342,7 @@ export const MCPEnvDialog: FC<MCPEnvDialogProps> = ({
} 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<MCPEnvDialogProps> = ({
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<MCPEnvDialogProps> = ({
cancelButtonVariant="ghost"
showConfirmButton
confirmButtonText={
isValidating ? 'Validating...' : t('setting.connect')
isValidating ? t('setting.verifying') : t('setting.connect')
}
onConfirm={handleConfigureMcpEnvSetting}
confirmButtonVariant="primary"

View file

@ -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<CookieDomain[]>([]);
const [deletingDomain, setDeletingDomain] = useState<string | null>(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 (
<div className="m-auto flex h-auto w-full flex-1 flex-col">
<SettingsSectionPage>
<AlertDialog
isOpen={showRestartDialog}
onClose={() => 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"
/>
<div className="px-6 pb-6 pt-8 flex w-full items-center justify-between">
<div className="text-heading-sm font-bold text-ds-text-neutral-default-default">
{t('layout.browser-cookie-management')}
</div>
</div>
<SettingsSection title={t('layout.cookie-domains')}>
<span className="block max-w-[600px] text-body-sm text-ds-text-neutral-muted-default">
{t('layout.browser-cookies-description')}
</span>
<div className="mt-4 flex w-full flex-col gap-3 border-[0.5px] border-x-0 border-b-0 border-solid border-ds-border-neutral-default-default pt-3">
<div className="flex flex-row items-center justify-between py-2">
<div className="flex flex-row items-center justify-start gap-2">
{cookieDomains.length > 0 && (
<span className="rounded-lg bg-ds-bg-information-subtle-default px-2 text-label-sm font-bold text-ds-text-information-strong-default">
{groupDomainsByMain(cookieDomains).length}
</span>
)}
</div>
<div className="gap-4 flex flex-col">
<div className="rounded-xl border-ds-border-neutral-muted-disabled bg-ds-bg-neutral-default-default p-6 relative flex w-full flex-col border">
<div className="text-body-sm text-ds-text-neutral-muted-default max-w-[600px]">
{t('layout.browser-cookies-description')}
</div>
<div className="mt-4 gap-3 border-ds-border-neutral-default-default pt-3 flex w-full flex-col border-[0.5px] border-x-0 border-b-0 border-solid">
<div className="py-2 flex flex-row items-center justify-between">
<div className="gap-2 flex flex-row items-center justify-start">
<div className="text-body-base font-bold text-ds-text-neutral-default-default">
{t('layout.cookie-domains')}
</div>
{cookieDomains.length > 0 && (
<div className="rounded-lg bg-ds-bg-information-subtle-default px-2 text-label-sm font-bold text-ds-text-information-strong-default">
{groupDomainsByMain(cookieDomains).length}
</div>
)}
</div>
<div className="gap-2 flex items-center">
{cookieDomains.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleDeleteAll}
disabled={deletingAll}
className="!text-ds-text-status-error-strong-default uppercase"
>
{deletingAll
? t('layout.deleting')
: t('layout.delete-all')}
</Button>
)}
<div className="flex items-center gap-2">
{cookieDomains.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleLoadCookies}
disabled={cookiesLoading}
onClick={handleDeleteAll}
disabled={deletingAll}
className="uppercase !text-ds-text-status-error-strong-default"
>
<RefreshCw
className={`h-4 w-4 ${cookiesLoading ? 'animate-spin' : ''}`}
/>
{deletingAll ? t('layout.deleting') : t('layout.delete-all')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleBrowserLogin}
disabled={loginLoading}
>
<Plus className="h-4 w-4" />
{loginLoading
? t('layout.opening')
: t('layout.open-browser')}
</Button>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={handleLoadCookies}
disabled={cookiesLoading}
aria-label={t('setting.refresh')}
>
<RefreshCw
className={`h-4 w-4 ${cookiesLoading ? 'animate-spin' : ''}`}
aria-hidden
/>
</Button>
<Button
variant="primary"
size="sm"
onClick={handleBrowserLogin}
disabled={loginLoading}
>
<Plus className="h-4 w-4" aria-hidden />
{loginLoading ? t('layout.opening') : t('layout.open-browser')}
</Button>
</div>
{cookieDomains.length > 0 ? (
<div className="gap-2 flex flex-col">
{groupDomainsByMain(cookieDomains).map((group, index) => (
<div
key={index}
className="rounded-xl bg-ds-bg-neutral-subtle-default px-4 py-2 flex items-center justify-between"
>
<div className="flex w-full flex-col items-start justify-start">
<span className="text-body-sm font-bold text-ds-text-neutral-default-default truncate">
{group.mainDomain}
</span>
<span className="mt-1 text-label-xs text-ds-text-neutral-muted-default">
{group.totalCookies} Cookie
{group.totalCookies !== 1 ? 's' : ''}
</span>
</div>
<Button
variant="ghost"
size="xs"
buttonContent="icon-only"
onClick={() =>
handleDeleteMainDomain(
group.mainDomain,
group.subdomains
)
}
disabled={deletingDomain === group.mainDomain}
className="ml-3 flex-shrink-0"
>
<Trash2 className="h-4 w-4 text-ds-text-status-error-strong-default" />
</Button>
</div>
))}
</div>
) : (
<div className="px-4 py-8 flex flex-col items-center justify-center">
<Cookie className="mb-4 h-12 w-12 text-ds-icon-neutral-muted-default opacity-50" />
<div className="text-body-base font-bold text-ds-text-neutral-muted-default text-center">
{t('layout.no-cookies-saved-yet')}
</div>
<p className="text-label-xs font-medium text-ds-text-neutral-muted-default text-center">
{t('layout.no-cookies-saved-yet-description')}
</p>
</div>
)}
</div>
</div>
<div className="text-label-xs text-ds-text-neutral-muted-default w-full text-center">
For more information, check out our
<a
href={`${SITE_URL}/privacy-policy`}
target="_blank"
className="ml-1 text-ds-text-status-splitting-strong-default underline"
rel="noreferrer"
>
{t('layout.privacy-policy')}
</a>
{cookiesLoading && cookieDomains.length === 0 ? (
<SettingsSectionLoading
label={t('setting.loading-cookies')}
rows={2}
className="py-0"
/>
) : cookieDomains.length > 0 ? (
<div className="flex flex-col gap-2">
{groupDomainsByMain(cookieDomains).map((group, index) => (
<div
key={index}
className="flex items-center justify-between rounded-xl bg-ds-bg-neutral-subtle-default px-4 py-2"
>
<div className="flex w-full flex-col items-start justify-start">
<span className="truncate text-body-sm font-bold text-ds-text-neutral-default-default">
{group.mainDomain}
</span>
<span className="mt-1 text-label-xs text-ds-text-neutral-muted-default">
{t('layout.cookie-count', {
count: group.totalCookies,
})}
</span>
</div>
<Button
variant="ghost"
size="xs"
buttonContent="icon-only"
onClick={() =>
handleDeleteMainDomain(group.mainDomain, group.subdomains)
}
disabled={deletingDomain === group.mainDomain}
className="ml-3 flex-shrink-0"
aria-label={t('layout.delete-cookies-for-domain', {
domain: group.mainDomain,
})}
>
<Trash2
className="h-4 w-4 text-ds-text-status-error-strong-default"
aria-hidden
/>
</Button>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center px-4 py-8">
<Cookie className="mb-4 h-12 w-12 text-ds-icon-neutral-muted-default opacity-50" />
<span className="text-body-base text-center font-bold text-ds-text-neutral-muted-default">
{t('layout.no-cookies-saved-yet')}
</span>
<span className="block text-center text-label-xs font-medium text-ds-text-neutral-muted-default">
{t('layout.no-cookies-saved-yet-description')}
</span>
</div>
)}
</div>
</div>
</div>
</SettingsSection>
<span className="block w-full text-center text-label-xs text-ds-text-neutral-muted-default">
<span>{t('layout.for-more-info')}</span>
<a
href={`${SITE_URL}/privacy-policy`}
target="_blank"
className="ml-1 text-ds-text-status-splitting-strong-default underline"
rel="noreferrer"
>
{t('layout.privacy-policy')}
</a>
</span>
</SettingsSectionPage>
);
}

View file

@ -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 (
<SettingsSectionPage>
<SettingsSection
title={t('layout.coming-soon')}
boxClassName="min-h-[200px] items-center justify-center py-16"
>
<Puzzle className="mb-4 h-12 w-12 text-ds-icon-neutral-muted-default opacity-50" />
<div className="text-body-base text-center font-bold text-ds-text-neutral-muted-default">
{t('layout.coming-soon')}
</div>
<span className="mt-2 block text-center text-label-sm text-ds-text-neutral-muted-default">
{t('layout.browser-plugins-description')}
</span>
</SettingsSection>
</SettingsSectionPage>
);
}

View file

@ -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 (
<div className="m-auto h-auto w-full flex-1">
{/* Header Section */}
<div className="mx-auto flex w-full max-w-[900px] items-center justify-between px-6 pb-6 pt-8">
<div className="flex w-full flex-row items-center justify-between gap-4">
<div className="flex flex-col">
<div className="text-heading-sm font-bold text-ds-text-neutral-default-default">
{t('setting.general')}
</div>
</div>
</div>
</div>
{/* Content Section */}
<div className="mb-xl flex flex-col gap-6">
{/* Profile Section */}
<div className="item-center flex flex-row justify-between rounded-2xl bg-ds-bg-neutral-default-default px-6 py-4">
<SettingsSectionPage>
{/* Profile Section */}
{(section === 'all' || section === 'profile') && (
<SettingsSection
title={t('setting.profile')}
variant="horizontal"
boxClassName="items-center justify-between gap-4"
>
<div className="flex flex-col gap-2">
<div className="text-body-base font-bold text-ds-text-neutral-default-default">
{t('setting.profile')}
</div>
<div className="text-body-sm">
<Trans
i18nKey="setting.you-are-currently-signed-in-with"
@ -247,41 +250,54 @@ export default function SettingGeneral() {
{t('setting.log-out')}
</Button>
</div>
</div>
</SettingsSection>
)}
{/* Language Section */}
<div className="item-center flex flex-row justify-between rounded-2xl bg-ds-bg-neutral-default-default px-6 py-4">
<div className="flex flex-1 items-center">
<div className="text-body-base font-bold text-ds-text-neutral-default-default">
{t('setting.language')}
</div>
</div>
{/* Language Section */}
{(section === 'all' || section === 'language') && (
<SettingsSection
title={t('setting.language')}
variant="horizontal"
boxClassName="items-center justify-end"
>
<Select value={language} onValueChange={switchLanguage}>
<SelectTrigger variant="secondary" className="w-48">
<SelectTrigger
variant="secondary"
className="w-48 !bg-ds-bg-neutral-subtle-default hover:!bg-ds-bg-neutral-subtle-default data-[state=open]:!bg-ds-bg-neutral-subtle-default"
>
<SelectValue placeholder={t('setting.select-language')} />
</SelectTrigger>
<SelectContent className="border bg-input-bg-default">
<SelectGroup>
<SelectItem value="system">
<SelectItem
value="system"
className="hover:!bg-ds-bg-neutral-subtle-default focus:!bg-ds-bg-neutral-subtle-default data-[highlighted]:!bg-ds-bg-neutral-subtle-default"
>
{t('setting.system-default')}
</SelectItem>
{languageList.map((item) => (
<SelectItem key={item.key} value={item.key}>
<SelectItem
key={item.key}
value={item.key}
className="hover:!bg-ds-bg-neutral-subtle-default focus:!bg-ds-bg-neutral-subtle-default data-[highlighted]:!bg-ds-bg-neutral-subtle-default"
>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</SettingsSection>
)}
{/* Network Proxy Section */}
<div className="flex flex-col gap-4 rounded-2xl bg-ds-bg-neutral-default-default px-6 py-4">
{/* Network Proxy Section */}
{(section === 'all' || section === 'network-proxy') && (
<SettingsSection
title={t('setting.network-proxy')}
boxClassName="gap-4"
>
<div className="flex flex-col gap-1">
<div className="text-body-base font-bold text-ds-text-neutral-default-default">
{t('setting.network-proxy')}
</div>
<div className="mb-4 text-sm leading-13 text-ds-text-neutral-muted-default">
<div className="text-sm leading-13 text-ds-text-neutral-muted-default">
{t('setting.network-proxy-description')}
</div>
</div>
@ -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')}
</Button>
}
/>
</div>
</div>
</div>
</SettingsSection>
)}
</SettingsSectionPage>
);
}

View file

@ -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 (
<SettingsSectionPage>
<SettingsSection
title={t('layout.coming-soon')}
boxClassName="items-center justify-between"
>
<div className="flex h-16 w-16 items-center justify-center">
<Brain className="h-8 w-8 text-ds-icon-neutral-muted-default" />
</div>
<span className="mb-2 block text-body-md font-bold text-ds-text-neutral-default-default">
{t('layout.coming-soon')}
</span>
<span className="block max-w-md text-center text-body-sm text-ds-text-neutral-muted-default">
{t('agents.memory-coming-soon-description')}
</span>
</SettingsSection>
</SettingsSectionPage>
);
}

View file

@ -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 (
<div className="flex w-full flex-col">
{title ? (
<div className="mb-1.5 gap-1 text-body-sm font-bold text-ds-text-neutral-default-default flex items-center">
<span className="mb-1.5 flex items-center gap-1 text-body-sm font-bold text-ds-text-neutral-default-default">
{title}
</div>
</span>
) : null}
<div className="gap-2 flex w-full items-center">
<div className="flex w-full items-center gap-2">
<Select
value={value || undefined}
onValueChange={onChange}
@ -118,24 +113,24 @@ export function ProviderModelCombobox({
wrapperClassName="min-w-0 flex-1"
state={error ? 'error' : undefined}
note={error ?? undefined}
aria-label={`${providerName} model type`}
aria-label={t('setting.provider-model-type-label', {
provider: providerName,
})}
>
<SelectValue
placeholder={triggerPlaceholder ?? 'Select model type'}
placeholder={triggerPlaceholder ?? t('setting.select-model-type')}
/>
</SelectTrigger>
<SelectContent>
{!hasAnyModels && !orphanValue ? (
<div className="px-3 py-6 text-xs text-ds-text-neutral-muted-default text-center">
<span className="block px-3 py-6 text-center text-xs text-ds-text-neutral-muted-default">
{emptyMessage}
</div>
</span>
) : (
<>
{orphanValue ? (
<SelectGroup>
<SelectLabel>
{t('setting.current', { defaultValue: 'Current' })}
</SelectLabel>
<SelectLabel>{t('setting.current')}</SelectLabel>
<SelectItem value={orphanValue}>{orphanValue}</SelectItem>
</SelectGroup>
) : null}
@ -167,15 +162,17 @@ export function ProviderModelCombobox({
buttonRadius="full"
onClick={onRefresh}
disabled={disabled || loading}
aria-label={`Refresh ${providerName} models`}
className="text-body-sm shrink-0"
aria-label={t('setting.refresh-provider-models', {
provider: providerName,
})}
className="shrink-0 text-body-sm"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="!h-4 !w-4" />
)}
{t('setting.refresh', { defaultValue: 'Refresh' })}
{t('setting.refresh')}
</Button>
</div>
</div>

View file

@ -22,13 +22,6 @@ export const LLAMA_CPP_PROVIDER_ID = 'llama.cpp' as const;
// --- Ollama endpoint auto-fix ---
// Toast strings shown when the Ollama endpoint input auto-appends "/v1".
// Triggered on blur when a user enters a bare Ollama URL (e.g. http://localhost:11434)
// that is missing the required /v1 suffix for the OpenAI-compatible API.
export const OLLAMA_ENDPOINT_AUTO_FIX_TITLE = 'Ollama endpoint updated';
export const OLLAMA_ENDPOINT_AUTO_FIX_DESC =
'Added /v1 once. You can remove it if not needed.';
// --- Local model config ---
// Model fetch config per local provider.

View file

@ -0,0 +1,122 @@
// ========= 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 { proxyFetchGet, proxyFetchPut } from '@/api/http';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { SITE_URL } from '@/lib';
import { ChevronDown } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import SettingsSection from '../SettingsSection';
import SettingsSectionPage from '../SettingsSectionPage';
export default function SettingPrivacy() {
const [helpImprove, setHelpImprove] = useState(false);
const { t } = useTranslation();
const [isHowWeHandleOpen, setIsHowWeHandleOpen] = useState(false);
useEffect(() => {
proxyFetchGet('/api/v1/user/privacy')
.then((res) => {
setHelpImprove(res.help_improve || false);
})
.catch((err) => console.error('Failed to fetch settings:', err));
}, []);
const handleToggleHelpImprove = (checked: boolean) => {
setHelpImprove(checked);
proxyFetchPut('/api/v1/user/privacy', { help_improve: checked }).catch(
(err) => console.error('Failed to update settings:', err)
);
};
return (
<SettingsSectionPage>
{/* How We Handle Your Data Section */}
<SettingsSection
title={t('setting.how-we-handle-your-data')}
action={
<Button
variant="ghost"
size="xs"
buttonContent="icon-only"
onClick={() => setIsHowWeHandleOpen((prev) => !prev)}
aria-expanded={isHowWeHandleOpen}
aria-controls="how-we-handle-your-data"
>
<ChevronDown
className={`h-4 w-4 transition-transform ${isHowWeHandleOpen ? 'rotate-0' : '-rotate-90'}`}
/>
</Button>
}
>
<span className="text-body-sm font-normal text-ds-text-neutral-default-default">
{t('setting.data-privacy-description')}{' '}
<a
className="text-blue-500 no-underline"
href={`${SITE_URL}/privacy-policy`}
target="_blank"
rel="noreferrer"
>
{t('setting.privacy-policy')}
</a>
.
</span>
{isHowWeHandleOpen && (
<div className="mr-10 mt-4 border-x-0 border-b-0 border-t-[0.5px] border-solid border-ds-border-neutral-default-default">
<ol
id="how-we-handle-your-data"
className="pl-5 text-body-sm font-normal text-ds-text-neutral-default-default"
>
<li>
{t(
'setting.we-only-use-the-essential-data-needed-to-run-your-tasks'
)}
:
</li>
<ul className="mb-2 pl-4">
<li>{t('setting.how-we-handle-your-data-line-1-line-1')}</li>
<li>{t('setting.how-we-handle-your-data-line-1-line-2')}</li>
<li>{t('setting.how-we-handle-your-data-line-1-line-3')}</li>
</ul>
<li>{t('setting.how-we-handle-your-data-line-2')}</li>
<li>{t('setting.how-we-handle-your-data-line-3')}</li>
<li>{t('setting.how-we-handle-your-data-line-4')}</li>
<li>{t('setting.how-we-handle-your-data-line-5')}</li>
</ol>
</div>
)}
</SettingsSection>
{/* Help Improve Eigent Section */}
<SettingsSection
title={t('setting.help-improve-eigent')}
variant="horizontal"
boxClassName="items-center justify-between gap-md"
>
<div className="flex flex-col gap-2">
<div className="text-body-sm font-normal text-ds-text-neutral-default-default">
{t('setting.help-improve-eigent-description')}
</div>
</div>
<div className="flex items-center justify-center">
<Switch
checked={helpImprove}
onCheckedChange={handleToggleHelpImprove}
/>
</div>
</SettingsSection>
</SettingsSectionPage>
);
}

View file

@ -0,0 +1,44 @@
// ========= 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, RefObject } from 'react';
interface SettingsContentShellProps {
children: ReactNode;
className?: string;
scrollRef?: RefObject<HTMLDivElement>;
}
export default function SettingsContentShell({
children,
className,
scrollRef,
}: SettingsContentShellProps) {
return (
<div
ref={scrollRef}
className={cn(
'scrollbar-always-visible min-h-0 flex-1 overflow-y-scroll [scrollbar-gutter:stable]',
className
)}
>
{/* Sections stay in one centered measure so they don't stretch across a
wide window. */}
<div className="mx-auto min-h-full w-full max-w-[964px] px-8">
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,95 @@
// ========= 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 ContentHeader from '@/components/Layout/ContentHeader';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { SettingsSectionId } from '@/store/settingsStore';
import { ArrowLeft } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useSettingsHeader } from './SettingsHeaderContext';
import { getSettingsNavigationItem } from './settingsNavigation';
interface SettingsHeaderProps {
activeSection: SettingsSectionId;
}
/**
* Settings content-pane header. Same 44px row as the other pages; sections
* still push their own title/back/actions through `SettingsHeaderContext`.
*/
export default function SettingsHeader({ activeSection }: SettingsHeaderProps) {
const { t } = useTranslation();
const { headerOverride, setHeaderActionsElement } = useSettingsHeader();
const headingRef = useRef<HTMLHeadingElement>(null);
const item = getSettingsNavigationItem(activeSection);
const title =
headerOverride?.title ??
t(item.labelKey, { defaultValue: item.defaultLabel });
const backLabel = t('layout.back', { defaultValue: 'Back' });
useEffect(() => {
headingRef.current?.focus();
}, []);
return (
<ContentHeader>
<div className="flex min-w-0 flex-1 items-center gap-2">
{headerOverride?.onBack ? (
<>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
className="shrink-0 rounded-lg"
aria-label={backLabel}
onClick={headerOverride.onBack}
>
<ArrowLeft className="h-4 w-4" aria-hidden />
</Button>
<h1
ref={headingRef}
tabIndex={-1}
className="max-w-52 shrink-0 truncate text-body-md font-bold text-ds-text-neutral-default-default outline-none"
>
{title}
</h1>
</>
) : (
<h1
ref={headingRef}
tabIndex={-1}
className={
headerOverride?.hideTitle
? 'sr-only outline-none'
: 'min-w-0 shrink-0 truncate px-1 text-body-md font-bold text-ds-text-neutral-default-default outline-none'
}
>
{title}
</h1>
)}
<div
ref={setHeaderActionsElement}
className={cn(
'flex min-w-0 items-center gap-2 empty:hidden',
headerOverride?.hideTitle ? 'flex-1' : 'ml-auto'
)}
/>
</div>
</ContentHeader>
);
}

View file

@ -0,0 +1,107 @@
// ========= 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 { SettingsSectionId } from '@/store/settingsStore';
import {
createContext,
type Dispatch,
type ReactNode,
type SetStateAction,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { createPortal } from 'react-dom';
export interface SettingsHeaderOverride {
title: ReactNode;
onBack?: () => void;
hideTitle?: boolean;
}
interface SettingsHeaderContextValue {
activeSection: SettingsSectionId;
headerOverride: SettingsHeaderOverride | null;
setHeaderOverride: (value: SettingsHeaderOverride | null) => void;
headerActionsElement: HTMLElement | null;
setHeaderActionsElement: Dispatch<SetStateAction<HTMLElement | null>>;
}
const SettingsHeaderContext = createContext<SettingsHeaderContextValue | null>(
null
);
interface SettingsHeaderProviderProps {
activeSection: SettingsSectionId;
children: ReactNode;
}
export function SettingsHeaderProvider({
activeSection,
children,
}: SettingsHeaderProviderProps) {
const [renderedSection, setRenderedSection] = useState(activeSection);
const [headerOverride, setHeaderOverrideState] =
useState<SettingsHeaderOverride | null>(null);
const [headerActionsElement, setHeaderActionsElement] =
useState<HTMLElement | null>(null);
if (renderedSection !== activeSection) {
setRenderedSection(activeSection);
setHeaderOverrideState(null);
}
const setHeaderOverride = useCallback(
(value: SettingsHeaderOverride | null) => {
setHeaderOverrideState(value);
},
[]
);
const value = useMemo(
() => ({
activeSection,
headerOverride,
setHeaderOverride,
headerActionsElement,
setHeaderActionsElement,
}),
[activeSection, headerActionsElement, headerOverride, setHeaderOverride]
);
return (
<SettingsHeaderContext.Provider value={value}>
{children}
</SettingsHeaderContext.Provider>
);
}
export function useSettingsHeader() {
const context = useContext(SettingsHeaderContext);
if (!context) {
throw new Error(
'useSettingsHeader must be used inside SettingsHeaderProvider'
);
}
return context;
}
export function SettingsHeaderActions({ children }: { children: ReactNode }) {
const { activeSection, headerActionsElement } = useSettingsHeader();
const [ownerSection] = useState(activeSection);
return headerActionsElement && ownerSection === activeSection
? createPortal(children, headerActionsElement)
: null;
}

View file

@ -0,0 +1,62 @@
// ========= 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';
interface SettingsSectionProps {
title?: ReactNode;
children: ReactNode;
action?: ReactNode;
variant?: 'vertical' | 'horizontal';
titleVariant?: 'default' | 'hidden';
className?: string;
boxClassName?: string;
}
export default function SettingsSection({
title,
children,
action,
variant = 'vertical',
titleVariant = 'default',
className,
boxClassName,
}: SettingsSectionProps) {
const showTitle = titleVariant === 'default' && title != null;
return (
<section className={cn('flex w-full flex-col', className)}>
{showTitle ? (
<div className="mb-2 flex min-h-6 items-center justify-between gap-4">
<span className="m-0 ml-4 text-body-sm font-bold text-ds-text-neutral-default-default">
{title}
</span>
{action ? <div className="mr-4 shrink-0">{action}</div> : null}
</div>
) : null}
<div
className={cn(
// Borderless: the section reads as a filled panel against the
// subtle content-pane background instead of an outlined card.
'flex rounded-2xl border-0 bg-ds-bg-neutral-default-default p-4',
variant === 'horizontal' ? 'flex-row' : 'flex-col',
boxClassName
)}
>
{children}
</div>
</section>
);
}

View file

@ -0,0 +1,171 @@
// ========= 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 { SettingsSectionId } from '@/store/settingsStore';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { LoaderCircle } from 'lucide-react';
import { lazy, type RefObject, Suspense, useLayoutEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import SettingsContentShell from './SettingsContentShell';
import WorkspaceProfileSettings from './WorkspaceProfile';
const sectionLoaders = {
models: () => import('./Models'),
skills: () => import('./Skills'),
'sub-agents': () => import('./SubAgents'),
memory: () => import('./Memory'),
connectors: () => import('./Connectors'),
channels: () => import('./Channels'),
'browser-connections': () => import('./Browser'),
'browser-plugins': () => import('./Extension'),
cookies: () => import('./Cookies'),
general: () => import('./General'),
appearance: () => import('./Appearance'),
privacy: () => import('./Privacy'),
} satisfies Partial<
Record<SettingsSectionId, () => Promise<{ default: React.ComponentType }>>
>;
const Models = lazy(sectionLoaders.models);
const Skills = lazy(sectionLoaders.skills);
const SubAgents = lazy(sectionLoaders['sub-agents']);
const Memory = lazy(sectionLoaders.memory);
const Connectors = lazy(sectionLoaders.connectors);
const Channels = lazy(sectionLoaders.channels);
const BrowserConnections = lazy(sectionLoaders['browser-connections']);
const BrowserPlugins = lazy(sectionLoaders['browser-plugins']);
const Cookies = lazy(sectionLoaders.cookies);
const General = lazy(sectionLoaders.general);
const Appearance = lazy(sectionLoaders.appearance);
const Privacy = lazy(sectionLoaders.privacy);
export function preloadSettingsSection(section: SettingsSectionId) {
void sectionLoaders[section as keyof typeof sectionLoaders]?.().catch(
() => undefined
);
}
interface SettingsSectionContentProps {
activeSection: SettingsSectionId;
}
function SectionFallback() {
const { t } = useTranslation();
return (
<div
role="status"
aria-live="polite"
className="flex min-h-[320px] w-full items-center justify-center text-ds-icon-neutral-muted-default"
>
<LoaderCircle className="h-5 w-5 animate-spin" aria-hidden />
<span className="sr-only">
{t('setting.loading', { defaultValue: 'Loading settings' })}
</span>
</div>
);
}
function SettingsSection({ section }: { section: SettingsSectionId }) {
switch (section) {
case 'workspace-profile':
return <WorkspaceProfileSettings />;
case 'models':
return <Models />;
case 'sub-agents':
return <SubAgents />;
case 'connectors':
return <Connectors />;
case 'skills':
return <Skills />;
case 'channels':
return <Channels />;
case 'memory':
return <Memory />;
case 'browser-connections':
return <BrowserConnections />;
case 'browser-plugins':
return <BrowserPlugins />;
case 'cookies':
return <Cookies />;
case 'general':
return <General />;
case 'appearance':
return <Appearance />;
case 'privacy':
return <Privacy />;
}
}
interface AnimatedSettingsSectionProps {
section: SettingsSectionId;
scrollRef: RefObject<HTMLDivElement>;
shouldReduceMotion: boolean | null;
}
function AnimatedSettingsSection({
section,
scrollRef,
shouldReduceMotion,
}: AnimatedSettingsSectionProps) {
useLayoutEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = 0;
}, [scrollRef]);
return (
<motion.div
data-settings-section={section}
className="min-h-full"
initial={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 0, transform: 'translateY(6px)' }
}
animate={{ opacity: 1, transform: 'translateY(0px)' }}
exit={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 0, transform: 'translateY(-3px)' }
}
transition={{
duration: shouldReduceMotion ? 0 : 0.14,
ease: [0.23, 1, 0.32, 1],
}}
>
<Suspense fallback={<SectionFallback />}>
<SettingsSection section={section} />
</Suspense>
</motion.div>
);
}
export default function SettingsSectionContent({
activeSection,
}: SettingsSectionContentProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const shouldReduceMotion = useReducedMotion();
return (
<SettingsContentShell scrollRef={scrollRef}>
<AnimatePresence mode="wait" initial={false}>
<AnimatedSettingsSection
key={activeSection}
section={activeSection}
scrollRef={scrollRef}
shouldReduceMotion={shouldReduceMotion}
/>
</AnimatePresence>
</SettingsContentShell>
);
}

View file

@ -0,0 +1,44 @@
// ========= 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';
interface SettingsSectionLoadingProps {
label: string;
rows?: number;
className?: string;
}
export default function SettingsSectionLoading({
label,
rows = 3,
className,
}: SettingsSectionLoadingProps) {
return (
<div
role="status"
aria-live="polite"
className={cn('flex w-full flex-col gap-4 py-4', className)}
>
<span className="sr-only">{label}</span>
{Array.from({ length: rows }).map((_, index) => (
<div
key={index}
aria-hidden
className="h-24 w-full animate-pulse rounded-2xl bg-ds-bg-neutral-subtle-default motion-reduce:animate-none"
/>
))}
</div>
);
}

View file

@ -0,0 +1,32 @@
// ========= 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';
interface SettingsSectionPageProps {
children: ReactNode;
className?: string;
}
export default function SettingsSectionPage({
children,
className,
}: SettingsSectionPageProps) {
return (
<section className={cn('flex w-full flex-col gap-4 py-4', className)}>
{children}
</section>
);
}

View file

@ -0,0 +1,85 @@
// ========= 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,
SidebarScrollArea,
SidebarSection,
SidebarShell,
} from '@/components/Layout/AppSidebar';
import type { SettingsSectionId } from '@/store/settingsStore';
import { useTranslation } from 'react-i18next';
import { preloadSettingsSection } from './SettingsSectionContent';
import { SETTINGS_NAVIGATION } from './settingsNavigation';
interface SettingsSidebarProps {
activeSection: SettingsSectionId;
onSectionChange: (section: SettingsSectionId) => void;
className?: string;
}
/** Settings rail: the former dialog menu, rebuilt on the shared sidebar kit. */
export default function SettingsSidebar({
activeSection,
onSectionChange,
className,
}: SettingsSidebarProps) {
const { t } = useTranslation();
return (
<SidebarShell
className={className}
ariaLabel={t('layout.settings', { defaultValue: 'Settings' })}
>
<SidebarBrandHeader />
<SidebarSection grow="fill">
<SidebarScrollArea
role="navigation"
ariaLabel={t('layout.settings', { defaultValue: 'Settings' })}
className="gap-4 pt-1"
>
{SETTINGS_NAVIGATION.map((group) => (
<SidebarNavGroup
key={group.scope}
label={t(group.labelKey, { defaultValue: group.defaultLabel })}
>
{group.items.map((item) => {
const Icon = item.icon;
const active = activeSection === item.id;
const label = t(item.labelKey, {
defaultValue: item.defaultLabel,
});
return (
<NavTab
key={item.id}
active={active}
onClick={() => onSectionChange(item.id)}
leading={<Icon className="h-4 w-4 shrink-0" aria-hidden />}
label={label}
ariaLabel={label}
ariaCurrentPage={active}
onPointerEnter={() => preloadSettingsSection(item.id)}
onFocus={() => preloadSettingsSection(item.id)}
/>
);
})}
</SidebarNavGroup>
))}
</SidebarScrollArea>
</SidebarSection>
</SidebarShell>
);
}

View file

@ -108,9 +108,9 @@ export default function SkillListItem(props: SkillListItemProps) {
}
aria-label={isClickable ? props.addButtonText : undefined}
>
<p className="text-body-sm text-ds-text-neutral-muted-default">
<span className="block text-body-sm text-ds-text-neutral-muted-default">
{props.message}
</p>
</span>
{isClickable && (
<Plus className="h-4 w-4 text-ds-icon-neutral-default-default" />
)}
@ -187,7 +187,7 @@ export default function SkillListItem(props: SkillListItemProps) {
const handleTryInChat = () => {
projectStore?.createProject('new project');
const prompt = `I just added the {{${skill.name}}} skill for Eigent, can you make something amazing with this skill?`;
const prompt = t('agents.try-skill-prompt', { name: skill.name });
navigate(`/?skill_prompt=${encodeURIComponent(prompt)}`);
};
@ -246,9 +246,9 @@ export default function SkillListItem(props: SkillListItemProps) {
className="max-w-sm whitespace-pre-wrap break-words"
>
<div className="w-full cursor-default">
<p className="line-clamp-5 overflow-hidden break-words text-body-sm text-ds-text-neutral-muted-default">
<span className="line-clamp-5 block overflow-hidden break-words text-body-sm text-ds-text-neutral-muted-default">
{skill.description}
</p>
</span>
</div>
</TooltipSimple>
@ -260,7 +260,7 @@ export default function SkillListItem(props: SkillListItemProps) {
className={`px-0 focus:ring-0 ${scopeOpen ? 'opacity-100' : 'opacity-50'}`}
onClick={() => setScopeOpen((prev) => !prev)}
>
Select agent access
{t('agents.select-agent-access')}
<ChevronRight
className={`h-4 w-4 ${scopeOpen ? '-rotate-90' : ''}`}
/>
@ -283,7 +283,7 @@ export default function SkillListItem(props: SkillListItemProps) {
) : (
<Users size={16} className="shrink-0" />
)}
All Agents
{t('agents.all-agents')}
</button>
{allAgents.map((agent) => {

View file

@ -493,9 +493,9 @@ export default function SkillUploadDialog({
<div className="flex flex-col gap-4">
{mode === 'create' ? (
<>
<p className="text-label-sm text-ds-text-neutral-muted-default">
<span className="block text-label-sm text-ds-text-neutral-muted-default">
{t('agents.compose-skill-hint')}
</p>
</span>
<Textarea
variant="none"
value={composeContent}
@ -661,10 +661,12 @@ export default function SkillUploadDialog({
isOpen={conflictDialog.open}
onClose={handleConflictCancel}
onConfirm={handleConflictConfirm}
title={`Replace "${conflictDialog.skillName}" skill?`}
message="There's an existing skill with the same name. Uploading this skill will replace the existing one, which can't be restored."
confirmText="Update and Replace"
cancelText="Cancel"
title={t('agents.replace-skill-title', {
name: conflictDialog.skillName,
})}
message={t('agents.replace-skill-message')}
confirmText={t('agents.update-and-replace')}
cancelText={t('layout.cancel')}
confirmVariant="caution"
/>
)}

View file

@ -14,19 +14,27 @@
import SearchInput from '@/components/Dashboard/SearchInput';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useSkillsStore, type Skill } from '@/store/skillsStore';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { Plus } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import {
SettingsHeaderActions,
useSettingsHeader,
} from '../SettingsHeaderContext';
import SettingsSection from '../SettingsSection';
import SettingsSectionLoading from '../SettingsSectionLoading';
import SettingsSectionPage from '../SettingsSectionPage';
import SkillDeleteDialog from './components/SkillDeleteDialog';
import SkillListItem from './components/SkillListItem';
import SkillUploadDialog from './components/SkillUploadDialog';
export default function Skills() {
const { t } = useTranslation();
const { setHeaderOverride } = useSettingsHeader();
const shouldReduceMotion = useReducedMotion();
const [searchParams, setSearchParams] = useSearchParams();
const { skills, syncFromDisk } = useSkillsStore();
@ -38,6 +46,15 @@ export default function Skills() {
);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [skillToDelete, setSkillToDelete] = useState<Skill | null>(null);
const [activeSkillTab, setActiveSkillTab] = useState('your-skills');
const activeSkillTitle =
activeSkillTab === 'example-skills'
? t('agents.example-skills')
: t('agents.your-skills');
useEffect(() => {
setHeaderOverride({ title: activeSkillTitle, hideTitle: true });
return () => setHeaderOverride(null);
}, [activeSkillTitle, setHeaderOverride]);
// On first mount, sync skills from local SKILL.md files
useEffect(() => {
@ -198,81 +215,74 @@ export default function Skills() {
};
return (
<div className="m-auto flex h-auto w-full flex-1 flex-col">
{/* Header Section */}
<div className="flex w-full items-center justify-between px-6 pb-6 pt-8">
<div className="text-heading-sm font-bold text-ds-text-neutral-default-default">
{t('agents.skills')}
<SettingsSectionPage>
<SettingsHeaderActions>
<Tabs value={activeSkillTab} onValueChange={setActiveSkillTab}>
<TabsList appearance="default">
<TabsTrigger value="your-skills">
<span className="text-body-sm font-semibold text-ds-text-neutral-default-default">
{t('agents.your-skills')}
</span>
</TabsTrigger>
<TabsTrigger value="example-skills">
<span className="text-body-sm font-semibold text-ds-text-neutral-default-default">
{t('agents.example-skills')}
</span>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="ml-auto flex items-center gap-2">
<SearchInput
variant="icon"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t('agents.search-skills')}
/>
<Button
variant="primary"
size="sm"
onClick={() => {
setSkillDialogMode('upload');
setUploadDialogOpen(true);
}}
>
<Plus className="h-3.5 w-3.5" />
{t('agents.add-skill')}
</Button>
</div>
</div>
{/* Content Section */}
<div className="mb-12 flex flex-col gap-6">
<div className="flex w-full flex-col items-center justify-between gap-4 rounded-2xl bg-ds-bg-neutral-default-default px-6 py-4">
<Tabs defaultValue="your-skills" className="w-full">
<div className="z-10 flex w-full items-center justify-between gap-4 border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default">
<TabsList
appearance="border"
className="h-auto flex-1 justify-start"
>
<TabsTrigger value="your-skills">
{t('agents.your-skills')}
</TabsTrigger>
<TabsTrigger value="example-skills">
{t('agents.example-skills')}
</TabsTrigger>
</TabsList>
<div className="mb-2 flex items-center gap-2">
<SearchInput
variant="icon"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('agents.search-skills')}
/>
<Button
variant="primary"
size="sm"
onClick={() => {
setSkillDialogMode('upload');
setUploadDialogOpen(true);
}}
>
<Plus className="h-4 w-4" />
{t('agents.add-skill')}
</Button>
</div>
</div>
<TabsContent value="your-skills" className="mt-4">
{renderYourSkills(
hasCompletedInitialSync && searchQuery.length === 0
)}
</TabsContent>
<TabsContent value="example-skills" className="mt-4">
{exampleSkills.length === 0 ? (
<SkillListItem
variant="placeholder"
message={
searchQuery
? t('agents.no-skills-found')
: t('agents.no-example-skills')
}
/>
) : (
<div className="flex flex-col gap-3">
{exampleSkills.map((skill) => (
<SkillListItem
key={skill.id}
skill={skill}
onDelete={undefined}
/>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
</div>
</SettingsHeaderActions>
<SettingsSection titleVariant="hidden">
{!hasCompletedInitialSync && skills.length === 0 ? (
<SettingsSectionLoading
label={t('setting.loading', {
defaultValue: 'Loading skills',
})}
rows={3}
className="py-0"
/>
) : activeSkillTab === 'your-skills' ? (
renderYourSkills(hasCompletedInitialSync && searchQuery.length === 0)
) : exampleSkills.length === 0 ? (
<SkillListItem
variant="placeholder"
message={
searchQuery
? t('agents.no-skills-found')
: t('agents.no-example-skills')
}
/>
) : (
<div className="flex flex-col gap-3">
{exampleSkills.map((skill) => (
<SkillListItem
key={skill.id}
skill={skill}
onDelete={undefined}
/>
))}
</div>
)}
</SettingsSection>
{/* Upload Dialog */}
<SkillUploadDialog
open={uploadDialogOpen}
@ -287,6 +297,6 @@ export default function Skills() {
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
/>
</div>
</SettingsSectionPage>
);
}

Some files were not shown because too many files have changed in this diff Show more