Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1c75df9
feat(profiles): move device sync to Profiles with selectable sources
dev01lay2 Mar 30, 2026
836fc7a
fix(ci): update ModelProfile test fixtures with sync metadata fields
dev01lay2 Mar 30, 2026
7673098
fix(ci): update tauri profile initializers for sync metadata fields
dev01lay2 Mar 30, 2026
90cea2d
fix(coverage): update cli ModelProfile initializer for sync metadata
dev01lay2 Mar 30, 2026
2ebd955
refactor(ui): use icon buttons for sync and add profile actions
dev01lay2 Mar 30, 2026
b243657
feat(sync-ui): close dialog with toast on sync start and spin sync icon
dev01lay2 Mar 31, 2026
5cc4988
refactor(profiles-ui): reduce profile info density with hoverable badges
dev01lay2 Mar 31, 2026
42f971e
feat(sync-dialog): require SSH connection before selecting device and…
dev01lay2 Mar 31, 2026
915ac45
fix(sync-dialog): improve i18n and disconnected-device affordance
dev01lay2 Mar 31, 2026
3e1e3f9
refactor(sync-dialog): use disconnected/connect badge instead of link…
dev01lay2 Mar 31, 2026
6a8e017
fix(sync-dialog): right-align connect badge with fixed width
dev01lay2 Mar 31, 2026
d47fada
feat(sync-dialog): open target VPS connection flow directly from conn…
dev01lay2 Mar 31, 2026
ac39c75
refactor(sync-dialog): use icon button for connect action
dev01lay2 Mar 31, 2026
368df75
refactor(connect-flow): keep user on Profiles page after device connect
dev01lay2 Mar 31, 2026
faf9160
fix(connect-flow): keep sync dialog open and auto-select device after…
dev01lay2 Mar 31, 2026
d3015b1
fix(profiles): replace inline status message with toasts
dev01lay2 Mar 31, 2026
fa2e409
refactor(sync-dialog): connect on checkbox select and remove separate…
dev01lay2 Mar 31, 2026
b97eb03
feat(sync-ui): persist selection/status in session and show connectin…
dev01lay2 Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions clawpal-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,9 @@ fn run_profile_command(command: ProfileCommands) -> Result<serde_json::Value, St
api_key,
base_url: None,
description: None,
sync_source_device_name: None,
sync_source_host_id: None,
sync_synced_at: None,
enabled: true,
};
let saved = upsert_profile(&openclaw, profile).map_err(|e| e.to_string())?;
Expand Down
18 changes: 18 additions & 0 deletions clawpal-core/src/precheck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand All @@ -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,
},
];
Expand Down
18 changes: 18 additions & 0 deletions clawpal-core/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ pub struct ModelProfile {
pub api_key: Option<String>,
pub base_url: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub sync_source_device_name: Option<String>,
#[serde(default)]
pub sync_source_host_id: Option<String>,
#[serde(default)]
pub sync_synced_at: Option<String>,
pub enabled: bool,
}

Expand Down Expand Up @@ -415,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,
}
}
Expand Down Expand Up @@ -580,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();
Expand All @@ -603,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 {
Expand All @@ -614,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,
};

Expand Down
3 changes: 3 additions & 0 deletions clawpal-core/tests/oauth_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
3 changes: 3 additions & 0 deletions clawpal-core/tests/profile_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
32 changes: 32 additions & 0 deletions src-tauri/src/commands/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@ fn merge_remote_profile_into_local(
remote: &ModelProfile,
resolved_api_key: Option<String>,
resolved_base_url: Option<String>,
source_device_name: &str,
source_host_id: &str,
synced_at: &str,
) -> bool {
let remote_key = normalize_profile_key(remote);
let target_idx = local_profiles
Expand Down Expand Up @@ -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;
}

