-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {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')}
-
+
);
}