diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index 82f5be0..d7ee87b 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -13,6 +13,8 @@ import {
faCalendarCheck,
faPhone,
faUsers,
+ faWifi,
+ faWifiSlash,
faArrowRightFromBracket,
faTowerBroadcast,
faChartLine,
@@ -32,6 +34,7 @@ import { Avatar } from "@/components/base/avatar/avatar";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
import { useAuth } from "@/providers/auth-provider";
+import { useNetworkStatus } from "@/hooks/use-network-status";
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
import { cx } from "@/utils/cx";
@@ -123,6 +126,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
const { logout, user } = useAuth();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
+ const networkQuality = useNetworkStatus();
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
@@ -218,6 +222,25 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
))}
+ {/* Network indicator — only shows when network is degraded */}
+ {networkQuality !== 'good' && (
+
+
+ {!collapsed && (
+ {networkQuality === 'offline' ? 'No connection' : 'Unstable network'}
+ )}
+
+ )}
+
{/* Account card */}
{collapsed ? (
diff --git a/src/hooks/use-network-status.ts b/src/hooks/use-network-status.ts
new file mode 100644
index 0000000..e988f31
--- /dev/null
+++ b/src/hooks/use-network-status.ts
@@ -0,0 +1,46 @@
+import { useState, useEffect, useRef } from 'react';
+
+export type NetworkQuality = 'good' | 'unstable' | 'offline';
+
+export const useNetworkStatus = (): NetworkQuality => {
+ const [quality, setQuality] = useState(navigator.onLine ? 'good' : 'offline');
+ const dropCountRef = useRef(0);
+ const resetTimerRef = useRef(null);
+
+ useEffect(() => {
+ const handleOffline = () => {
+ console.log('[NETWORK] Offline');
+ setQuality('offline');
+ };
+
+ const handleOnline = () => {
+ console.log('[NETWORK] Back online');
+ dropCountRef.current++;
+
+ // 3+ drops in 2 minutes = unstable
+ if (dropCountRef.current >= 3) {
+ setQuality('unstable');
+ } else {
+ setQuality('good');
+ }
+
+ // Reset drop counter after 2 minutes of stability
+ if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
+ resetTimerRef.current = window.setTimeout(() => {
+ dropCountRef.current = 0;
+ if (navigator.onLine) setQuality('good');
+ }, 120000);
+ };
+
+ window.addEventListener('offline', handleOffline);
+ window.addEventListener('online', handleOnline);
+
+ return () => {
+ window.removeEventListener('offline', handleOffline);
+ window.removeEventListener('online', handleOnline);
+ if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
+ };
+ }, []);
+
+ return quality;
+};
diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts
index 3d44bee..83ec8c4 100644
--- a/src/lib/api-client.ts
+++ b/src/lib/api-client.ts
@@ -88,6 +88,21 @@ const handleResponse = async (response: Response, silent = false, retryFn?: (
const json = await response.json().catch(() => null);
+ // Sidecar may return 400 when the underlying platform token expired — retry with refreshed token
+ if (!response.ok && retryFn) {
+ const msg = (json?.message ?? '').toLowerCase();
+ if (msg.includes('agent identity') || msg.includes('token') || msg.includes('unauthenticated')) {
+ const refreshed = await tryRefreshToken();
+ if (refreshed) {
+ const retryResponse = await retryFn();
+ return handleResponse(retryResponse, silent);
+ }
+ clearTokens();
+ if (!silent) notify.error('Session expired. Please log in again.');
+ throw new AuthError();
+ }
+ }
+
if (!response.ok) {
const message = json?.message ?? json?.error ?? `Request failed (${response.status})`;
if (!silent) notify.error(message);
@@ -152,7 +167,22 @@ export const apiClient = {
}
}
- const json = await response.json();
+ let json = await response.json();
+
+ // Platform returns 200 with UNAUTHENTICATED error when token expires — retry with refresh
+ const authError = json.errors?.find((e: any) => e.extensions?.code === 'UNAUTHENTICATED');
+ if (authError) {
+ const refreshed = await tryRefreshToken();
+ if (refreshed) {
+ const retryResponse = await doFetch();
+ json = await retryResponse.json();
+ } else {
+ clearTokens();
+ if (!options?.silent) notify.error('Session expired', 'Please log in again.');
+ throw new AuthError();
+ }
+ }
+
if (json.errors) {
const message = json.errors[0]?.message ?? 'GraphQL error';
if (!options?.silent) notify.error('Query failed', message);
diff --git a/src/providers/sip-provider.tsx b/src/providers/sip-provider.tsx
index f510748..9926898 100644
--- a/src/providers/sip-provider.tsx
+++ b/src/providers/sip-provider.tsx
@@ -14,27 +14,25 @@ import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOu
import { apiClient } from '@/lib/api-client';
import type { SIPConfig } from '@/types/sip';
-const getSipConfig = (): SIPConfig => {
+// SIP config comes exclusively from the Agent entity (stored on login).
+// No env var fallback — users without an Agent entity don't connect SIP.
+const getSipConfig = (): SIPConfig | null => {
try {
const stored = localStorage.getItem('helix_agent_config');
if (stored) {
const config = JSON.parse(stored);
- return {
- displayName: 'Helix Agent',
- uri: config.sipUri,
- password: config.sipPassword,
- wsServer: config.sipWsServer,
- stunServers: 'stun:stun.l.google.com:19302',
- };
+ if (config.sipUri && config.sipWsServer) {
+ return {
+ displayName: 'Helix Agent',
+ uri: config.sipUri,
+ password: config.sipPassword,
+ wsServer: config.sipWsServer,
+ stunServers: 'stun:stun.l.google.com:19302',
+ };
+ }
}
} catch {}
- return {
- displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent',
- uri: import.meta.env.VITE_SIP_URI ?? '',
- password: import.meta.env.VITE_SIP_PASSWORD ?? '',
- wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '',
- stunServers: 'stun:stun.l.google.com:19302',
- };
+ return null;
};
export const SipProvider = ({ children }: PropsWithChildren) => {
@@ -55,9 +53,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
});
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]);
- // Auto-connect SIP on mount
+ // Auto-connect SIP on mount — only if Agent entity has SIP config
useEffect(() => {
- connectSip(getSipConfig());
+ const config = getSipConfig();
+ if (config) {
+ connectSip(config);
+ } else {
+ console.log('[SIP] No agent SIP config — skipping connection');
+ }
}, []);
// Call duration timer
@@ -178,7 +181,7 @@ export const useSip = () => {
isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState),
ozonetelStatus: 'logged-in' as const,
ozonetelError: null as string | null,
- connect: () => connectSip(getSipConfig()),
+ connect: () => { const c = getSipConfig(); if (c) connectSip(c); },
disconnect: disconnectSip,
makeCall,
dialOutbound,