add changes to fix enhancement for layout ui

This commit is contained in:
Douglas 2026-05-20 13:37:44 +01:00
parent 17808242d8
commit afd57e98a5
17 changed files with 578 additions and 229 deletions

View file

@ -25,6 +25,15 @@ 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 = [
'projects',
'agents',
@ -86,6 +95,9 @@ export function HistoryTabsNav({
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;
@ -97,11 +109,17 @@ export function HistoryTabsNav({
const r = el.getBoundingClientRect();
const nr = nav.getBoundingClientRect();
const gapPx = 8;
setActiveLine({
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(() => {
@ -186,23 +204,32 @@ export function HistoryTabsNav({
}}
style={{ position: 'absolute' }}
/>
<motion.div
aria-hidden
className="bg-ds-bg-brand-default-default h-0.5 pointer-events-none absolute z-[11] rounded-full"
initial={false}
animate={{
left: activeLine.left,
top: activeLine.top,
width: activeLine.width,
}}
transition={{
type: 'spring',
stiffness: 420,
damping: 34,
mass: 0.55,
}}
style={{ position: 'absolute' }}
/>
{activeLine.width > 0 && (
<motion.div
aria-hidden
className="bg-ds-bg-brand-default-default h-0.5 pointer-events-none absolute z-[11] rounded-full"
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

View file

@ -0,0 +1,147 @@
// ========= 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 } from '@/api/http';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Check, Copy, LoaderCircle } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
interface InviteCodeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function InviteCodeDialog({
open,
onOpenChange,
}: InviteCodeDialogProps) {
const { t } = useTranslation();
const cachedCodeRef = useRef<string | null>(null);
const [inviteCode, setInviteCode] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!open) {
setCopied(false);
return;
}
if (cachedCodeRef.current) {
setInviteCode(cachedCodeRef.current);
setLoading(false);
void navigator.clipboard.writeText(cachedCodeRef.current);
return;
}
let cancelled = false;
setInviteCode(null);
setLoading(true);
void proxyFetchGet('/api/v1/user/invite_code')
.then(async (res: { invite_code?: string }) => {
if (cancelled) return;
if (res?.invite_code) {
cachedCodeRef.current = res.invite_code;
setInviteCode(res.invite_code);
await navigator.clipboard.writeText(res.invite_code);
} else {
toast.error(t('layout.failed-to-get-invite-code'));
onOpenChange(false);
}
})
.catch((error) => {
if (cancelled) return;
console.error('Failed to get referral link:', error);
toast.error(t('layout.failed-to-get-invitation-link'));
onOpenChange(false);
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [open, onOpenChange, t]);
const handleCopyCode = useCallback(async () => {
if (!inviteCode) return;
try {
await navigator.clipboard.writeText(inviteCode);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
/* Clipboard may be unavailable */
}
}, [inviteCode]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
size="sm"
overlayClassName="pointer-events-none invisible backdrop-blur-none"
className="gap-0 !rounded-xl border-ds-border-neutral-strong-default !bg-ds-bg-neutral-strong-default p-0 shadow-sm sm:max-w-[360px] min-h-[360px] border"
>
<div className="gap-md bg-ds-bg-neutral-strong-default px-md pt-2 pb-md min-h-0 mt-10 flex h-full flex-1 flex-col items-center justify-center text-center">
<span className="text-heading-sm font-bold text-ds-text-neutral-default-default m-0">
{t('layout.invitation-code-copied-title')}
</span>
<span className="text-body-sm text-ds-text-neutral-subtle-default m-0 max-w-60">
{t('layout.invitation-code-copied-description')}
</span>
<div className="gap-4 min-h-40 flex h-full w-full flex-1 flex-col items-center justify-center">
{loading ? (
<LoaderCircle
className="h-8 w-8 animate-spin text-ds-icon-neutral-muted-default"
aria-label={t('layout.loading', { defaultValue: 'Loading' })}
/>
) : (
inviteCode && (
<div className="gap-4 bg-ds-bg-neutral-subtle-default rounded-2xl px-8 py-4 flex flex-col items-center justify-center">
<p
className="text-heading-base font-bold text-ds-text-brand-muted-default font-mono m-0 tracking-wide"
aria-label={t('layout.invitation-code-label')}
>
{inviteCode}
</p>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void handleCopyCode()}
>
{copied ? (
<Check className="h-4 w-4 shrink-0" aria-hidden />
) : (
<Copy className="h-4 w-4 shrink-0" aria-hidden />
)}
{copied
? t('layout.invitation-code-copy-success')
: t('layout.copy-code')}
</Button>
</div>
)
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -13,6 +13,7 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
export type NotificationPanelProps = {
@ -35,15 +36,15 @@ export default function NotificationPanel({
return () => window.removeEventListener('keydown', onKeyDown);
}, [open, onOpenChange]);
if (!open) {
if (!open || typeof document === 'undefined') {
return null;
}
return (
return createPortal(
<>
<button
type="button"
className="inset-0 fixed z-40 cursor-default bg-transparent backdrop-blur-[1px]"
className="inset-0 fixed z-[99] cursor-default bg-transparent backdrop-blur-[1px]"
aria-label={t('layout.notification-panel-dismiss', {
defaultValue: 'Dismiss',
})}
@ -54,7 +55,7 @@ export default function NotificationPanel({
role="dialog"
aria-modal="true"
aria-labelledby="notification-panel-heading"
className="right-2 top-10 bottom-2 min-h-0 ease-out animate-in fade-in-0 slide-in-from-right-2 rounded-2xl bg-ds-bg-neutral-default-default fixed z-50 flex w-[300px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden duration-200"
className="right-3 top-12 bottom-3 min-h-0 ease-out animate-in fade-in-0 slide-in-from-right-2 rounded-2xl bg-ds-bg-neutral-default-default border-ds-border-neutral-subtle-disabled fixed z-[100] flex w-[300px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden border-[0.5px] border-solid duration-200"
>
<div className="min-h-0 pl-3 pr-1.5 py-3 flex flex-1 flex-col overflow-y-auto">
<span
@ -68,6 +69,7 @@ export default function NotificationPanel({
</p>
</div>
</div>
</>
</>,
document.body
);
}

View file

@ -12,24 +12,19 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import {
fetchDelete,
fetchPut,
proxyFetchDelete,
proxyFetchGet,
} from '@/api/http';
import { fetchDelete, fetchPut, proxyFetchDelete } from '@/api/http';
import giftWhiteIcon from '@/assets/gift-white.svg';
import giftIcon from '@/assets/gift.svg';
import eigentAppIconBlack from '@/assets/logo/icon_black.svg';
import eigentAppIconWhite from '@/assets/logo/icon_white.svg';
import EndNoticeDialog from '@/components/Dialog/EndNotice';
import InviteCodeDialog from '@/components/Dialog/InviteCodeDialog';
import ReportBugDialog from '@/components/Dialog/ReportBugDialog';
import NotificationPanel from '@/components/Notification';
import { Button } from '@/components/ui/button';
import { TooltipSimple } from '@/components/ui/tooltip';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useHost } from '@/host';
import { SITE_URL } from '@/lib';
import { share } from '@/lib/share';
import { useAuthStore } from '@/store/authStore';
import { useInstallationUI } from '@/store/installationStore';
@ -41,8 +36,6 @@ import { AnimatePresence, motion } from 'framer-motion';
import {
Archive,
Bell,
ChevronLeft,
ChevronRight,
CircleHelp,
Minus,
PanelLeft,
@ -132,8 +125,9 @@ function HeaderWin() {
const [platform, setPlatform] = useState<string>('');
const navigate = useNavigate();
const location = useLocation();
const { canGoBack, canGoForward } = useStackNavigationBounds();
const { canGoBack } = useStackNavigationBounds();
const [reportBugOpen, setReportBugOpen] = useState(false);
const [inviteCodeDialogOpen, setInviteCodeDialogOpen] = useState(false);
const [endDialogOpen, setEndDialogOpen] = useState(false);
const [endProjectLoading, setEndProjectLoading] = useState(false);
//Get Chatstore for the active project's task
@ -218,20 +212,8 @@ function HeaderWin() {
return <div>Loading...</div>;
}
const getReferFriendsLink = async () => {
try {
const res: any = await proxyFetchGet('/api/v1/user/invite_code');
if (res?.invite_code) {
const inviteLink = `${SITE_URL}/signup?invite_code=${res.invite_code}`;
await navigator.clipboard.writeText(inviteLink);
toast.success(t('layout.invitation-link-copied'));
} else {
toast.error(t('layout.failed-to-get-invite-code'));
}
} catch (error) {
console.error('Failed to get referral link:', error);
toast.error(t('layout.failed-to-get-invitation-link'));
}
const openInviteCodeDialog = () => {
setInviteCodeDialogOpen(true);
};
const handleShare = async (taskId: string) => {
@ -444,8 +426,8 @@ function HeaderWin() {
</TooltipSimple>
</div>
{/* Middle: full width on project home only (/) — left: nav + title; right: project + utilities */}
<div className="min-h-0 min-w-0 relative z-0 flex h-full flex-1">
{/* Middle: full width on project home only (/) — nav + title */}
<div className="no-drag min-h-0 min-w-0 h-7 relative z-50 flex w-full">
<AnimatePresence initial={false}>
{isHomeRoute && (
<motion.div
@ -454,173 +436,27 @@ function HeaderWin() {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={topBarCrossfade}
className="drag inset-0 min-w-0 gap-2 absolute z-10 flex items-center justify-between"
className="drag inset-0 min-w-0 absolute z-10 flex items-center"
>
<div className="min-w-0 min-h-0 relative z-50 flex h-full items-center">
<div className="no-drag min-w-0 flex items-center">
<div className="min-w-0 min-h-0 border-ds-border-neutral-subtle-default ml-1 pl-1 relative z-50 flex h-full items-center border-y-0 border-r-0 border-l border-solid">
<div className="min-w-0 flex-1 overflow-hidden rounded-full">
<TooltipSimple
content={t('layout.back')}
content={activeTaskTitle}
side="bottom"
align="center"
>
<Button
variant="ghost"
size="sm"
buttonContent="icon-only"
className="no-drag shrink-0 rounded-full"
disabled={!canGoBack}
onClick={() => navigate(-1)}
aria-label={t('layout.back')}
>
<ChevronLeft aria-hidden />
</Button>
</TooltipSimple>
<TooltipSimple
content={t('layout.forward')}
side="bottom"
align="center"
>
<Button
variant="ghost"
size="sm"
buttonContent="icon-only"
className="no-drag shrink-0 rounded-full"
disabled={!canGoForward}
onClick={() => navigate(1)}
aria-label={t('layout.forward')}
>
<ChevronRight aria-hidden />
</Button>
</TooltipSimple>
<div className="min-w-0 flex-1 overflow-hidden rounded-full">
<TooltipSimple
content={
activeTaskTitle === t('layout.new-project')
? t('layout.new-project')
: activeTaskTitle
}
side="bottom"
align="center"
>
<button
id="active-task-title-btn"
type="button"
className="no-drag min-w-0 px-2 text-label-sm font-bold !text-ds-text-neutral-default-default focus-visible:ring-ds-ring-brand-default-focus/50 hover:bg-ds-bg-neutral-default-hover active:bg-ds-bg-neutral-default-active flex min-h-[28px] max-w-[300px] flex-1 items-center text-left outline-none focus-visible:ring-[3px]"
onClick={toggleHistorySidebar}
aria-expanded={historySidebarOpen}
aria-haspopup="dialog"
>
<span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{activeTaskTitle}
</span>
</button>
</TooltipSimple>
</div>
</div>
</div>
<div className="no-drag gap-0 z-50 flex h-full shrink-0 items-center">
<div className="gap-1 pr-2 border-ds-border-neutral-subtle-default flex items-center border-y-0 border-r border-l-0 border-solid">
{showEndProject && (
<TooltipSimple
content={t('layout.achieve-project')}
side="bottom"
align="end"
>
<Button
type="button"
onClick={() => setEndDialogOpen(true)}
variant="ghost"
size="sm"
buttonContent="icon-only"
buttonRadius="full"
tone="error"
aria-label={t('layout.achieve-project')}
>
<Archive aria-hidden />
</Button>
</TooltipSimple>
)}
{chatStore.activeTaskId &&
chatStore.tasks[chatStore.activeTaskId as string]
?.status === ChatTaskStatus.FINISHED && (
<TooltipSimple
content={t('layout.share')}
side="bottom"
align="end"
>
<Button
onClick={() =>
handleShare(chatStore.activeTaskId as string)
}
variant="ghost"
size="sm"
buttonContent="icon-only"
buttonRadius="full"
tone="information"
aria-label={t('layout.share')}
>
<Share aria-hidden />
</Button>
</TooltipSimple>
)}
</div>
<div className="gap-1 pl-2 flex items-center">
<TooltipSimple
content={t('layout.notifications')}
side="bottom"
align="end"
>
<Button
<button
id="active-task-title-btn"
type="button"
variant="ghost"
size="sm"
className="no-drag rounded-full"
aria-label={t('layout.notifications')}
aria-expanded={notificationPanelOpen}
aria-controls="notification-panel"
onClick={() => setNotificationPanelOpen((open) => !open)}
buttonContent="icon-only"
className="no-drag min-w-0 px-2 text-label-sm font-bold !text-ds-text-neutral-default-default focus-visible:ring-ds-ring-brand-default-focus/50 hover:bg-ds-bg-neutral-default-hover active:bg-ds-bg-neutral-default-active flex min-h-[28px] max-w-[300px] flex-1 items-center text-left outline-none focus-visible:ring-[3px]"
onClick={toggleHistorySidebar}
aria-expanded={historySidebarOpen}
aria-haspopup="dialog"
>
<Bell aria-hidden />
</Button>
</TooltipSimple>
<TooltipSimple
content={t('layout.support')}
side="bottom"
align="end"
>
<Button
type="button"
variant="ghost"
size="sm"
className="no-drag rounded-full"
aria-label={t('layout.support')}
onClick={() => setReportBugOpen(true)}
buttonContent="icon-only"
>
<CircleHelp aria-hidden />
</Button>
</TooltipSimple>
<TooltipSimple
content={t('layout.refer-friends')}
side="bottom"
align="end"
>
<Button
onClick={getReferFriendsLink}
variant="ghost"
size="sm"
className="no-drag rounded-full"
buttonContent="icon-only"
>
<img
src={appearance === 'dark' ? giftWhiteIcon : giftIcon}
alt="gift-icon"
width={16}
height={16}
/>
</Button>
<span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{activeTaskTitle}
</span>
</button>
</TooltipSimple>
</div>
</div>
@ -632,31 +468,118 @@ function HeaderWin() {
)}
</div>
{/* Trailing: update + settings (home) or back (history) — always at the end */}
{/* Trailing: project actions (home only) + utilities + settings/back + update */}
<div
className={`${
platform === 'darwin' && 'px-1'
} no-drag gap-1 relative z-50 flex h-full shrink-0 items-center`}
} no-drag h-7 relative z-50 flex shrink-0 items-center`}
>
{packageUpdateAvailable && (
{isHomeRoute && (
<div className="gap-1 mr-2 pr-2 border-ds-border-neutral-subtle-default flex items-center border-y-0 border-r border-l-0 border-solid">
{showEndProject && (
<TooltipSimple
content={t('layout.achieve-project')}
side="bottom"
align="end"
>
<Button
type="button"
onClick={() => setEndDialogOpen(true)}
variant="ghost"
size="sm"
buttonContent="icon-only"
buttonRadius="full"
tone="error"
aria-label={t('layout.achieve-project')}
>
<Archive aria-hidden />
</Button>
</TooltipSimple>
)}
{chatStore.activeTaskId &&
chatStore.tasks[chatStore.activeTaskId as string]?.status ===
ChatTaskStatus.FINISHED && (
<TooltipSimple
content={t('layout.share')}
side="bottom"
align="end"
>
<Button
onClick={() =>
handleShare(chatStore.activeTaskId as string)
}
variant="ghost"
size="sm"
buttonContent="icon-only"
buttonRadius="full"
tone="information"
aria-label={t('layout.share')}
>
<Share aria-hidden />
</Button>
</TooltipSimple>
)}
</div>
)}
<div className="gap-1 flex h-full shrink-0 items-center">
<TooltipSimple
content={t('layout.update', { defaultValue: 'Update' })}
content={t('layout.notifications')}
side="bottom"
align="end"
>
<Button
type="button"
variant="primary"
variant="ghost"
size="sm"
className="no-drag px-3 shrink-0 rounded-full"
onClick={handleStartDownload}
aria-label={t('layout.update', { defaultValue: 'Update' })}
className="no-drag rounded-full"
aria-label={t('layout.notifications')}
aria-expanded={notificationPanelOpen}
aria-controls="notification-panel"
onClick={() => setNotificationPanelOpen((open) => !open)}
buttonContent="icon-only"
>
{t('layout.update', { defaultValue: 'Update' })}
<Bell aria-hidden />
</Button>
</TooltipSimple>
<TooltipSimple
content={t('layout.support')}
side="bottom"
align="end"
>
<Button
type="button"
variant="ghost"
size="sm"
className="no-drag rounded-full"
aria-label={t('layout.support')}
onClick={() => setReportBugOpen(true)}
buttonContent="icon-only"
>
<CircleHelp aria-hidden />
</Button>
</TooltipSimple>
<TooltipSimple
content={t('layout.refer-friends')}
side="bottom"
align="end"
>
<Button
onClick={openInviteCodeDialog}
variant="ghost"
size="sm"
className="no-drag rounded-full"
buttonContent="icon-only"
aria-label={t('layout.refer-friends')}
>
<img
src={appearance === 'dark' ? giftWhiteIcon : giftIcon}
alt=""
width={16}
height={16}
aria-hidden
/>
</Button>
</TooltipSimple>
)}
<div className="flex h-full shrink-0 items-center">
<AnimatePresence mode="wait" initial={false}>
{isHomeRoute ? (
<motion.div
@ -713,6 +636,24 @@ function HeaderWin() {
</motion.div>
)}
</AnimatePresence>
{packageUpdateAvailable && (
<TooltipSimple
content={t('layout.update', { defaultValue: 'Update' })}
side="bottom"
align="end"
>
<Button
type="button"
variant="primary"
size="sm"
className="no-drag px-3 shrink-0 rounded-full"
onClick={handleStartDownload}
aria-label={t('layout.update', { defaultValue: 'Update' })}
>
{t('layout.update', { defaultValue: 'Update' })}
</Button>
</TooltipSimple>
)}
</div>
</div>
@ -754,6 +695,10 @@ function HeaderWin() {
onOpenChange={setNotificationPanelOpen}
/>
<ReportBugDialog open={reportBugOpen} onOpenChange={setReportBugOpen} />
<InviteCodeDialog
open={inviteCodeDialogOpen}
onOpenChange={setInviteCodeDialogOpen}
/>
</div>
);
}

View file

@ -149,6 +149,11 @@
"log-saved": "تم حفظ السجل:",
"export-error": "خطأ في التصدير:",
"invitation-link-copied": "تم نسخ رابط الدعوة!",
"invitation-code-copied-title": "تم نسخ رمز الدعوة",
"invitation-code-copied-description": "شارك هذا الرمز مع صديق لاستخدامه عند التسجيل.",
"invitation-code-label": "رمز الدعوة",
"copy-code": "نسخ الرمز",
"invitation-code-copy-success": "تم النسخ!",
"failed-to-get-invite-code": "فشل في الحصول على رمز الدعوة",
"failed-to-get-invitation-link": "فشل في الحصول على رابط الدعوة",
"no-active-project-to-end": "لا يوجد مشروع نشط لإنهائه",

View file

@ -149,6 +149,11 @@
"log-saved": "Protokoll gespeichert:",
"export-error": "Export-Fehler:",
"invitation-link-copied": "Einladungslink kopiert!",
"invitation-code-copied-title": "Einladungscode kopiert",
"invitation-code-copied-description": "Teile diesen Code mit einem Freund, damit er ihn bei der Registrierung verwenden kann.",
"invitation-code-label": "Einladungscode",
"copy-code": "Code kopieren",
"invitation-code-copy-success": "Kopiert!",
"failed-to-get-invite-code": "Fehler beim Abrufen des Einladungscodes",
"failed-to-get-invitation-link": "Fehler beim Abrufen des Einladungslinks",
"no-active-project-to-end": "Kein aktives Projekt zum Beenden",

View file

@ -168,6 +168,11 @@
"log-saved": "log saved:",
"export-error": "export error:",
"invitation-link-copied": "Invitation link copied!",
"invitation-code-copied-title": "Invitation Code Copied",
"invitation-code-copied-description": "Share this code with a friend to use when they sign up.",
"invitation-code-label": "Invitation code",
"copy-code": "Copy code",
"invitation-code-copy-success": "Copied!",
"failed-to-get-invite-code": "Failed to get invite code",
"failed-to-get-invitation-link": "Failed to get invitation link",
"no-active-project-to-end": "No active project to end",

View file

@ -149,6 +149,11 @@
"log-saved": "registro guardado:",
"export-error": "error de exportación:",
"invitation-link-copied": "¡Enlace de invitación copiado!",
"invitation-code-copied-title": "Código de invitación copiado",
"invitation-code-copied-description": "Comparte este código con un amigo para que lo use al registrarse.",
"invitation-code-label": "Código de invitación",
"copy-code": "Copiar código",
"invitation-code-copy-success": "¡Copiado!",
"failed-to-get-invite-code": "Error al obtener código de invitación",
"failed-to-get-invitation-link": "Error al obtener enlace de invitación",
"no-active-project-to-end": "No hay proyecto activo para finalizar",

View file

@ -149,6 +149,11 @@
"log-saved": "journal sauvegardé :",
"export-error": "erreur d'export :",
"invitation-link-copied": "Lien d'invitation copié !",
"invitation-code-copied-title": "Code d'invitation copié",
"invitation-code-copied-description": "Partagez ce code avec un ami pour qu'il l'utilise lors de son inscription.",
"invitation-code-label": "Code d'invitation",
"copy-code": "Copier le code",
"invitation-code-copy-success": "Copié !",
"failed-to-get-invite-code": "Échec de l'obtention du code d'invitation",
"failed-to-get-invitation-link": "Échec de l'obtention du lien d'invitation",
"no-active-project-to-end": "Aucun projet actif à terminer",

View file

@ -149,6 +149,11 @@
"log-saved": "log salvato:",
"export-error": "errore di esportazione:",
"invitation-link-copied": "Link di invito copiato!",
"invitation-code-copied-title": "Codice invito copiato",
"invitation-code-copied-description": "Condividi questo codice con un amico per utilizzarlo durante la registrazione.",
"invitation-code-label": "Codice invito",
"copy-code": "Copia codice",
"invitation-code-copy-success": "Copiato!",
"failed-to-get-invite-code": "Impossibile ottenere il codice di invito",
"failed-to-get-invitation-link": "Impossibile ottenere il link di invito",
"no-active-project-to-end": "Nessun progetto attivo da terminare",

View file

@ -149,6 +149,11 @@
"log-saved": "ログが保存されました:",
"export-error": "エクスポートエラー:",
"invitation-link-copied": "招待リンクがコピーされました!",
"invitation-code-copied-title": "招待コードをコピーしました",
"invitation-code-copied-description": "このコードを友達に共有して、登録時に使用してもらいましょう。",
"invitation-code-label": "招待コード",
"copy-code": "コードをコピー",
"invitation-code-copy-success": "コピーしました!",
"failed-to-get-invite-code": "招待コードの取得に失敗しました",
"failed-to-get-invitation-link": "招待リンクの取得に失敗しました",
"no-active-project-to-end": "終了するアクティブなプロジェクトがありません",

View file

@ -149,6 +149,11 @@
"log-saved": "로그 저장됨:",
"export-error": "내보내기 오류:",
"invitation-link-copied": "초대 링크가 복사되었습니다!",
"invitation-code-copied-title": "초대 코드가 복사되었습니다",
"invitation-code-copied-description": "이 코드를 친구에게 공유하여 가입 시 사용하도록 하세요.",
"invitation-code-label": "초대 코드",
"copy-code": "코드 복사",
"invitation-code-copy-success": "복사되었습니다!",
"failed-to-get-invite-code": "초대 코드를 가져오는데 실패했습니다",
"failed-to-get-invitation-link": "초대 링크를 가져오는데 실패했습니다",
"no-active-project-to-end": "종료할 활성 프로젝트가 없습니다",

View file

@ -149,6 +149,11 @@
"log-saved": "журнал сохранен:",
"export-error": "ошибка экспорта:",
"invitation-link-copied": "Ссылка-приглашение скопирована!",
"invitation-code-copied-title": "Код приглашения скопирован",
"invitation-code-copied-description": "Поделитесь этим кодом с другом, чтобы он использовал его при регистрации.",
"invitation-code-label": "Код приглашения",
"copy-code": "Скопировать код",
"invitation-code-copy-success": "Скопировано!",
"failed-to-get-invite-code": "Не удалось получить код приглашения",
"failed-to-get-invitation-link": "Не удалось получить ссылку-приглашение",
"no-active-project-to-end": "Нет активного проекта для завершения",

View file

@ -168,6 +168,11 @@
"log-saved": "日志已保存:",
"export-error": "导出错误:",
"invitation-link-copied": "邀请链接已复制!",
"invitation-code-copied-title": "邀请码已复制",
"invitation-code-copied-description": "将此邀请码分享给好友,他们注册时可填写使用。",
"invitation-code-label": "邀请码",
"copy-code": "复制邀请码",
"invitation-code-copy-success": "已复制!",
"failed-to-get-invite-code": "获取邀请码失败",
"failed-to-get-invitation-link": "获取邀请链接失败",
"no-active-project-to-end": "没有活动项目可结束",

View file

@ -151,6 +151,11 @@
"log-saved": "日誌已儲存:",
"export-error": "匯出錯誤:",
"invitation-link-copied": "邀請連結已複製!",
"invitation-code-copied-title": "邀請碼已複製",
"invitation-code-copied-description": "將此邀請碼分享給好友,他們註冊時可填寫使用。",
"invitation-code-label": "邀請碼",
"copy-code": "複製邀請碼",
"invitation-code-copy-success": "已複製!",
"failed-to-get-invite-code": "取得邀請碼失敗",
"failed-to-get-invitation-link": "取得邀請連結失敗",
"no-active-project-to-end": "沒有活動專案可結束",

View file

@ -0,0 +1,128 @@
// ========= 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 { AnimatePresence, motion } from 'framer-motion';
import type { ReactNode } from 'react';
export type ConfigCardRingStatus = 'idle' | 'configuring' | 'success' | 'error';
const RING_OFFSET_REST_PX = 1;
const RING_OFFSET_BOUNCE_PX = 3;
const BORDER_COLOR: Record<Exclude<ConfigCardRingStatus, 'idle'>, string> = {
configuring: 'var(--ds-border-neutral-subtle-disabled)',
success: 'var(--ds-border-success-default-default)',
error: 'var(--ds-border-error-default-default)',
};
function ringInset(px: number): string {
return `${-px}px`;
}
const CONFIGURING_TRANSITION = {
inset: {
duration: 1.2,
repeat: Infinity,
ease: 'easeInOut' as const,
},
borderColor: { duration: 0.3, ease: 'easeOut' as const },
opacity: { duration: 0.2 },
};
const SUCCESS_TRANSITION = {
inset: { duration: 0.5, ease: [0.4, 0, 0.2, 1] as const },
borderColor: { duration: 0.5, ease: [0.4, 0, 0.2, 1] as const },
opacity: { duration: 0.2 },
};
const ERROR_TRANSITION = {
inset: { duration: 0.4, ease: [0.4, 0, 0.2, 1] as const },
borderColor: { duration: 0.4, ease: [0.4, 0, 0.2, 1] as const },
opacity: { duration: 1, ease: 'easeInOut' as const },
};
function getRingMotionProps(status: Exclude<ConfigCardRingStatus, 'idle'>) {
switch (status) {
case 'configuring':
return {
animate: {
inset: [
ringInset(RING_OFFSET_REST_PX),
ringInset(RING_OFFSET_BOUNCE_PX),
ringInset(RING_OFFSET_REST_PX),
],
borderColor: BORDER_COLOR.configuring,
opacity: 1,
},
transition: CONFIGURING_TRANSITION,
};
case 'success':
return {
animate: {
inset: ringInset(RING_OFFSET_REST_PX),
borderColor: BORDER_COLOR.success,
opacity: 1,
},
transition: SUCCESS_TRANSITION,
};
case 'error':
return {
animate: {
inset: ringInset(RING_OFFSET_REST_PX),
borderColor: BORDER_COLOR.error,
opacity: [1, 0.2, 1],
},
transition: ERROR_TRANSITION,
};
}
}
export function ConfigModelCard({
status,
children,
className,
}: {
status: ConfigCardRingStatus;
children: ReactNode;
className?: string;
}) {
const showRing = status !== 'idle';
const ringMotion = showRing ? getRingMotionProps(status) : null;
return (
<div className={cn('relative w-full', className)}>
<AnimatePresence>
{ringMotion && (
<motion.div
key="config-card-ring"
className="rounded-2xl pointer-events-none absolute z-0 border-2 border-solid"
initial={{
inset: ringInset(RING_OFFSET_REST_PX),
borderColor: BORDER_COLOR.configuring,
opacity: 0,
}}
animate={ringMotion.animate}
exit={{ opacity: 0, transition: { duration: 0.2 } }}
transition={ringMotion.transition}
/>
)}
</AnimatePresence>
<div className="rounded-2xl bg-ds-bg-neutral-subtle-default relative z-[1] flex w-full flex-col">
{children}
</div>
</div>
);
}

View file

@ -61,7 +61,7 @@ import {
Server,
Settings,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
@ -72,6 +72,7 @@ import {
needsInvertModelImage,
} from '@/shared/modelProviderImages';
import { ConfigModelCard, type ConfigCardRingStatus } from './ConfigModelCard';
import {
fetchProviderModels,
loadCachedModels,
@ -152,6 +153,24 @@ export default function SettingModels() {
);
const [showSecret, setShowSecret] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState<number | null>(null);
const [configCardRing, setConfigCardRing] =
useState<ConfigCardRingStatus>('idle');
const configCardRingResetRef = useRef<ReturnType<typeof setTimeout> | null>(
null
);
const showConfigCardRing = useCallback((status: ConfigCardRingStatus) => {
if (configCardRingResetRef.current) {
clearTimeout(configCardRingResetRef.current);
configCardRingResetRef.current = null;
}
setConfigCardRing(status);
if (status === 'success' || status === 'error') {
configCardRingResetRef.current = setTimeout(() => {
setConfigCardRing('idle');
configCardRingResetRef.current = null;
}, 1000);
}
}, []);
const [errors, setErrors] = useState<
{
apiKey?: string;
@ -447,6 +466,15 @@ export default function SettingModels() {
}
}, [items, modelType, fetchModelsForPlatform]);
useEffect(
() => () => {
if (configCardRingResetRef.current) {
clearTimeout(configCardRingResetRef.current);
}
},
[]
);
// Get current default model display text
const getDefaultModelDisplayText = (): string => {
if (cloudPrefer) {
@ -600,8 +628,12 @@ export default function SettingModels() {
newErrors[idx].model_type = '';
}
setErrors(newErrors);
if (hasError) return;
if (hasError) {
showConfigCardRing('error');
return;
}
showConfigCardRing('configuring');
setLoading(idx);
const item = items[idx];
let external: any = {};
@ -637,6 +669,8 @@ export default function SettingModels() {
next[idx].apiKey = getValidateMessage(res);
return next;
});
showConfigCardRing('error');
setLoading(null);
return;
}
console.log(res);
@ -649,9 +683,9 @@ export default function SettingModels() {
next[idx].apiKey = getValidateMessage(e);
return next;
});
return;
} finally {
showConfigCardRing('error');
setLoading(null);
return;
}
const data: any = {
@ -719,12 +753,17 @@ export default function SettingModels() {
} else {
handleSwitch(idx, true);
}
showConfigCardRing('success');
} catch (e) {
console.error('Error saving provider:', e);
showConfigCardRing('error');
} finally {
setLoading(null);
}
};
const handleLocalVerify = async () => {
showConfigCardRing('configuring');
setLocalVerifying(true);
setLocalError(null);
setLocalInputError(false);
@ -751,12 +790,14 @@ export default function SettingModels() {
setLocalError(t('setting.endpoint-url-can-not-be-empty'));
setLocalInputError(true);
setLocalVerifying(false);
showConfigCardRing('error');
return;
}
if (!currentType) {
setLocalError(t('setting.model-type-can-not-be-empty'));
setLocalInputError(true);
setLocalVerifying(false);
showConfigCardRing('error');
return;
}
try {
@ -830,6 +871,7 @@ export default function SettingModels() {
},
});
showConfigCardRing('error');
return;
}
console.log(res);
@ -844,6 +886,7 @@ export default function SettingModels() {
},
},
});
showConfigCardRing('error');
return;
}
}
@ -895,11 +938,13 @@ export default function SettingModels() {
}
await fetchModelsForPlatform(localPlatform, currentEndpoint);
showConfigCardRing('success');
} catch (e: any) {
setLocalError(
e.message || t('setting.verification-failed-please-check-endpoint-url')
);
setLocalInputError(true);
showConfigCardRing('error');
} finally {
setLocalVerifying(false);
}
@ -1482,7 +1527,7 @@ export default function SettingModels() {
const canSwitch = !!form[idx].provider_id;
return (
<div className="flex w-full flex-col rounded-2xl bg-ds-bg-neutral-subtle-default">
<ConfigModelCard status={configCardRing}>
<div className="mx-6 mb-4 flex flex-col items-start justify-between border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default pb-4 pt-2">
<div className="inline-flex items-center justify-between gap-2 self-stretch">
<div className="text-body-base my-2 font-bold text-ds-text-neutral-default-default">
@ -1783,7 +1828,7 @@ export default function SettingModels() {
{loading === idx ? t('setting.configuring') : t('setting.save')}
</Button>
</div>
</div>
</ConfigModelCard>
);
}
@ -1803,7 +1848,7 @@ export default function SettingModels() {
const platformModelsError = platformState?.error || null;
return (
<div className="flex w-full flex-col rounded-2xl bg-ds-bg-neutral-subtle-default">
<ConfigModelCard status={configCardRing}>
<div className="mx-6 mb-4 flex flex-col items-start justify-between border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default pb-4 pt-2">
<div className="inline-flex items-center justify-between gap-2 self-stretch">
<div className="flex items-center gap-2">
@ -2020,7 +2065,7 @@ export default function SettingModels() {
{localVerifying ? t('setting.configuring') : t('setting.save')}
</Button>
</div>
</div>
</ConfigModelCard>
);
}