diff --git a/src/components/Dashboard/HistoryTabsNav.tsx b/src/components/Dashboard/HistoryTabsNav.tsx index 72eec242..60880312 100644 --- a/src/components/Dashboard/HistoryTabsNav.tsx +++ b/src/components/Dashboard/HistoryTabsNav.tsx @@ -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' }} /> - + {activeLine.width > 0 && ( + + )} {HISTORY_TABS.map(({ id, icon, iconAnimateOnHover }) => ( + + ) + )} + + + + + ); +} diff --git a/src/components/Notification/index.tsx b/src/components/Notification/index.tsx index 90e41e6a..a65511a0 100644 --- a/src/components/Notification/index.tsx +++ b/src/components/Notification/index.tsx @@ -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( <> - - - - -
- - - -
- - - -
-
- {showEndProject && ( - - - - )} - {chatStore.activeTaskId && - chatStore.tasks[chatStore.activeTaskId as string] - ?.status === ChatTaskStatus.FINISHED && ( - - - - )} -
-
- - - - - - - - + + {activeTaskTitle} + +
@@ -632,31 +468,118 @@ function HeaderWin() { )} - {/* Trailing: update + settings (home) or back (history) — always at the end */} + {/* Trailing: project actions (home only) + utilities + settings/back + update */}
- {packageUpdateAvailable && ( + {isHomeRoute && ( +
+ {showEndProject && ( + + + + )} + {chatStore.activeTaskId && + chatStore.tasks[chatStore.activeTaskId as string]?.status === + ChatTaskStatus.FINISHED && ( + + + + )} +
+ )} +
+ + + + + + - )} -
{isHomeRoute ? ( )} + {packageUpdateAvailable && ( + + + + )}
@@ -754,6 +695,10 @@ function HeaderWin() { onOpenChange={setNotificationPanelOpen} /> +
); } diff --git a/src/i18n/locales/ar/layout.json b/src/i18n/locales/ar/layout.json index da64e85a..a3ccf189 100644 --- a/src/i18n/locales/ar/layout.json +++ b/src/i18n/locales/ar/layout.json @@ -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": "لا يوجد مشروع نشط لإنهائه", diff --git a/src/i18n/locales/de/layout.json b/src/i18n/locales/de/layout.json index 0cba267f..350cb503 100644 --- a/src/i18n/locales/de/layout.json +++ b/src/i18n/locales/de/layout.json @@ -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", diff --git a/src/i18n/locales/en-us/layout.json b/src/i18n/locales/en-us/layout.json index d358352e..3e9b9021 100644 --- a/src/i18n/locales/en-us/layout.json +++ b/src/i18n/locales/en-us/layout.json @@ -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", diff --git a/src/i18n/locales/es/layout.json b/src/i18n/locales/es/layout.json index 2cf54c03..5908676b 100644 --- a/src/i18n/locales/es/layout.json +++ b/src/i18n/locales/es/layout.json @@ -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", diff --git a/src/i18n/locales/fr/layout.json b/src/i18n/locales/fr/layout.json index c5b92bdb..9d4182ae 100644 --- a/src/i18n/locales/fr/layout.json +++ b/src/i18n/locales/fr/layout.json @@ -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", diff --git a/src/i18n/locales/it/layout.json b/src/i18n/locales/it/layout.json index 7c5a220c..43402e1c 100644 --- a/src/i18n/locales/it/layout.json +++ b/src/i18n/locales/it/layout.json @@ -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", diff --git a/src/i18n/locales/ja/layout.json b/src/i18n/locales/ja/layout.json index 174a9dc4..635aee3d 100644 --- a/src/i18n/locales/ja/layout.json +++ b/src/i18n/locales/ja/layout.json @@ -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": "終了するアクティブなプロジェクトがありません", diff --git a/src/i18n/locales/ko/layout.json b/src/i18n/locales/ko/layout.json index 991d91bb..e62824ec 100644 --- a/src/i18n/locales/ko/layout.json +++ b/src/i18n/locales/ko/layout.json @@ -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": "종료할 활성 프로젝트가 없습니다", diff --git a/src/i18n/locales/ru/layout.json b/src/i18n/locales/ru/layout.json index 2d39bda3..7709cd82 100644 --- a/src/i18n/locales/ru/layout.json +++ b/src/i18n/locales/ru/layout.json @@ -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": "Нет активного проекта для завершения", diff --git a/src/i18n/locales/zh-Hans/layout.json b/src/i18n/locales/zh-Hans/layout.json index ae4bdaeb..0d5c8ce7 100644 --- a/src/i18n/locales/zh-Hans/layout.json +++ b/src/i18n/locales/zh-Hans/layout.json @@ -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": "没有活动项目可结束", diff --git a/src/i18n/locales/zh-Hant/layout.json b/src/i18n/locales/zh-Hant/layout.json index 5fb308b8..dc9bb051 100644 --- a/src/i18n/locales/zh-Hant/layout.json +++ b/src/i18n/locales/zh-Hant/layout.json @@ -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": "沒有活動專案可結束", diff --git a/src/pages/Agents/ConfigModelCard.tsx b/src/pages/Agents/ConfigModelCard.tsx new file mode 100644 index 00000000..421bb84e --- /dev/null +++ b/src/pages/Agents/ConfigModelCard.tsx @@ -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, 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) { + 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 ( +
+ + {ringMotion && ( + + )} + +
+ {children} +
+
+ ); +} diff --git a/src/pages/Agents/Models.tsx b/src/pages/Agents/Models.tsx index bb4416a5..6d9a4683 100644 --- a/src/pages/Agents/Models.tsx +++ b/src/pages/Agents/Models.tsx @@ -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>({}); const [loading, setLoading] = useState(null); + const [configCardRing, setConfigCardRing] = + useState('idle'); + const configCardRingResetRef = useRef | 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 ( -
+
@@ -1783,7 +1828,7 @@ export default function SettingModels() { {loading === idx ? t('setting.configuring') : t('setting.save')}
-
+ ); } @@ -1803,7 +1848,7 @@ export default function SettingModels() { const platformModelsError = platformState?.error || null; return ( -
+
@@ -2020,7 +2065,7 @@ export default function SettingModels() { {localVerifying ? t('setting.configuring') : t('setting.save')}
-
+ ); }