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(