From 1c75df9e759758eda37c25f5da85938a672a38af Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Mon, 30 Mar 2026 01:55:36 +0000 Subject: [PATCH 01/18] feat(profiles): move device sync to Profiles with selectable sources --- clawpal-core/src/profile.rs | 6 ++ src-tauri/src/commands/profiles.rs | 20 ++++++ src/App.tsx | 2 - src/components/SidebarFooter.tsx | 31 +------- src/hooks/useSshConnection.ts | 17 ++--- src/lib/api.ts | 7 +- src/lib/types.ts | 3 + src/locales/en.json | 9 +++ src/locales/zh.json | 9 +++ src/pages/Settings.tsx | 111 +++++++++++++++++++++++++++++ 10 files changed, 175 insertions(+), 40 deletions(-) diff --git a/clawpal-core/src/profile.rs b/clawpal-core/src/profile.rs index 614c78bb..54f4f3ed 100644 --- a/clawpal-core/src/profile.rs +++ b/clawpal-core/src/profile.rs @@ -21,6 +21,12 @@ pub struct ModelProfile { pub api_key: Option, pub base_url: Option, pub description: Option, + #[serde(default)] + pub sync_source_device_name: Option, + #[serde(default)] + pub sync_source_host_id: Option, + #[serde(default)] + pub sync_synced_at: Option, pub enabled: bool, } diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs index 5dcb247a..70a75e24 100644 --- a/src-tauri/src/commands/profiles.rs +++ b/src-tauri/src/commands/profiles.rs @@ -227,6 +227,9 @@ fn merge_remote_profile_into_local( remote: &ModelProfile, resolved_api_key: Option, resolved_base_url: Option, + source_device_name: &str, + source_host_id: &str, + synced_at: &str, ) -> bool { let remote_key = normalize_profile_key(remote); let target_idx = local_profiles @@ -282,6 +285,9 @@ fn merge_remote_profile_into_local( if !existing.enabled && remote.enabled { existing.enabled = true; } + existing.sync_source_device_name = Some(source_device_name.to_string()); + existing.sync_source_host_id = Some(source_host_id.to_string()); + existing.sync_synced_at = Some(synced_at.to_string()); return false; } @@ -292,6 +298,9 @@ fn merge_remote_profile_into_local( if !is_non_empty(merged.base_url.as_deref()) && is_non_empty(resolved_base_url.as_deref()) { merged.base_url = resolved_base_url; } + merged.sync_source_device_name = Some(source_device_name.to_string()); + merged.sync_source_host_id = Some(source_host_id.to_string()); + merged.sync_synced_at = Some(synced_at.to_string()); local_profiles.push(merged); true } @@ -573,6 +582,7 @@ pub struct RemoteAuthSyncResult { pub async fn remote_sync_profiles_to_local_auth( pool: State<'_, SshConnectionPool>, host_id: String, + source_device_name: Option, ) -> Result { let (remote_profiles, _) = collect_remote_profiles_from_openclaw(&pool, &host_id, true).await?; if remote_profiles.is_empty() { @@ -589,6 +599,13 @@ pub async fn remote_sync_profiles_to_local_auth( let paths = resolve_paths(); let mut local_profiles = dedupe_profiles_by_model_key(load_model_profiles(&paths)); + let source_name = source_device_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(host_id.as_str()) + .to_string(); + let synced_at = chrono::Utc::now().to_rfc3339(); let mut created_profiles = 0usize; let mut updated_profiles = 0usize; @@ -652,6 +669,9 @@ pub async fn remote_sync_profiles_to_local_auth( remote, resolved_api_key, resolved_base_url, + &source_name, + &host_id, + &synced_at, ) { created_profiles += 1; } else { diff --git a/src/App.tsx b/src/App.tsx index 7448a642..518eeeb7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -175,7 +175,6 @@ export function App() { }); const { - profileSyncStatus, showSshTransferSpeedUi, sshTransferStats, doctorNavPulse, @@ -417,7 +416,6 @@ export function App() { import("./PendingChangesBar").then((m) => ({ default: m.PendingChangesBar }))); -interface ProfileSyncStatus { - phase: "idle" | "syncing" | "success" | "error"; - message: string; - instanceId: string | null; -} - interface SidebarFooterProps { - profileSyncStatus: ProfileSyncStatus; showSshTransferSpeedUi: boolean; isRemote: boolean; isConnected: boolean; @@ -26,33 +19,15 @@ interface SidebarFooterProps { } export function SidebarFooter({ - profileSyncStatus, showSshTransferSpeedUi, isRemote, isConnected, + showSshTransferSpeedUi, isRemote, isConnected, sshTransferStats, inStart, route, showToast, bumpConfigVersion, }: SidebarFooterProps) { const { t } = useTranslation(); return ( <>
-
- - - {profileSyncStatus.phase === "idle" - ? t("doctor.profileSyncIdle") - : profileSyncStatus.phase === "syncing" - ? t("doctor.profileSyncSyncing", { instance: profileSyncStatus.instanceId || t("instance.current") }) - : profileSyncStatus.phase === "success" - ? t("doctor.profileSyncSuccessStatus", { instance: profileSyncStatus.instanceId || t("instance.current") }) - : t("doctor.profileSyncErrorStatus", { instance: profileSyncStatus.instanceId || t("instance.current") })} - -
{showSshTransferSpeedUi && isRemote && isConnected && ( -
+
{t("doctor.sshTransferSpeedTitle")}
{t("doctor.sshTransferSpeedDown", { speed: `${formatBytes(Math.max(0, Math.round(sshTransferStats?.downloadBytesPerSec ?? 0)))} /s` })} diff --git a/src/hooks/useSshConnection.ts b/src/hooks/useSshConnection.ts index a6313948..927a6e04 100644 --- a/src/hooks/useSshConnection.ts +++ b/src/hooks/useSshConnection.ts @@ -175,6 +175,7 @@ export function useSshConnection(params: UseSshConnectionParams) { }, [activeInstance, requestPassphrase, sshHosts, t, setPersistenceScope, setPersistenceResolved]); const syncRemoteAuthAfterConnect = useCallback(async (hostId: string) => { + const hostLabel = sshHosts.find((host) => host.id === hostId)?.label || hostId; const now = Date.now(); const last = remoteAuthSyncAtRef.current[hostId] || 0; if (now - last < 30_000) return; @@ -182,10 +183,10 @@ export function useSshConnection(params: UseSshConnectionParams) { setProfileSyncStatus({ phase: "syncing", message: t("doctor.profileSyncStarted"), - instanceId: hostId, + instanceId: hostLabel, }); try { - const result = await api.remoteSyncProfilesToLocalAuth(hostId); + const result = await api.remoteSyncProfilesToLocalAuth(hostId, hostLabel); invalidateGlobalReadCache(["listModelProfiles", "resolveApiKeys"]); const localProfiles = await api.listModelProfiles().catch((error) => { logDevIgnoredError("syncRemoteAuthAfterConnect listModelProfiles", error); @@ -198,27 +199,27 @@ export function useSshConnection(params: UseSshConnectionParams) { resolvedKeys: result.resolvedKeys, }); showToast(message, "success"); - setProfileSyncStatus({ phase: "success", message, instanceId: hostId }); + setProfileSyncStatus({ phase: "success", message, instanceId: hostLabel }); } else { const message = t("doctor.profileSyncNoLocalProfiles"); showToast(message, "error"); - setProfileSyncStatus({ phase: "error", message, instanceId: hostId }); + setProfileSyncStatus({ phase: "error", message, instanceId: hostLabel }); } } else if (result.totalRemoteProfiles > 0) { const message = t("doctor.profileSyncNoUsableKeys"); showToast(message, "error"); - setProfileSyncStatus({ phase: "error", message, instanceId: hostId }); + setProfileSyncStatus({ phase: "error", message, instanceId: hostLabel }); } else { const message = t("doctor.profileSyncNoProfiles"); showToast(message, "error"); - setProfileSyncStatus({ phase: "error", message, instanceId: hostId }); + setProfileSyncStatus({ phase: "error", message, instanceId: hostLabel }); } } catch (e) { const message = t("doctor.profileSyncFailed", { error: String(e) }); showToast(message, "error"); - setProfileSyncStatus({ phase: "error", message, instanceId: hostId }); + setProfileSyncStatus({ phase: "error", message, instanceId: hostLabel }); } - }, [showToast, t]); + }, [showToast, sshHosts, t]); // SSH self-healing: detect dropped connections and reconnect useEffect(() => { diff --git a/src/lib/api.ts b/src/lib/api.ts index a967bb60..def3def6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -380,8 +380,11 @@ export const api = { invoke("remote_test_model_profile", { hostId, profileId }), remoteResolveApiKeys: (hostId: string): Promise => invoke("remote_resolve_api_keys", { hostId }), - remoteSyncProfilesToLocalAuth: (hostId: string): Promise => - invoke("remote_sync_profiles_to_local_auth", { hostId }), + remoteSyncProfilesToLocalAuth: (hostId: string, sourceDeviceName?: string): Promise => + invoke("remote_sync_profiles_to_local_auth", { + hostId, + sourceDeviceName: sourceDeviceName ?? null, + }), pushModelProfilesToLocalOpenclaw: (profileIds: string[]): Promise => invoke("push_model_profiles_to_local_openclaw", { profileIds }), pushModelProfilesToRemoteOpenclaw: (hostId: string, profileIds: string[]): Promise => diff --git a/src/lib/types.ts b/src/lib/types.ts index 2f8445b6..461dd9ad 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -529,6 +529,9 @@ export interface ModelProfile { apiKey?: string; baseUrl?: string; description?: string; + syncSourceDeviceName?: string; + syncSourceHostId?: string; + syncSyncedAt?: string; enabled: boolean; } diff --git a/src/locales/en.json b/src/locales/en.json index 7b0e2763..35b6ba1e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -274,6 +274,15 @@ "settings.pushProfileBlockedMissingCredential": "This profile has no resolved static credential to push.", "settings.loadingProfiles": "Loading profiles...", "settings.noProfiles": "No model profiles yet.", + "settings.syncedDevicesCount": "Synced {{count}} devices", + "settings.syncDevicesTitle": "Device sync", + "settings.noSyncDevices": "No syncable devices", + "settings.syncFromDevicesAction": "Sync selected devices ({{count}})", + "settings.syncStatusIdle": "Idle", + "settings.syncStatusSyncing": "Syncing", + "settings.syncStatusSuccess": "Success", + "settings.syncStatusFailed": "Failed", + "settings.profileSyncSource": "Source: {{device}} · Synced at: {{syncedAt}}", "settings.enabled": "enabled", "settings.disabled": "disabled", "settings.enable": "Enable", diff --git a/src/locales/zh.json b/src/locales/zh.json index 1cc64fff..c825c746 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -273,6 +273,15 @@ "settings.pushProfileBlockedMissingCredential": "这个配置没有可解析的静态凭据可供推送。", "settings.loadingProfiles": "加载配置中...", "settings.noProfiles": "暂无模型配置。", + "settings.syncedDevicesCount": "已同步 {{count}} 个设备", + "settings.syncDevicesTitle": "设备同步", + "settings.noSyncDevices": "暂无可同步设备", + "settings.syncFromDevicesAction": "同步所选设备({{count}})", + "settings.syncStatusIdle": "未同步", + "settings.syncStatusSyncing": "同步中", + "settings.syncStatusSuccess": "同步成功", + "settings.syncStatusFailed": "同步失败", + "settings.profileSyncSource": "来源设备:{{device}} · 同步时间:{{syncedAt}}", "settings.enabled": "已启用", "settings.disabled": "已禁用", "settings.enable": "启用", diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index fc63ccf8..f5dd0125 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -11,6 +11,7 @@ import type { FormEvent } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { hasGuidanceEmitted, useApi } from "@/lib/use-api"; +import { api } from "@/lib/api"; import { isAlreadyExplainedGuidanceError } from "@/lib/guidance"; import { useTheme } from "@/lib/use-theme"; import { useFont } from "@/lib/use-font"; @@ -21,6 +22,7 @@ import type { ModelProfile, ProviderAuthSuggestion, ResolvedApiKey, + SshHost, } from "@/lib/types"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { BugReportSettings } from "@/components/BugReportSettings"; @@ -105,6 +107,10 @@ export function Settings({ const [authSuggestion, setAuthSuggestion] = useState(null); const [testingProfileId, setTestingProfileId] = useState(null); const [showSshTransferSpeedUi, setShowSshTransferSpeedUi] = useState(false); + const [remoteDevices, setRemoteDevices] = useState([]); + const [syncDialogOpen, setSyncDialogOpen] = useState(false); + const [selectedSyncHostIds, setSelectedSyncHostIds] = useState([]); + const [syncStatusByHostId, setSyncStatusByHostId] = useState>({}); const [catalogRefreshed, setCatalogRefreshed] = useState(false); @@ -145,6 +151,15 @@ export function Settings({ useEffect(refreshProfiles, [ua]); + useEffect(() => { + ua.listSshHosts() + .then((hosts) => setRemoteDevices(hosts)) + .catch((e) => { + console.error("Failed to load SSH hosts:", e); + setRemoteDevices([]); + }); + }, [ua]); + useEffect(() => { ua.getAppPreferences() .then((prefs) => { @@ -429,6 +444,47 @@ export function Settings({ const showProfiles = section !== "preferences"; const showPreferences = section !== "profiles"; + const syncedDeviceCount = useMemo(() => { + const ids = new Set(); + for (const profile of profiles || []) { + const source = profile.syncSourceHostId?.trim(); + if (source) ids.add(source); + } + return ids.size; + }, [profiles]); + + const syncButtonText = remoteDevices.length > 0 && selectedSyncHostIds.length === 0 + ? "从设备同步" + : t("settings.syncFromDevicesAction", { count: selectedSyncHostIds.length }); + + const formatSyncedAt = (value?: string) => { + if (!value) return "-"; + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) return value; + return new Date(timestamp).toLocaleString(); + }; + + const runDeviceSync = useCallback(async () => { + if (selectedSyncHostIds.length === 0) { + toast.message("从设备同步"); + return; + } + for (const hostId of selectedSyncHostIds) { + const device = remoteDevices.find((item) => item.id === hostId); + const deviceName = device?.label || hostId; + setSyncStatusByHostId((prev) => ({ ...prev, [hostId]: "syncing" })); + try { + await api.remoteSyncProfilesToLocalAuth(hostId, deviceName); + setSyncStatusByHostId((prev) => ({ ...prev, [hostId]: "success" })); + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + setSyncStatusByHostId((prev) => ({ ...prev, [hostId]: "failed" })); + toast.error(`${deviceName} 同步失败:${errorText}`); + } + } + refreshProfiles(); + }, [remoteDevices, selectedSyncHostIds, ua]); + const handleSshTransferSpeedUiToggle = useCallback((nextChecked: boolean) => { setShowSshTransferSpeedUi(nextChecked); ua.setSshTransferSpeedUiPreference(nextChecked) @@ -562,6 +618,9 @@ export function Settings({
{t('settings.modelProfiles')}
+
@@ -623,6 +682,14 @@ export function Settings({ URL: {profile.baseUrl}
)} + {(profile.syncSourceDeviceName || profile.syncSyncedAt) && ( +
+ {t("settings.profileSyncSource", { + device: profile.syncSourceDeviceName || "-", + syncedAt: formatSyncedAt(profile.syncSyncedAt), + })} +
+ )}
{profileUi.actions.includes("edit") ? (
+ + + + {t("settings.syncDevicesTitle")} + +
+ {remoteDevices.length === 0 ? ( +

{t("settings.noSyncDevices")}

+ ) : remoteDevices.map((device) => { + const checked = selectedSyncHostIds.includes(device.id); + const status = syncStatusByHostId[device.id] || "idle"; + const statusText = status === "syncing" + ? t("settings.syncStatusSyncing") + : status === "success" + ? t("settings.syncStatusSuccess") + : status === "failed" + ? t("settings.syncStatusFailed") + : t("settings.syncStatusIdle"); + return ( + + ); + })} +
+ + + + +
+
+ {message && (

{message}

)} From 836fc7a4c31443ff1d647d4543a1cb1b8977453c Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Mon, 30 Mar 2026 03:02:37 +0000 Subject: [PATCH 02/18] fix(ci): update ModelProfile test fixtures with sync metadata fields --- clawpal-core/src/precheck.rs | 18 ++++++++++++++++++ clawpal-core/src/profile.rs | 12 ++++++++++++ clawpal-core/tests/oauth_e2e.rs | 3 +++ clawpal-core/tests/profile_e2e.rs | 3 +++ 4 files changed, 36 insertions(+) diff --git a/clawpal-core/src/precheck.rs b/clawpal-core/src/precheck.rs index a52c499b..0f47431b 100644 --- a/clawpal-core/src/precheck.rs +++ b/clawpal-core/src/precheck.rs @@ -95,6 +95,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }]; let issues = precheck_auth(&profiles); @@ -112,6 +115,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }]; let issues = precheck_auth(&profiles); @@ -129,6 +135,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: false, }]; let issues = precheck_auth(&profiles); @@ -191,6 +200,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }]; let issues = precheck_auth(&profiles); @@ -209,6 +221,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }, ModelProfile { @@ -220,6 +235,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }, ]; diff --git a/clawpal-core/src/profile.rs b/clawpal-core/src/profile.rs index 54f4f3ed..659059fa 100644 --- a/clawpal-core/src/profile.rs +++ b/clawpal-core/src/profile.rs @@ -421,6 +421,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, } } @@ -586,6 +589,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }; let content = serde_json::json!({ "profiles": [donor], "version": 1 }).to_string(); @@ -609,6 +615,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }; let second = ModelProfile { @@ -620,6 +629,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }; diff --git a/clawpal-core/tests/oauth_e2e.rs b/clawpal-core/tests/oauth_e2e.rs index ba93c3e6..f728a698 100644 --- a/clawpal-core/tests/oauth_e2e.rs +++ b/clawpal-core/tests/oauth_e2e.rs @@ -73,6 +73,9 @@ fn e2e_create_oauth_profile_and_probe() { api_key: Some(oauth_token.clone()), base_url: None, description: Some("E2E OAuth token test profile".to_string()), + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }; diff --git a/clawpal-core/tests/profile_e2e.rs b/clawpal-core/tests/profile_e2e.rs index 6a2e89ab..cb6c8469 100644 --- a/clawpal-core/tests/profile_e2e.rs +++ b/clawpal-core/tests/profile_e2e.rs @@ -235,6 +235,9 @@ fn run_case(case: &ModelCase) -> CaseResult { api_key: Some(api_key.clone()), base_url: None, description: Some(format!("E2E — {}/{}", case.provider, case.model)), + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }; From 76730982711541e0f50a6fc3cb02594dbed3413a Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Mon, 30 Mar 2026 04:14:14 +0000 Subject: [PATCH 03/18] fix(ci): update tauri profile initializers for sync metadata fields --- src-tauri/src/commands/profiles.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs index 70a75e24..f4964d63 100644 --- a/src-tauri/src/commands/profiles.rs +++ b/src-tauri/src/commands/profiles.rs @@ -361,6 +361,9 @@ fn extract_profiles_from_openclaw_config( api_key: None, base_url, description: Some(format!("Extracted from config ({scope_label})")), + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }; let key = profile_to_model_value(&profile); @@ -1330,6 +1333,9 @@ mod tests { api_key: api_key.map(|v| v.to_string()), base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, } } @@ -1596,6 +1602,9 @@ mod tests { api_key: None, base_url: Some("https://openrouter.example/v1".to_string()), description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }, provider_key: "openrouter".to_string(), @@ -1739,6 +1748,9 @@ pub fn resolve_provider_auth(provider: String) -> Result Date: Mon, 30 Mar 2026 04:24:57 +0000 Subject: [PATCH 04/18] fix(coverage): update cli ModelProfile initializer for sync metadata --- clawpal-cli/src/main.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clawpal-cli/src/main.rs b/clawpal-cli/src/main.rs index 96f95033..97417578 100644 --- a/clawpal-cli/src/main.rs +++ b/clawpal-cli/src/main.rs @@ -307,6 +307,9 @@ fn run_profile_command(command: ProfileCommands) -> Result Date: Mon, 30 Mar 2026 16:46:07 +0000 Subject: [PATCH 05/18] refactor(ui): use icon buttons for sync and add profile actions --- src/pages/Settings.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index f5dd0125..0eb3d5ed 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -58,6 +58,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; +import { PlusIcon, RefreshCwIcon } from "lucide-react"; const MODEL_CATALOG_CACHE_TTL_MS = 5 * 60_000; @@ -618,10 +619,23 @@ export function Settings({
{t('settings.modelProfiles')}
- + -
From b2436570fb190de244402f2d68152f8738eebb64 Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Tue, 31 Mar 2026 02:19:37 +0000 Subject: [PATCH 06/18] feat(sync-ui): close dialog with toast on sync start and spin sync icon --- src/pages/Settings.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 0eb3d5ed..a33890a3 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -458,6 +458,11 @@ export function Settings({ ? "从设备同步" : t("settings.syncFromDevicesAction", { count: selectedSyncHostIds.length }); + const isDeviceSyncing = useMemo( + () => Object.values(syncStatusByHostId).some((status) => status === "syncing"), + [syncStatusByHostId], + ); + const formatSyncedAt = (value?: string) => { if (!value) return "-"; const timestamp = Date.parse(value); @@ -470,6 +475,10 @@ export function Settings({ toast.message("从设备同步"); return; } + + toast.success(`已开始同步 ${selectedSyncHostIds.length} 个设备`); + setSyncDialogOpen(false); + for (const hostId of selectedSyncHostIds) { const device = remoteDevices.find((item) => item.id === hostId); const deviceName = device?.label || hostId; @@ -626,7 +635,7 @@ export function Settings({ title={t("settings.syncedDevicesCount", { count: syncedDeviceCount })} aria-label={t("settings.syncedDevicesCount", { count: syncedDeviceCount })} > - +
-
- {t('settings.credential')}: {t(`settings.credentialKind.${credential.kind}`)} +
+ + {t(`settings.credentialKind.${credential.kind}`)} + + {showCredentialRef && ( + + Ref + + )} + {showCredentialStatus && ( + + {credentialStatusText} + + )} + {profile.baseUrl && ( + + URL + + )} + {(profile.syncSourceDeviceName || profile.syncSyncedAt) && ( + + {profile.syncSourceDeviceName || "-"} + + )}
- {showCredentialRef && ( -
- {t("settings.credentialRef")}: {credential.authRef || "-"} -
- )} - {showCredentialStatus && ( -
- {t("settings.credentialStatus")}: {credentialStatusText} -
- )} - {profile.baseUrl && ( -
- URL: {profile.baseUrl} -
- )} - {(profile.syncSourceDeviceName || profile.syncSyncedAt) && ( -
- {t("settings.profileSyncSource", { - device: profile.syncSourceDeviceName || "-", - syncedAt: formatSyncedAt(profile.syncSyncedAt), - })} -
- )}
{profileUi.actions.includes("edit") ? ( + )}
{statusText} From 915ac45a59e99b16e497611d588c0b50cdaba639 Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Tue, 31 Mar 2026 05:58:00 +0000 Subject: [PATCH 09/18] fix(sync-dialog): improve i18n and disconnected-device affordance --- src/locales/en.json | 5 +++++ src/locales/zh.json | 5 +++++ src/pages/Settings.tsx | 36 +++++++++++++++++------------------- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 35b6ba1e..3d4475bc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -277,7 +277,12 @@ "settings.syncedDevicesCount": "Synced {{count}} devices", "settings.syncDevicesTitle": "Device sync", "settings.noSyncDevices": "No syncable devices", + "settings.syncFromDevices": "Sync from devices", "settings.syncFromDevicesAction": "Sync selected devices ({{count}})", + "settings.syncStarted": "Started syncing {{count}} devices", + "settings.syncFailedForDevice": "Sync failed for {{device}}: {{error}}", + "settings.connectDevice": "Connect device", + "settings.connectDeviceFirst": "Please connect device first: {{device}}", "settings.syncStatusIdle": "Idle", "settings.syncStatusSyncing": "Syncing", "settings.syncStatusSuccess": "Success", diff --git a/src/locales/zh.json b/src/locales/zh.json index c825c746..b8f54a4d 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -276,7 +276,12 @@ "settings.syncedDevicesCount": "已同步 {{count}} 个设备", "settings.syncDevicesTitle": "设备同步", "settings.noSyncDevices": "暂无可同步设备", + "settings.syncFromDevices": "从设备同步", "settings.syncFromDevicesAction": "同步所选设备({{count}})", + "settings.syncStarted": "已开始同步 {{count}} 个设备", + "settings.syncFailedForDevice": "{{device}} 同步失败:{{error}}", + "settings.connectDevice": "连接设备", + "settings.connectDeviceFirst": "请先连接设备:{{device}}", "settings.syncStatusIdle": "未同步", "settings.syncStatusSyncing": "同步中", "settings.syncStatusSuccess": "同步成功", diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c4052113..a2353ed7 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -483,7 +483,7 @@ export function Settings({ }, [profiles]); const syncButtonText = remoteDevices.length > 0 && selectedSyncHostIds.length === 0 - ? "从设备同步" + ? t("settings.syncFromDevices") : t("settings.syncFromDevicesAction", { count: selectedSyncHostIds.length }); const isDeviceSyncing = useMemo( @@ -500,11 +500,11 @@ export function Settings({ const runDeviceSync = useCallback(async () => { if (selectedSyncHostIds.length === 0) { - toast.message("从设备同步"); + toast.message(t("settings.syncFromDevices")); return; } - toast.success(`已开始同步 ${selectedSyncHostIds.length} 个设备`); + toast.success(t("settings.syncStarted", { count: selectedSyncHostIds.length })); setSyncDialogOpen(false); for (const hostId of selectedSyncHostIds) { @@ -517,7 +517,7 @@ export function Settings({ } catch (error) { const errorText = error instanceof Error ? error.message : String(error); setSyncStatusByHostId((prev) => ({ ...prev, [hostId]: "failed" })); - toast.error(`${deviceName} 同步失败:${errorText}`); + toast.error(t("settings.syncFailedForDevice", { device: deviceName, error: errorText })); } } refreshProfiles(); @@ -820,17 +820,15 @@ export function Settings({ const checked = selectedSyncHostIds.includes(device.id); const connected = hostConnectionById[device.id] ?? false; const status = syncStatusByHostId[device.id] || "idle"; - const statusText = !connected - ? "未连接" - : status === "syncing" - ? t("settings.syncStatusSyncing") - : status === "success" - ? t("settings.syncStatusSuccess") - : status === "failed" - ? t("settings.syncStatusFailed") - : t("settings.syncStatusIdle"); + const statusText = status === "syncing" + ? t("settings.syncStatusSyncing") + : status === "success" + ? t("settings.syncStatusSuccess") + : status === "failed" + ? t("settings.syncStatusFailed") + : t("settings.syncStatusIdle"); return ( -
{connected ? statusText : ""} From 6a8e0178be5d02508b5d6c026bbbb543dd8bb926 Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Tue, 31 Mar 2026 06:28:13 +0000 Subject: [PATCH 11/18] fix(sync-dialog): right-align connect badge with fixed width --- src/pages/Settings.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 13ae8fba..274f0f5f 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -842,10 +842,12 @@ export function Settings({ }} /> {device.label} - {!connected && ( +
+ + {connected ? statusText : ( )} -
- {connected ? statusText : ""} + ); })} From d47fadaa6c764a1d46543c01f617a76db00db267 Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Tue, 31 Mar 2026 06:30:19 +0000 Subject: [PATCH 12/18] feat(sync-dialog): open target VPS connection flow directly from connect badge --- src/App.tsx | 26 ++++++++++++++++++++------ src/pages/Settings.tsx | 6 +++--- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 79d82197..e9fba9f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -470,9 +470,16 @@ export function App() { globalMode section="profiles" onOpenDoctor={openDoctor} - onOpenStart={() => { - setInStart(true); - setStartSection("overview"); + onConnectDevice={(hostId) => { + void connectWithPassphraseFallback(hostId) + .then(() => { + openTab(hostId); + setInStart(false); + }) + .catch(() => { + setInStart(true); + setStartSection("overview"); + }); }} onDataChange={bumpConfigVersion} /> @@ -483,9 +490,16 @@ export function App() { globalMode section="preferences" onOpenDoctor={openDoctor} - onOpenStart={() => { - setInStart(true); - setStartSection("overview"); + onConnectDevice={(hostId) => { + void connectWithPassphraseFallback(hostId) + .then(() => { + openTab(hostId); + setInStart(false); + }) + .catch(() => { + setInStart(true); + setStartSection("overview"); + }); }} onDataChange={bumpConfigVersion} hasAppUpdate={appUpdateAvailable} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 274f0f5f..c9e47cd9 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -86,7 +86,7 @@ export function Settings({ globalMode = false, section = "all", onOpenDoctor, - onOpenStart, + onConnectDevice, }: { onDataChange?: () => void; hasAppUpdate?: boolean; @@ -94,7 +94,7 @@ export function Settings({ globalMode?: boolean; section?: "all" | "profiles" | "preferences"; onOpenDoctor?: () => void; - onOpenStart?: () => void; + onConnectDevice?: (hostId: string) => void; }) { const { t, i18n } = useTranslation(); const ua = useApi(); @@ -853,7 +853,7 @@ export function Settings({ event.preventDefault(); event.stopPropagation(); setSyncDialogOpen(false); - onOpenStart?.(); + onConnectDevice?.(device.id); toast.message(t("settings.connectDeviceFirst", { device: device.label })); }} > From ac39c758036fd0f1f23c078e2072f63cfd5c14fd Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Tue, 31 Mar 2026 06:54:59 +0000 Subject: [PATCH 13/18] refactor(sync-dialog): use icon button for connect action --- src/pages/Settings.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c9e47cd9..31991a33 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -58,7 +58,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import { PlusIcon, RefreshCwIcon } from "lucide-react"; +import { Link2Icon, PlusIcon, RefreshCwIcon } from "lucide-react"; const MODEL_CATALOG_CACHE_TTL_MS = 5 * 60_000; @@ -845,9 +845,11 @@ export function Settings({ {connected ? statusText : ( - + + )} From 368df753f121d632de399d0772053cf8391c318c Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Tue, 31 Mar 2026 06:58:27 +0000 Subject: [PATCH 14/18] refactor(connect-flow): keep user on Profiles page after device connect --- src/App.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e9fba9f8..520f864f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -473,12 +473,12 @@ export function App() { onConnectDevice={(hostId) => { void connectWithPassphraseFallback(hostId) .then(() => { - openTab(hostId); - setInStart(false); + setInStart(true); + setStartSection("profiles"); }) .catch(() => { setInStart(true); - setStartSection("overview"); + setStartSection("profiles"); }); }} onDataChange={bumpConfigVersion} @@ -493,12 +493,12 @@ export function App() { onConnectDevice={(hostId) => { void connectWithPassphraseFallback(hostId) .then(() => { - openTab(hostId); - setInStart(false); + setInStart(true); + setStartSection("profiles"); }) .catch(() => { setInStart(true); - setStartSection("overview"); + setStartSection("profiles"); }); }} onDataChange={bumpConfigVersion} From faf91604aa1a1e08390f750552ed93a581c75c9d Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Tue, 31 Mar 2026 07:28:16 +0000 Subject: [PATCH 15/18] fix(connect-flow): keep sync dialog open and auto-select device after connect --- src/App.tsx | 32 ++++++++++---------------------- src/pages/Settings.tsx | 13 ++++++++----- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 520f864f..fb815763 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -470,17 +470,11 @@ export function App() { globalMode section="profiles" onOpenDoctor={openDoctor} - onConnectDevice={(hostId) => { - void connectWithPassphraseFallback(hostId) - .then(() => { - setInStart(true); - setStartSection("profiles"); - }) - .catch(() => { - setInStart(true); - setStartSection("profiles"); - }); - }} + onConnectDevice={(hostId) => ( + connectWithPassphraseFallback(hostId) + .then(() => true) + .catch(() => false) + )} onDataChange={bumpConfigVersion} /> )} @@ -490,17 +484,11 @@ export function App() { globalMode section="preferences" onOpenDoctor={openDoctor} - onConnectDevice={(hostId) => { - void connectWithPassphraseFallback(hostId) - .then(() => { - setInStart(true); - setStartSection("profiles"); - }) - .catch(() => { - setInStart(true); - setStartSection("profiles"); - }); - }} + onConnectDevice={(hostId) => ( + connectWithPassphraseFallback(hostId) + .then(() => true) + .catch(() => false) + )} onDataChange={bumpConfigVersion} hasAppUpdate={appUpdateAvailable} onAppUpdateSeen={() => setAppUpdateAvailable(false)} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 31991a33..c81fcac0 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -94,7 +94,7 @@ export function Settings({ globalMode?: boolean; section?: "all" | "profiles" | "preferences"; onOpenDoctor?: () => void; - onConnectDevice?: (hostId: string) => void; + onConnectDevice?: (hostId: string) => Promise; }) { const { t, i18n } = useTranslation(); const ua = useApi(); @@ -851,12 +851,15 @@ export function Settings({ variant="ghost" className="h-6 w-6" title={t("settings.connectDevice")} - onClick={(event) => { + onClick={async (event) => { event.preventDefault(); event.stopPropagation(); - setSyncDialogOpen(false); - onConnectDevice?.(device.id); - toast.message(t("settings.connectDeviceFirst", { device: device.label })); + if (!onConnectDevice) return; + const connectedNow = await onConnectDevice(device.id); + if (connectedNow) { + setHostConnectionById((prev) => ({ ...prev, [device.id]: true })); + setSelectedSyncHostIds((prev) => prev.includes(device.id) ? prev : [...prev, device.id]); + } }} > From d3015b143e7d374e396b23fbcf7f6c8192ae6cec Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Tue, 31 Mar 2026 07:52:19 +0000 Subject: [PATCH 16/18] fix(profiles): replace inline status message with toasts --- src/pages/Settings.tsx | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c81fcac0..85c571ae 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -106,7 +106,6 @@ export function Settings({ const [form, setForm] = useState(emptyForm()); const [credentialSource, setCredentialSource] = useState("manual"); const [profileDialogOpen, setProfileDialogOpen] = useState(false); - const [message, setMessage] = useState(""); const [authSuggestion, setAuthSuggestion] = useState(null); const [testingProfileId, setTestingProfileId] = useState(null); const [showSshTransferSpeedUi, setShowSshTransferSpeedUi] = useState(false); @@ -302,7 +301,7 @@ export function Settings({ const saveProfile = async (authRefOverride?: string): Promise => { if (!form.provider || !form.model) { - setMessage(t('settings.providerModelRequired')); + toast.error(t('settings.providerModelRequired')); return false; } const apiKeyOptional = form.useCustomUrl || providerSupportsOptionalApiKey(form.provider); @@ -310,7 +309,7 @@ export function Settings({ const envSource = credentialSource === "env"; const manualSource = credentialSource === "manual"; if (!ua.isRemote && manualSource && !form.apiKey && !form.id && !apiKeyOptional) { - setMessage(t('settings.apiKeyRequired')); + toast.error(t('settings.apiKeyRequired')); return false; } const overrideAuthRef = (authRefOverride || "").trim(); @@ -337,14 +336,15 @@ export function Settings({ }; try { await ua.upsertModelProfile(profileData); - setMessage(t('settings.profileSaved')); + toast.success(t('settings.profileSaved')); setForm(emptyForm()); setProfileDialogOpen(false); refreshProfiles(); onDataChange?.(); return true; } catch (e) { - setMessage(t('settings.saveFailed', { error: String(e) })); + const errorText = e instanceof Error ? e.message : String(e); + toast.error(t('settings.saveFailed', { error: errorText })); return false; } }; @@ -378,14 +378,17 @@ export function Settings({ const deleteProfile = (id: string) => { ua.deleteModelProfile(id) .then(() => { - setMessage(t('settings.profileDeleted')); + toast.success(t('settings.profileDeleted')); if (form.id === id) { setForm(emptyForm()); } refreshProfiles(); onDataChange?.(); }) - .catch((e) => setMessage(t('settings.deleteFailed', { error: String(e) }))); + .catch((e) => { + const errorText = e instanceof Error ? e.message : String(e); + toast.error(t('settings.deleteFailed', { error: errorText })); + }); }; const toggleProfileEnabled = (profile: ModelProfile) => { @@ -877,10 +880,6 @@ export function Settings({ - {message && ( -

{message}

- )} - {/* Add / Edit Profile Dialog */} { setProfileDialogOpen(open); From fa2e4097075aa9d78e909d177393e31e1aea8dbd Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Tue, 31 Mar 2026 08:24:20 +0000 Subject: [PATCH 17/18] refactor(sync-dialog): connect on checkbox select and remove separate connect button --- src/pages/Settings.tsx | 49 +++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 85c571ae..bca31476 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -58,12 +58,13 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import { Link2Icon, PlusIcon, RefreshCwIcon } from "lucide-react"; +import { PlusIcon, RefreshCwIcon } from "lucide-react"; const MODEL_CATALOG_CACHE_TTL_MS = 5 * 60_000; let modelCatalogCache: { value: ModelCatalogProvider[]; expiresAt: number } | null = null; let profilesExtractedOnce = false; +let syncSelectionSessionCache: string[] = []; const PROVIDER_FALLBACK_OPTIONS = [ "openai", "openai-codex", @@ -111,7 +112,7 @@ export function Settings({ const [showSshTransferSpeedUi, setShowSshTransferSpeedUi] = useState(false); const [remoteDevices, setRemoteDevices] = useState([]); const [syncDialogOpen, setSyncDialogOpen] = useState(false); - const [selectedSyncHostIds, setSelectedSyncHostIds] = useState([]); + const [selectedSyncHostIds, setSelectedSyncHostIds] = useState(() => [...syncSelectionSessionCache]); const [syncStatusByHostId, setSyncStatusByHostId] = useState>({}); const [hostConnectionById, setHostConnectionById] = useState>({}); @@ -188,6 +189,10 @@ export function Settings({ }; }, [remoteDevices, syncDialogOpen, ua]); + useEffect(() => { + syncSelectionSessionCache = selectedSyncHostIds; + }, [selectedSyncHostIds]); + useEffect(() => { ua.getAppPreferences() .then((prefs) => { @@ -835,39 +840,25 @@ export function Settings({
{ - if (!connected) return; + onCheckedChange={async (value) => { const enabled = Boolean(value); - setSelectedSyncHostIds((prev) => enabled - ? [...prev, device.id] - : prev.filter((id) => id !== device.id)); + if (!enabled) { + setSelectedSyncHostIds((prev) => prev.filter((id) => id !== device.id)); + return; + } + if (!connected) { + if (!onConnectDevice) return; + const connectedNow = await onConnectDevice(device.id); + if (!connectedNow) return; + setHostConnectionById((prev) => ({ ...prev, [device.id]: true })); + } + setSelectedSyncHostIds((prev) => prev.includes(device.id) ? prev : [...prev, device.id]); }} /> {device.label}
- {connected ? statusText : ( - - )} + {connected ? statusText : t("settings.disconnected")} ); From b97eb03a477a3a1a75b728d509898a28e7dfb071 Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Tue, 31 Mar 2026 08:46:32 +0000 Subject: [PATCH 18/18] feat(sync-ui): persist selection/status in session and show connecting state --- src/locales/en.json | 1 + src/locales/zh.json | 1 + src/pages/Settings.tsx | 33 ++++++++++++++++++++++++--------- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index fb2982cb..093f61aa 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -282,6 +282,7 @@ "settings.syncStarted": "Started syncing {{count}} devices", "settings.syncFailedForDevice": "Sync failed for {{device}}: {{error}}", "settings.connectDevice": "Connect", + "settings.connecting": "Connecting", "settings.disconnected": "Disconnected", "settings.connectDeviceFirst": "Please connect device first: {{device}}", "settings.syncStatusIdle": "Idle", diff --git a/src/locales/zh.json b/src/locales/zh.json index b2a4afce..e87a096a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -281,6 +281,7 @@ "settings.syncStarted": "已开始同步 {{count}} 个设备", "settings.syncFailedForDevice": "{{device}} 同步失败:{{error}}", "settings.connectDevice": "连接", + "settings.connecting": "连接中", "settings.disconnected": "未连接", "settings.connectDeviceFirst": "请先连接设备:{{device}}", "settings.syncStatusIdle": "未同步", diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index bca31476..12d43ae6 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -65,6 +65,7 @@ const MODEL_CATALOG_CACHE_TTL_MS = 5 * 60_000; let modelCatalogCache: { value: ModelCatalogProvider[]; expiresAt: number } | null = null; let profilesExtractedOnce = false; let syncSelectionSessionCache: string[] = []; +let syncStatusSessionCache: Record = {}; const PROVIDER_FALLBACK_OPTIONS = [ "openai", "openai-codex", @@ -113,8 +114,9 @@ export function Settings({ const [remoteDevices, setRemoteDevices] = useState([]); const [syncDialogOpen, setSyncDialogOpen] = useState(false); const [selectedSyncHostIds, setSelectedSyncHostIds] = useState(() => [...syncSelectionSessionCache]); - const [syncStatusByHostId, setSyncStatusByHostId] = useState>({}); + const [syncStatusByHostId, setSyncStatusByHostId] = useState>(() => ({ ...syncStatusSessionCache })); const [hostConnectionById, setHostConnectionById] = useState>({}); + const [hostConnectingById, setHostConnectingById] = useState>({}); const [catalogRefreshed, setCatalogRefreshed] = useState(false); @@ -193,6 +195,10 @@ export function Settings({ syncSelectionSessionCache = selectedSyncHostIds; }, [selectedSyncHostIds]); + useEffect(() => { + syncStatusSessionCache = syncStatusByHostId; + }, [syncStatusByHostId]); + useEffect(() => { ua.getAppPreferences() .then((prefs) => { @@ -518,12 +524,15 @@ export function Settings({ for (const hostId of selectedSyncHostIds) { const device = remoteDevices.find((item) => item.id === hostId); const deviceName = device?.label || hostId; + syncStatusSessionCache = { ...syncStatusSessionCache, [hostId]: "syncing" }; setSyncStatusByHostId((prev) => ({ ...prev, [hostId]: "syncing" })); try { await api.remoteSyncProfilesToLocalAuth(hostId, deviceName); + syncStatusSessionCache = { ...syncStatusSessionCache, [hostId]: "success" }; setSyncStatusByHostId((prev) => ({ ...prev, [hostId]: "success" })); } catch (error) { const errorText = error instanceof Error ? error.message : String(error); + syncStatusSessionCache = { ...syncStatusSessionCache, [hostId]: "failed" }; setSyncStatusByHostId((prev) => ({ ...prev, [hostId]: "failed" })); toast.error(t("settings.syncFailedForDevice", { device: deviceName, error: errorText })); } @@ -827,19 +836,23 @@ export function Settings({ ) : remoteDevices.map((device) => { const checked = selectedSyncHostIds.includes(device.id); const connected = hostConnectionById[device.id] ?? false; + const connecting = hostConnectingById[device.id] ?? false; const status = syncStatusByHostId[device.id] || "idle"; - const statusText = status === "syncing" - ? t("settings.syncStatusSyncing") - : status === "success" - ? t("settings.syncStatusSuccess") - : status === "failed" - ? t("settings.syncStatusFailed") - : t("settings.syncStatusIdle"); + const statusText = connecting + ? t("settings.connecting") + : status === "syncing" + ? t("settings.syncStatusSyncing") + : status === "success" + ? t("settings.syncStatusSuccess") + : status === "failed" + ? t("settings.syncStatusFailed") + : t("settings.syncStatusIdle"); return ( );