Expand All @@ -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
}
Expand Down Expand Up @@ -352,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);
Expand Down Expand Up @@ -573,6 +585,7 @@ pub struct RemoteAuthSyncResult {
pub async fn remote_sync_profiles_to_local_auth(
pool: State<'_, SshConnectionPool>,
host_id: String,
source_device_name: Option<String>,
) -> Result<RemoteAuthSyncResult, String> {
let (remote_profiles, _) = collect_remote_profiles_from_openclaw(&pool, &host_id, true).await?;
if remote_profiles.is_empty() {
Expand All @@ -589,6 +602,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;
Expand Down Expand Up @@ -652,6 +672,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 {
Expand Down Expand Up @@ -1310,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,
}
}
Expand Down Expand Up @@ -1576,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(),
Expand Down Expand Up @@ -1719,6 +1748,9 @@ pub fn resolve_provider_auth(provider: String) -> Result<ProviderAuthSuggestion,
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 key = resolve_profile_api_key(&probe_profile, &global_base);
Expand Down
12 changes: 10 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,6 @@ export function App() {
});

const {
profileSyncStatus,
showSshTransferSpeedUi,
sshTransferStats,
doctorNavPulse,
Expand Down Expand Up @@ -417,7 +416,6 @@ export function App() {
</nav>

<SidebarFooter
profileSyncStatus={profileSyncStatus}
showSshTransferSpeedUi={showSshTransferSpeedUi}
isRemote={isRemote}
isConnected={isConnected}
Expand Down Expand Up @@ -472,6 +470,11 @@ export function App() {
globalMode
section="profiles"
onOpenDoctor={openDoctor}
onConnectDevice={(hostId) => (
connectWithPassphraseFallback(hostId)
.then(() => true)
.catch(() => false)
)}
onDataChange={bumpConfigVersion}
/>
)}
Expand All @@ -481,6 +484,11 @@ export function App() {
globalMode
section="preferences"
onOpenDoctor={openDoctor}
onConnectDevice={(hostId) => (
connectWithPassphraseFallback(hostId)
.then(() => true)
.catch(() => false)
)}
onDataChange={bumpConfigVersion}
hasAppUpdate={appUpdateAvailable}
onAppUpdateSeen={() => setAppUpdateAvailable(false)}
Expand Down
31 changes: 3 additions & 28 deletions src/components/SidebarFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import { Suspense, lazy } from "react";
import { useTranslation } from "react-i18next";
import { shouldShowPendingChangesBar } from "@/lib/route-ui";
import { cn, formatBytes } from "@/lib/utils";
import { formatBytes } from "@/lib/utils";
import { api } from "../lib/api";
import type { SshTransferStats } from "../lib/types";

const PendingChangesBar = lazy(() => 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;
Expand All @@ -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 (
<>
<div className="px-5 pb-3 text-[11px] text-muted-foreground/80">
<div className="flex items-center gap-1.5">
<span className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
profileSyncStatus.phase === "syncing" && "bg-amber-500 animate-pulse",
profileSyncStatus.phase === "success" && "bg-green-500",
profileSyncStatus.phase === "error" && "bg-red-500",
profileSyncStatus.phase === "idle" && "bg-muted-foreground/40",
)} />
<span>
{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") })}
</span>
</div>
{showSshTransferSpeedUi && isRemote && isConnected && (
<div className="mt-2 border-t border-border/40 pt-2 text-muted-foreground/75">
<div className="text-muted-foreground/75">
<div className="text-[10px] uppercase tracking-wide">{t("doctor.sshTransferSpeedTitle")}</div>
<div className="mt-0.5">
{t("doctor.sshTransferSpeedDown", { speed: `${formatBytes(Math.max(0, Math.round(sshTransferStats?.downloadBytesPerSec ?? 0)))} /s` })}
Expand Down
17 changes: 9 additions & 8 deletions src/hooks/useSshConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,17 +175,18 @@ 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;
remoteAuthSyncAtRef.current[hostId] = now;
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);
Expand All @@ -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(() => {
Expand Down
Loading
Loading