diff --git a/src/components/proxy/ClaudeDesktopRouteToggle.tsx b/src/components/proxy/ClaudeDesktopRouteToggle.tsx
index bf9aa950f..df6e8ffab 100644
--- a/src/components/proxy/ClaudeDesktopRouteToggle.tsx
+++ b/src/components/proxy/ClaudeDesktopRouteToggle.tsx
@@ -84,7 +84,7 @@ export function ClaudeDesktopRouteToggle({
className={cn(
"h-4 w-4 transition-colors",
isRunning
- ? "text-emerald-500 animate-pulse"
+ ? "text-emerald-500 status-heartbeat"
: "text-muted-foreground",
)}
/>
diff --git a/src/components/proxy/FailoverToggle.tsx b/src/components/proxy/FailoverToggle.tsx
index 739fb03b7..8f44079c6 100644
--- a/src/components/proxy/FailoverToggle.tsx
+++ b/src/components/proxy/FailoverToggle.tsx
@@ -72,7 +72,7 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
className={cn(
"h-4 w-4 transition-colors",
isEnabled
- ? "text-emerald-500 animate-pulse"
+ ? "text-emerald-500 status-heartbeat"
: "text-muted-foreground",
)}
/>
diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx
index fa2993d40..3c566556a 100644
--- a/src/components/proxy/ProxyToggle.tsx
+++ b/src/components/proxy/ProxyToggle.tsx
@@ -75,7 +75,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
className={cn(
"h-4 w-4 transition-colors",
takeoverEnabled
- ? "text-emerald-500 animate-pulse"
+ ? "text-emerald-500 status-heartbeat"
: "text-muted-foreground",
)}
/>
diff --git a/src/components/settings/ProxyTabContent.tsx b/src/components/settings/ProxyTabContent.tsx
index 6f33cf6bc..4c985ebbe 100644
--- a/src/components/settings/ProxyTabContent.tsx
+++ b/src/components/settings/ProxyTabContent.tsx
@@ -118,7 +118,7 @@ export function ProxyTabContent({
className="gap-1.5 h-6 ml-auto mr-2"
>
{isRunning
? t("settings.advanced.proxy.running")
diff --git a/src/index.css b/src/index.css
index 5a1fbcb9e..f44492a0d 100644
--- a/src/index.css
+++ b/src/index.css
@@ -145,6 +145,35 @@ html.dark {
color-scheme: dark;
}
+.status-heartbeat {
+ opacity: 1;
+ transition-property: color, opacity;
+ transition-duration: 150ms, 300ms;
+ transition-timing-function: ease, ease-out;
+}
+
+html[data-window-active="true"][data-status-heartbeat="true"]
+ .status-heartbeat {
+ opacity: 0.5;
+}
+
+html[data-window-active="false"] .status-heartbeat {
+ opacity: 1;
+ transition-duration: 0s;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .status-heartbeat {
+ opacity: 1;
+ transition-property: color;
+ }
+
+ html[data-window-active="true"][data-status-heartbeat="true"]
+ .status-heartbeat {
+ opacity: 1;
+ }
+}
+
::-webkit-scrollbar {
display: none;
}
diff --git a/src/lib/windowActivity.ts b/src/lib/windowActivity.ts
new file mode 100644
index 000000000..3fbd1be27
--- /dev/null
+++ b/src/lib/windowActivity.ts
@@ -0,0 +1,61 @@
+import { isTauri } from "@tauri-apps/api/core";
+import { getCurrentWindow } from "@tauri-apps/api/window";
+
+const HEARTBEAT_INTERVAL_MS = 3000;
+const HEARTBEAT_DIM_MS = 300;
+
+let initialized = false;
+let heartbeatInterval: number | undefined;
+let heartbeatReset: number | undefined;
+
+function stopHeartbeat() {
+ if (heartbeatInterval !== undefined) {
+ window.clearInterval(heartbeatInterval);
+ heartbeatInterval = undefined;
+ }
+ if (heartbeatReset !== undefined) {
+ window.clearTimeout(heartbeatReset);
+ heartbeatReset = undefined;
+ }
+ delete document.documentElement.dataset.statusHeartbeat;
+}
+
+function startHeartbeat() {
+ stopHeartbeat();
+ heartbeatInterval = window.setInterval(() => {
+ document.documentElement.dataset.statusHeartbeat = "true";
+ heartbeatReset = window.setTimeout(() => {
+ delete document.documentElement.dataset.statusHeartbeat;
+ heartbeatReset = undefined;
+ }, HEARTBEAT_DIM_MS);
+ }, HEARTBEAT_INTERVAL_MS);
+}
+
+function setWindowActive(active: boolean) {
+ document.documentElement.dataset.windowActive = String(active);
+
+ if (active) {
+ startHeartbeat();
+ } else {
+ stopHeartbeat();
+ }
+}
+
+export function initializeWindowActivity() {
+ if (initialized) return;
+ initialized = true;
+
+ setWindowActive(document.hasFocus());
+
+ // Browser focus events are a fallback for non-Tauri renderer tests and dev mode.
+ window.addEventListener("focus", () => setWindowActive(true));
+ window.addEventListener("blur", () => setWindowActive(false));
+
+ if (isTauri()) {
+ void getCurrentWindow()
+ .onFocusChanged(({ payload }) => setWindowActive(payload))
+ .catch((error) => {
+ console.error("Failed to observe window focus changes", error);
+ });
+ }
+}
diff --git a/src/main.tsx b/src/main.tsx
index bc15c43ee..2022fb43a 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -23,6 +23,7 @@ import {
MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
syncModelsDevPricingOnStartup,
} from "./lib/modelsDevAutoSync";
+import { initializeWindowActivity } from "@/lib/windowActivity";
installGlobalErrorHandlers();
@@ -114,6 +115,8 @@ async function bootstrap() {
reportFrontendError("get_init_error", e);
}
+ initializeWindowActivity();
+
ReactDOM.createRoot(document.getElementById("root")!).render(