diff --git a/frontend/src/components/flowpilot/EscalationQueue.tsx b/frontend/src/components/flowpilot/EscalationQueue.tsx index af2de015..20e865f1 100644 --- a/frontend/src/components/flowpilot/EscalationQueue.tsx +++ b/frontend/src/components/flowpilot/EscalationQueue.tsx @@ -3,12 +3,21 @@ import { useNavigate } from 'react-router-dom' import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react' import { aiSessionsApi } from '@/api' import type { AISessionSummary } from '@/types/ai-session' +import { timeAgo } from '@/lib/timeAgo' interface EscalationQueueProps { onPickup?: (sessionId: string) => void + onCountChange?: (count: number) => void } -export function EscalationQueue({ onPickup }: EscalationQueueProps) { +function waitTimeColor(createdAt: string): string { + const hours = (Date.now() - new Date(createdAt).getTime()) / 3_600_000 + if (hours >= 4) return '#f87171' // danger + if (hours >= 1) return '#fbbf24' // warning/amber + return '#848b9b' // muted +} + +export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProps) { const navigate = useNavigate() const [sessions, setSessions] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -19,7 +28,12 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) { setError(null) try { const data = await aiSessionsApi.getEscalationQueue() - setSessions(data) + // Sort oldest-first — longest waiting = most urgent + const sorted = [...data].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ) + setSessions(sorted) + onCountChange?.(sorted.length) } catch { setError('Failed to load escalation queue') } finally { @@ -29,6 +43,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) { useEffect(() => { loadQueue() + // eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount }, []) const handlePickup = (sessionId: string) => { @@ -50,7 +65,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) { if (error) { return (
-

{error}

+

{error}

