mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-17 20:53:30 +00:00
Merge codex/refactor-session-side-panel into refactor-dashboard
This commit is contained in:
commit
7abbfcacf5
203 changed files with 8777 additions and 6958 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
330
src/components/Home/HomeHeader.tsx
Normal file
330
src/components/Home/HomeHeader.tsx
Normal 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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
33
src/components/Home/HomeSections.tsx
Normal file
33
src/components/Home/HomeSections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/components/Home/HomeSidebarNav.tsx
Normal file
96
src/components/Home/HomeSidebarNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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 },
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
55
src/components/Home/hooks/useHomeSection.ts
Normal file
55
src/components/Home/hooks/useHomeSection.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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§ion=${tabId}`, { replace: true });
|
||||
};
|
||||
|
||||
const handleDelete = (id: string, callback?: () => void) => {
|
||||
setCurHistoryId(id);
|
||||
setDeleteModalOpen(true);
|
||||
|
|
@ -198,6 +159,7 @@ export default function HomeHub() {
|
|||
|
||||
const hubContextValue = useMemo(
|
||||
() => ({
|
||||
sectionCounts,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
searchQuery,
|
||||
|
|
@ -226,6 +188,7 @@ export default function HomeHub() {
|
|||
// are infrequent; include only the data dependencies React tracks here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
sectionCounts,
|
||||
viewMode,
|
||||
searchQuery,
|
||||
sortBy,
|
||||
|
|
@ -265,20 +228,7 @@ export default function HomeHub() {
|
|||
cancelText={t('layout.cancel')}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
133
src/components/Layout/AppShellLayout.tsx
Normal file
133
src/components/Layout/AppShellLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
>
|
||||
67
src/components/Layout/AppSidebar/SidebarBrandHeader.tsx
Normal file
67
src/components/Layout/AppSidebar/SidebarBrandHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/components/Layout/AppSidebar/SidebarScrollArea.tsx
Normal file
83
src/components/Layout/AppSidebar/SidebarScrollArea.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/components/Layout/AppSidebar/SidebarShell.tsx
Normal file
111
src/components/Layout/AppSidebar/SidebarShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/components/Layout/AppSidebar/constants.ts
Normal file
27
src/components/Layout/AppSidebar/constants.ts
Normal 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';
|
||||
47
src/components/Layout/AppSidebar/index.ts
Normal file
47
src/components/Layout/AppSidebar/index.ts
Normal 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';
|
||||
81
src/components/Layout/ContentHeader.tsx
Normal file
81
src/components/Layout/ContentHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 row’s right edge,
|
||||
not the inner title button. avoidCollisions={false} stops Floating UI
|
||||
from treating the sidebar’s overflow clip as the boundary and shifting
|
||||
the tip back over the row. */}
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
74
src/components/ProjectPageSidebar/TriggerNavTab.tsx
Normal file
74
src/components/ProjectPageSidebar/TriggerNavTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
41
src/components/Settings/Channels/index.tsx
Normal file
41
src/components/Settings/Channels/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
39
src/components/Settings/Extension/index.tsx
Normal file
39
src/components/Settings/Extension/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
41
src/components/Settings/Memory/index.tsx
Normal file
41
src/components/Settings/Memory/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
122
src/components/Settings/Privacy/index.tsx
Normal file
122
src/components/Settings/Privacy/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/Settings/SettingsContentShell.tsx
Normal file
44
src/components/Settings/SettingsContentShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/components/Settings/SettingsHeader.tsx
Normal file
95
src/components/Settings/SettingsHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/components/Settings/SettingsHeaderContext.tsx
Normal file
107
src/components/Settings/SettingsHeaderContext.tsx
Normal 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;
|
||||
}
|
||||
62
src/components/Settings/SettingsSection.tsx
Normal file
62
src/components/Settings/SettingsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/components/Settings/SettingsSectionContent.tsx
Normal file
171
src/components/Settings/SettingsSectionContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/Settings/SettingsSectionLoading.tsx
Normal file
44
src/components/Settings/SettingsSectionLoading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/components/Settings/SettingsSectionPage.tsx
Normal file
32
src/components/Settings/SettingsSectionPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/components/Settings/SettingsSidebar.tsx
Normal file
85
src/components/Settings/SettingsSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue