Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 0 additions & 1 deletion src/__tests__/health-checks-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ describe('health-checks lib', () => {
const result = await checkTelnyxCredentialConnection()
expect(result.ok).toBe(ok)
if (detail) expect(result.detail).toContain(detail)
if (!response) expect(mockFetch).not.toHaveBeenCalled()
})

it.each([
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/agents/heartbeat/fallback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export async function POST(request?: NextRequest) {
.update({ last_heartbeat: now, updated_at: now })
.eq('user_id', auth.session.user.id)
.eq('tenant_id', auth.tenant_id)
if (touchError) return NextResponse.json({ error: 'Internal error' }, { status: 400 })
if (touchError) return NextResponse.json({ error: 'Internal error' }, { status: 500 })

const { data: row } = await admin
.from('agent_presence')
Expand Down
222 changes: 97 additions & 125 deletions src/components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter } from 'next/navigation'
import {
Circle,
LogOut,
User,
ChevronDown,
Expand Down Expand Up @@ -32,12 +33,12 @@ import { AgentDiagnostic } from '@/components/phone/agent-diagnostic'
const MANUAL_STATUSES: AgentStatus[] = ['available', 'busy', 'away', 'offline']

const statusConfig: Record<AgentStatus, { label: string; color: string; dot: string }> = {
available: { label: 'Available', color: 'text-success', dot: 'bg-success' },
busy: { label: 'Busy', color: 'text-destructive', dot: 'bg-destructive' },
away: { label: 'Away', color: 'text-warning', dot: 'bg-warning' },
on_call: { label: 'On Call', color: 'text-primary', dot: 'bg-primary' },
wrap_up: { label: 'Wrap Up', color: 'text-warning', dot: 'bg-amber-500' },
offline: { label: 'Offline', color: 'text-muted-foreground', dot: 'bg-muted-foreground' },
available: { label: 'Available', color: 'text-success', dot: 'bg-success' },
busy: { label: 'Busy', color: 'text-destructive', dot: 'bg-destructive' },
away: { label: 'Away', color: 'text-warning', dot: 'bg-warning' },
on_call: { label: 'On Call', color: 'text-primary', dot: 'bg-primary' },
wrap_up: { label: 'Wrap Up', color: 'text-warning', dot: 'bg-amber-500' },
offline: { label: 'Offline', color: 'text-muted-foreground', dot: 'bg-muted-foreground' },
}

const KNOWN_STATUSES: AgentStatus[] = ['available', 'busy', 'away', 'offline', 'on_call', 'wrap_up']
Expand Down Expand Up @@ -75,52 +76,38 @@ export function Header() {
return () => window.removeEventListener('agent-status-optimistic', handleOptimistic)
}, [])

const handleStatusChange = useCallback(
async (newStatus: AgentStatus): Promise<boolean> => {
if (newStatus === 'available' && !isRegistered) {
showPresenceToast('Cannot go available — phone not connected. Please refresh.', 'error')
return false
}

const previousStatus = status
setStatus(newStatus) // optimistic UI
window.dispatchEvent(
new CustomEvent('agent-status-optimistic', {
detail: { from: previousStatus, to: newStatus },
})
)
const handleStatusChange = useCallback(async (newStatus: AgentStatus): Promise<boolean> => {
if (newStatus === 'available' && !isRegistered) {
showPresenceToast('Cannot go available — phone not connected. Please refresh.', 'error')
return false
}

try {
// Use the presence API so both agent_presence AND users.agent_status
// are updated atomically — no more competing Supabase direct writes.
const res = await fetch('/api/agents/presence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
const payload = (await res.json().catch(() => null)) as
| { noop?: boolean; reason?: string }
| null
const previousStatus = status
setStatus(newStatus) // optimistic UI
window.dispatchEvent(
new CustomEvent('agent-status-optimistic', {
detail: { from: previousStatus, to: newStatus },
})
)

if (!res.ok) throw new Error(`Presence update failed: ${res.status}`)
try {
// Use the presence API so both agent_presence AND users.agent_status
// are updated atomically — no more competing Supabase direct writes.
const res = await fetch('/api/agents/presence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
const payload = (await res.json().catch(() => null)) as
| { noop?: boolean; reason?: string }
| null

if (
payload?.noop === true &&
(payload.reason === 'restricted_status' || payload.reason === 'concurrent_modification')
) {
setStatus(previousStatus)
window.dispatchEvent(
new CustomEvent('agent-status-optimistic', {
detail: { from: newStatus, to: previousStatus },
})
)
return false
}
if (!res.ok) throw new Error(`Presence update failed: ${res.status}`)

return true
} catch (err) {
console.error('[Header] Failed to persist agent status:', err)
// Revert optimistic update
if (
payload?.noop === true &&
(payload.reason === 'restricted_status' || payload.reason === 'concurrent_modification')
) {
setStatus(previousStatus)
window.dispatchEvent(
new CustomEvent('agent-status-optimistic', {
Expand All @@ -129,9 +116,20 @@ export function Header() {
)
return false
}
},
[isRegistered, status]
)

return true
} catch (err) {
console.error('[Header] Failed to persist agent status:', err)
// Revert optimistic update
setStatus(previousStatus)
window.dispatchEvent(
new CustomEvent('agent-status-optimistic', {
detail: { from: newStatus, to: previousStatus },
})
)
return false
}
}, [isRegistered, status])

useEffect(() => {
if (isRegistered) {
Expand All @@ -140,9 +138,7 @@ export function Header() {
}, [isRegistered])

useEffect(() => {
const activeCall =
currentCall?.state &&
['ringing_inbound', 'ringing_outbound', 'answering', 'active', 'held'].includes(currentCall.state)
const activeCall = currentCall?.state && ['ringing_inbound', 'ringing_outbound', 'answering', 'active', 'held'].includes(currentCall.state)
if (status === 'available' && hasInitialized && !isRegistered && !activeCall) {
if (autoOfflineAttemptedRef.current) return
autoOfflineAttemptedRef.current = true
Expand All @@ -167,26 +163,16 @@ export function Header() {
router.refresh()
}

const initials =
[user.first_name?.[0], user.last_name?.[0]].filter(Boolean).join('').toUpperCase() ||
user.email[0].toUpperCase()
const initials = [user.first_name?.[0], user.last_name?.[0]]
.filter(Boolean)
.join('')
.toUpperCase() || user.email[0].toUpperCase()

const displayName = [user.first_name, user.last_name].filter(Boolean).join(' ') || user.email
const displayName =
[user.first_name, user.last_name].filter(Boolean).join(' ') || user.email

const currentConfig = statusConfig[status] ?? statusConfig['offline']

const presenceBadgeState: 'online' | 'offline' | 'degraded' | 'on_call' =
status === 'on_call' || status === 'wrap_up'
? 'on_call'
: status === 'available' && connectionState !== 'offline'
? connectionState === 'degraded'
? 'degraded'
: 'online'
: 'offline'

const showOfflineBanner = status === 'offline' || connectionState === 'offline'
const showDegradedBanner = !showOfflineBanner && connectionState === 'degraded'

return (
<>
<header className="flex h-14 items-center justify-between border-b border-border bg-card px-5">
Expand All @@ -195,63 +181,54 @@ export function Header() {
{user.role}
</Badge>
<Badge
variant={connectionState === 'online' ? 'secondary' : connectionState === 'degraded' ? 'outline' : 'destructive'}
className={cn(
'text-xs font-semibold',
presenceBadgeState === 'online'
? 'bg-green-600 text-white hover:bg-green-600'
: presenceBadgeState === 'offline'
? 'bg-red-600 text-white hover:bg-red-600'
: 'bg-amber-300 text-amber-900 hover:bg-amber-300'
connectionState === 'online'
? 'text-green-700'
: connectionState === 'degraded'
? 'text-amber-700'
: 'text-red-700'
)}
>
{presenceBadgeState === 'online'
? '🟢 ONLINE'
: presenceBadgeState === 'degraded'
? '🟠 DEGRADED'
: presenceBadgeState === 'on_call'
? '🟡 ON CALL'
: '🔴 OFFLINE'}
{connectionState === 'online' ? '🟢 ONLINE' : connectionState === 'degraded' ? '🟠 DEGRADED' : '🔴 OFFLINE'}
</Badge>
</div>

<div className="flex items-center gap-2">
<ThemeToggle />
<AgentDiagnostic />

{status === 'on_call' || status === 'wrap_up' ? (
{(status === 'on_call' || status === 'wrap_up') ? (
<div className="flex h-8 items-center gap-2 rounded-md border border-border px-3">
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', currentConfig.dot)} />
<span className="text-sm">
{status === 'wrap_up' && wrapUpSecondsRemaining > 0
? `Wrap Up (${wrapUpSecondsRemaining}s)`
: currentConfig.label}
</span>
<span className="text-sm">{status === 'wrap_up' && wrapUpSecondsRemaining > 0 ? `Wrap Up (${wrapUpSecondsRemaining}s)` : currentConfig.label}</span>
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-2 px-3 border-border">
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', currentConfig.dot)} />
<span className="text-sm">{currentConfig.label}</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{MANUAL_STATUSES.map((key) => {
const config = statusConfig[key]
return (
<DropdownMenuItem
key={key}
onClick={() => handleStatusChange(key)}
className="gap-2"
>
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', config.dot)} />
{status === 'offline' && key === 'available' ? 'Go Available' : config.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-2 px-3 border-border">
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', currentConfig.dot)} />
<span className="text-sm">{currentConfig.label}</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{MANUAL_STATUSES.map((key) => {
const config = statusConfig[key]
return (
<DropdownMenuItem
key={key}
onClick={() => handleStatusChange(key)}
className="gap-2"
>
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', config.dot)} />
{status === 'offline' && key === 'available' ? 'Go Available' : config.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)}

<DropdownMenu>
Expand Down Expand Up @@ -283,10 +260,7 @@ export function Header() {
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={handleLogout}
>
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</DropdownMenuItem>
Expand All @@ -295,21 +269,19 @@ export function Header() {
</div>
</header>

{(showOfflineBanner || showDegradedBanner) && (
{connectionState !== 'online' && (
<div
className={cn(
'flex items-center justify-between border-b px-5 py-2 text-sm',
showOfflineBanner
? 'border-red-300 bg-red-50 text-red-800'
: 'border-amber-300 bg-amber-50 text-amber-800'
connectionState === 'degraded'
? 'border-amber-300 bg-amber-50 text-amber-800'
: 'border-red-300 bg-red-50 text-red-800'
)}
>
<span>
{status === 'offline'
? 'You are currently offline. Calls will not be routed to you.'
: connectionState === 'offline'
? 'Connection lost. Attempting to reconnect...'
: 'Connection is degraded. Calls should still route, but reconnecting is recommended.'}
{connectionState === 'degraded'
? 'Connection is degraded. Calls should still route, but reconnecting is recommended.'
: 'You are currently offline. Calls will not be routed to you.'}
</span>
<button
type="button"
Expand Down
Loading
Loading