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
222 changes: 125 additions & 97 deletions src/components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter } from 'next/navigation'
import {
Circle,
LogOut,
User,
ChevronDown,
Expand Down Expand Up @@ -33,12 +32,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 @@ -76,38 +75,52 @@ 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 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 previousStatus = status
setStatus(newStatus) // optimistic UI
window.dispatchEvent(
new CustomEvent('agent-status-optimistic', {
detail: { from: previousStatus, to: newStatus },
})
)

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
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 (!res.ok) throw new Error(`Presence update failed: ${res.status}`)

if (!res.ok) throw new Error(`Presence update failed: ${res.status}`)
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 (
payload?.noop === true &&
(payload.reason === 'restricted_status' || payload.reason === 'concurrent_modification')
) {
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', {
Expand All @@ -116,20 +129,9 @@ export function Header() {
)
return false
}

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])
},
[isRegistered, status]
)

useEffect(() => {
if (isRegistered) {
Expand All @@ -138,7 +140,9 @@ 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 @@ -163,16 +167,26 @@ 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'
Comment on lines +181 to +185

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid labeling busy/away agents as offline

The new badge mapping only treats available as online/degraded, so busy and away now always render as 🔴 OFFLINE even when the connection is healthy. This misreports presence state and conflicts with the status selector (which still shows Busy/Away), making operators look disconnected when they are intentionally in a non-available state.

Useful? React with 👍 / 👎.


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 @@ -181,54 +195,63 @@ export function Header() {
{user.role}
</Badge>
<Badge
variant={connectionState === 'online' ? 'secondary' : connectionState === 'degraded' ? 'outline' : 'destructive'}
className={cn(
'text-xs font-semibold',
connectionState === 'online'
? 'text-green-700'
: connectionState === 'degraded'
? 'text-amber-700'
: 'text-red-700'
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' ? '🟢 ONLINE' : connectionState === 'degraded' ? '🟠 DEGRADED' : '🔴 OFFLINE'}
{presenceBadgeState === 'online'
? '🟢 ONLINE'
: presenceBadgeState === 'degraded'
? '🟠 DEGRADED'
: presenceBadgeState === 'on_call'
? '🟡 ON CALL'
: '🔴 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 @@ -260,7 +283,10 @@ 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 @@ -269,19 +295,21 @@ export function Header() {
</div>
</header>

{connectionState !== 'online' && (
{(showOfflineBanner || showDegradedBanner) && (
<div
className={cn(
'flex items-center justify-between border-b px-5 py-2 text-sm',
connectionState === 'degraded'
? 'border-amber-300 bg-amber-50 text-amber-800'
: 'border-red-300 bg-red-50 text-red-800'
showOfflineBanner
? 'border-red-300 bg-red-50 text-red-800'
: 'border-amber-300 bg-amber-50 text-amber-800'
)}
>
<span>
{connectionState === 'degraded'
? 'Connection is degraded. Calls should still route, but reconnecting is recommended.'
: 'You are currently offline. Calls will not be routed to you.'}
{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.'}
</span>
<button
type="button"
Expand Down
13 changes: 9 additions & 4 deletions src/hooks/usePresence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const HEARTBEAT_RETRY_MAX_MS = 30_000
const WRAP_UP_SYNC_EVENT = 'agent-wrap-up-ends-at'
const PRESENCE_RESTORE_KEY = 'agent-presence:last-status'
const PRESENCE_RESTORE_MAX_AGE_MS = 15_000
const HEARTBEAT_RECENCY_MS = 60_000
const PRESENCE_LEADER_KEY = 'presence-leader'
const PRESENCE_LEADER_FRESH_MS = 5_000
const BC_LEADER_HEARTBEAT_INTERVAL_MS = 3_000 // Leader broadcasts heartbeat every 3s
Expand Down Expand Up @@ -602,12 +603,16 @@ export function usePresence() {
if (raw) {
const parsed = JSON.parse(raw) as { status?: AgentStatus; at?: number }
const ageMs = Date.now() - Number(parsed?.at ?? 0)
const heartbeatAgeMs = lastHeartbeatIso ? Date.now() - new Date(lastHeartbeatIso).getTime() : Number.POSITIVE_INFINITY
const heartbeatAgeMs = lastHeartbeatIso
? Date.now() - new Date(lastHeartbeatIso).getTime()
: Number.POSITIVE_INFINITY

// Only restore when this was a near-immediate refresh while actively working.
// If heartbeat is older than 60s, default to offline and require manual re-available.
if (
parsed?.status &&
parsed.status !== 'offline' &&
parsed?.status === 'available' &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore non-offline manual statuses on page reload

Restricting session restore to parsed?.status === 'available' causes agents who refresh while busy or away to be forced to offline: beforeunload/pagehide still sends the offline beacon, init() then reads currentStatus === 'offline', and this guard now blocks restoring the prior manual status. This is a regression from the previous parsed.status !== 'offline' behavior and will unexpectedly drop busy/away agents offline on every reload.

Useful? React with 👍 / 👎.

ageMs <= PRESENCE_RESTORE_MAX_AGE_MS &&
heartbeatAgeMs <= PRESENCE_RESTORE_MAX_AGE_MS
heartbeatAgeMs <= HEARTBEAT_RECENCY_MS
) {
await updateStatus(parsed.status)
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
Loading
Loading