+
+ +
))} diff --git a/frontend/src/pages/EscalationQueuePage.tsx b/frontend/src/pages/EscalationQueuePage.tsx index 0945f147..cddbff18 100644 --- a/frontend/src/pages/EscalationQueuePage.tsx +++ b/frontend/src/pages/EscalationQueuePage.tsx @@ -1,20 +1,27 @@ +import { useState } from 'react' import { AlertTriangle } from 'lucide-react' import { EscalationQueue } from '@/components/flowpilot' export default function EscalationQueuePage() { + const [count, setCount] = useState(null) + return ( -
+
- - + +

Escalation Queue

-

Sessions from your team waiting for pickup

+

+ {count !== null && count > 0 + ? `${count} session${count !== 1 ? 's' : ''} waiting for pickup` + : 'Sessions from your team waiting for pickup'} +

- +
) } diff --git a/frontend/src/pages/SessionHistoryPage.tsx b/frontend/src/pages/SessionHistoryPage.tsx index 1a10b230..fb08106a 100644 --- a/frontend/src/pages/SessionHistoryPage.tsx +++ b/frontend/src/pages/SessionHistoryPage.tsx @@ -17,12 +17,25 @@ import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { getSessionResumePath } from '@/lib/routing' +const PAGE_SIZE = 25 + +const TABS = [ + { id: 'ai', label: 'AI Sessions' }, + { id: 'flows', label: 'Flow Sessions' }, +] as const + +type TabId = typeof TABS[number]['id'] + export function SessionHistoryPage() { const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() + const [activeTab, setActiveTab] = useState('ai') + // ── AI Session state ── const [aiSessions, setAiSessions] = useState([]) const [aiLoading, setAiLoading] = useState(false) + const [aiLoadingMore, setAiLoadingMore] = useState(false) + const [aiHasMore, setAiHasMore] = useState(false) const [aiSearchInput, setAiSearchInput] = useState('') const aiSearchTimeout = useRef | undefined>(undefined) const [aiFilters, setAiFilters] = useState({ @@ -34,11 +47,12 @@ export function SessionHistoryPage() { date_to: '', }) + // ── Flow Session state ── const [sessions, setSessions] = useState([]) - const [hasMore, setHasMore] = useState(false) + const [flowLoading, setFlowLoading] = useState(false) + const [flowHasMore, setFlowHasMore] = useState(false) const [trees, setTrees] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [filter, setFilter] = useState<'all' | 'completed' | 'active' | 'prepared'>('active') + const [flowTab, setFlowTab] = useState<'all' | 'completed' | 'active' | 'prepared'>('active') // Close session popover state const [closingSessionId, setClosingSessionId] = useState(null) @@ -47,28 +61,19 @@ export function SessionHistoryPage() { const [closeLoading, setCloseLoading] = useState(false) const closePopoverRef = useRef(null) - // Initialize filters from URL params const [filters, setFilters] = useState(() => { const ticketNumber = searchParams.get('ticket') || '' const clientName = searchParams.get('client') || '' const treeName = searchParams.get('tree') || '' const dateType = (searchParams.get('dateType') || 'started') as 'started' | 'completed' - const from = searchParams.get('from') const to = searchParams.get('to') const dateRange: DateRange | undefined = from && to ? { from: new Date(from), to: new Date(to) } : undefined - - return { - ticketNumber, - clientName, - treeName, - dateRange, - dateType, - } + return { ticketNumber, clientName, treeName, dateRange, dateType } }) - // Debounce AI search input → aiFilters.q + // ── AI Sessions: debounce search ── useEffect(() => { if (aiSearchTimeout.current) clearTimeout(aiSearchTimeout.current) aiSearchTimeout.current = setTimeout(() => { @@ -77,54 +82,86 @@ export function SessionHistoryPage() { return () => { if (aiSearchTimeout.current) clearTimeout(aiSearchTimeout.current) } }, [aiSearchInput]) - // Load trees for filter dropdown - useEffect(() => { - const loadTrees = async () => { - try { - const treesData = await treesApi.list({}) - setTrees(treesData) - } catch (err) { - console.error('Failed to load trees:', err) - } - } - loadTrees() - }, []) - - // Load sessions when filters change + // ── AI Sessions: fetch ── useEffect(() => { let cancelled = false + const load = async () => { + setAiLoading(true) + try { + const data = await aiSessionsApi.listSessions({ + limit: PAGE_SIZE, + q: aiFilters.q || undefined, + session_type: aiFilters.session_type || undefined, + problem_domain: aiFilters.problem_domain || undefined, + confidence_tier: aiFilters.confidence_tier || undefined, + date_from: aiFilters.date_from || undefined, + date_to: aiFilters.date_to ? `${aiFilters.date_to}T23:59:59.999Z` : undefined, + }) + if (!cancelled) { + setAiSessions(data) + setAiHasMore(data.length >= PAGE_SIZE) + } + } catch { + if (!cancelled) toast.error('Failed to load AI sessions') + } finally { + if (!cancelled) setAiLoading(false) + } + } + load() + return () => { cancelled = true } + }, [aiFilters]) - const loadSessions = async () => { - setIsLoading(true) + const loadMoreAiSessions = async () => { + setAiLoadingMore(true) + try { + const data = await aiSessionsApi.listSessions({ + skip: aiSessions.length, + limit: PAGE_SIZE, + q: aiFilters.q || undefined, + session_type: aiFilters.session_type || undefined, + problem_domain: aiFilters.problem_domain || undefined, + confidence_tier: aiFilters.confidence_tier || undefined, + date_from: aiFilters.date_from || undefined, + date_to: aiFilters.date_to ? `${aiFilters.date_to}T23:59:59.999Z` : undefined, + }) + setAiSessions(prev => [...prev, ...data]) + setAiHasMore(data.length >= PAGE_SIZE) + } catch { + toast.error('Failed to load more sessions') + } finally { + setAiLoadingMore(false) + } + } + + // ── Dynamic problem domains derived from loaded sessions ── + const problemDomains = [...new Set(aiSessions.map(s => s.problem_domain).filter(Boolean))] as string[] + + // ── Flow Sessions: load trees ── + useEffect(() => { + treesApi.list({}).then(setTrees).catch(() => {}) + }, []) + + // ── Flow Sessions: fetch ── + useEffect(() => { + if (activeTab !== 'flows') return + let cancelled = false + const load = async () => { + setFlowLoading(true) try { const params: Record = {} - - // Tab filter (all/active/completed/prepared) - if (filter === 'prepared') { + if (flowTab === 'prepared') { params.status = 'prepared' - } else if (filter !== 'all') { - params.completed = filter === 'completed' + } else if (flowTab !== 'all') { + params.completed = flowTab === 'completed' } - - // Search/filter params - if (filters.ticketNumber) { - params.ticket_number = filters.ticketNumber - } - if (filters.clientName) { - params.client_name = filters.clientName - } - if (filters.treeName) { - params.tree_name = filters.treeName - } - - // Date range params + if (filters.ticketNumber) params.ticket_number = filters.ticketNumber + if (filters.clientName) params.client_name = filters.clientName + if (filters.treeName) params.tree_name = filters.treeName if (filters.dateRange?.from) { const fromDate = filters.dateRange.from const toDate = filters.dateRange.to || filters.dateRange.from - // Set end-of-day on the "to" date so sessions created that day are included const toDateEnd = new Date(toDate) toDateEnd.setHours(23, 59, 59, 999) - if (filters.dateType === 'started') { params.started_after = fromDate.toISOString() params.started_before = toDateEnd.toISOString() @@ -133,29 +170,24 @@ export function SessionHistoryPage() { params.completed_before = toDateEnd.toISOString() } } - - const sessionsData = await sessionsApi.list({ ...params, size: 51 }) + const data = await sessionsApi.list({ ...params, size: PAGE_SIZE + 1 }) if (cancelled) return - const truncated = sessionsData.length > 50 - setHasMore(truncated) - setSessions(truncated ? sessionsData.slice(0, 50) : sessionsData) - } catch (err) { - if (cancelled) return - toast.error('Failed to load sessions') - console.error(err) + const truncated = data.length > PAGE_SIZE + setFlowHasMore(truncated) + setSessions(truncated ? data.slice(0, PAGE_SIZE) : data) + } catch { + if (!cancelled) toast.error('Failed to load sessions') } finally { - if (!cancelled) setIsLoading(false) + if (!cancelled) setFlowLoading(false) } } - - loadSessions() + load() return () => { cancelled = true } - }, [filter, filters]) + }, [activeTab, flowTab, filters]) - // Update URL params when filters change + // ── Flow Sessions: URL param sync ── useEffect(() => { const params = new URLSearchParams() - if (filters.ticketNumber) params.set('ticket', filters.ticketNumber) if (filters.clientName) params.set('client', filters.clientName) if (filters.treeName) params.set('tree', filters.treeName) @@ -164,50 +196,10 @@ export function SessionHistoryPage() { params.set('to', (filters.dateRange.to || filters.dateRange.from).toISOString()) params.set('dateType', filters.dateType) } - setSearchParams(params, { replace: true }) }, [filters, setSearchParams]) - // Load AI sessions always - useEffect(() => { - let cancelled = false - const loadAiSessions = async () => { - setAiLoading(true) - try { - const data = await aiSessionsApi.listSessions({ - limit: 50, - q: aiFilters.q || undefined, - session_type: aiFilters.session_type || undefined, - problem_domain: aiFilters.problem_domain || undefined, - confidence_tier: aiFilters.confidence_tier || undefined, - date_from: aiFilters.date_from || undefined, - date_to: aiFilters.date_to ? `${aiFilters.date_to}T23:59:59.999Z` : undefined, - }) - if (!cancelled) setAiSessions(data) - } catch { - if (!cancelled) toast.error('Failed to load AI sessions') - } finally { - if (!cancelled) setAiLoading(false) - } - } - loadAiSessions() - return () => { cancelled = true } - }, [aiFilters]) - - const handleFilterChange = (newFilters: SessionFilterState) => { - setFilters(newFilters) - } - - const handleClearFilters = () => { - setFilters({ - ticketNumber: '', - clientName: '', - treeName: '', - dateRange: undefined, - dateType: 'started', - }) - } - + // ── Close session handlers ── const handleCloseSession = useCallback(async () => { if (!closingSessionId || !closeOutcome) return setCloseLoading(true) @@ -234,7 +226,6 @@ export function SessionHistoryPage() { } }, [closingSessionId, closeOutcome, closeNotes]) - // Close popover on click outside useEffect(() => { if (!closingSessionId) return const handleClickOutside = (e: MouseEvent) => { @@ -248,473 +239,453 @@ export function SessionHistoryPage() { return () => document.removeEventListener('mousedown', handleClickOutside) }, [closingSessionId]) - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleString() - } - - const getTreeName = (session: Session): string => { - return session.tree_snapshot?.name || 'Unknown Tree' - } + const handleFilterChange = (newFilters: SessionFilterState) => setFilters(newFilters) + const handleClearFilters = () => setFilters({ ticketNumber: '', clientName: '', treeName: '', dateRange: undefined, dateType: 'started' }) + const formatDate = (dateString: string) => new Date(dateString).toLocaleString() + const getTreeName = (session: Session): string => session.tree_snapshot?.name || 'Unknown Tree' const formatOutcomeLabel = (outcome: Session['outcome']): string => { if (!outcome) return 'Not set' - const labels: Record = { - resolved: 'Resolved', - escalated: 'Escalated', - workaround: 'Workaround', - unresolved: 'Unresolved', - cancelled: 'Cancelled', - resolved_externally: 'Resolved Externally', - } + const labels: Record = { resolved: 'Resolved', escalated: 'Escalated', workaround: 'Workaround', unresolved: 'Unresolved', cancelled: 'Cancelled', resolved_externally: 'Resolved Externally' } return labels[outcome] ?? outcome } const hasAiFiltersActive = !!(aiSearchInput || aiFilters.q || aiFilters.session_type || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to) const hasFlowFiltersActive = !!(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) - // Determine section visibility - const showAiSection = aiLoading || aiSessions.length > 0 || hasAiFiltersActive - const showFlowSection = isLoading || sessions.length > 0 || hasFlowFiltersActive - const showCombinedEmpty = !showAiSection && !showFlowSection - return (
- -
-
-

Sessions

-

- View and manage all your sessions -

-
- - {showCombinedEmpty && ( - } - title="No sessions yet" - description="Start a flow or FlowPilot session to begin. All your sessions will appear here." - action={ -
- - Start a Flow - - - Start AI Session - -
- } - learnMoreLink="/guides/sessions" - /> - )} - - {/* FlowPilot Sessions Section */} - {showAiSection && ( - <> -

AI Sessions

- - {/* AI Session Filter Bar */} -
-
- {/* Search input */} -
- - setAiSearchInput(e.target.value)} - placeholder="Search sessions..." - className="w-full rounded-lg border border-border bg-card pl-8 pr-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none" - /> -
- - {/* Session type pills */} -
- {(['', 'guided', 'chat'] as const).map((t) => ( - - ))} -
- - {/* Problem domain dropdown */} - - - {/* Confidence tier pills */} -
- {(['', 'guided', 'exploring', 'discovery'] as const).map((tier) => ( - - ))} -
- - {/* Date range inputs */} -
- setAiFilters((f) => ({ ...f, date_from: e.target.value }))} - title="From date" - className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none [color-scheme:dark]" - /> - to - setAiFilters((f) => ({ ...f, date_to: e.target.value }))} - title="To date" - className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none [color-scheme:dark]" - /> -
- - {/* Clear filters */} - {hasAiFiltersActive && ( - - )} -
-
- - {aiLoading ? ( -
- -
- ) : aiSessions.length === 0 ? ( - { - setAiSearchInput('') - setAiFilters({ q: '', session_type: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' }) - }} - className="text-foreground hover:underline text-sm" - > - Clear all filters - - } - /> - ) : ( -
- {aiSessions.map((s) => ( - - ))} -
- )} - - {/* Divider between sections */} - {showFlowSection && ( -
- )} - - )} - - {/* Flow Sessions Section */} - {showFlowSection && ( - <> -

Flow Sessions

- - {/* Filter Tabs */} -
- {(['active', 'prepared', 'completed', 'all'] as const).map((tab) => ( - - ))} -
- -
- -
- - {/* Loading State */} - {isLoading ? ( -
- + +
+ {/* Page heading */} +
+

Session History

+

View and manage your sessions

- ) : sessions.length === 0 ? ( - (filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) ? ( - - Clear all filters - - } - /> - ) : ( - } - title="Your session history will appear here" - description="Every troubleshooting session is recorded with decisions, timing, and outcomes — ready for export or review." - action={ - - Start a Session - - } - learnMoreLink="/guides/sessions" - /> - ) - ) : ( - <> -
- {sessions.map((session, i) => ( -
+ {TABS.map((tab) => ( + - {!session.completed_at && session.started_at && ( - <> - - - - )} - - {/* Close Session Popover */} - {closingSessionId === session.id && ( -
-

Close Session

- - - - - -