refactor: redesign Session History with tabs + Load More, improve Escalation Queue urgency

Session History:
- Split into AI Sessions / Flow Sessions tabs (AI default)
- Load More pagination (25 per page) instead of 50-item hard cap
- Dynamic problem domain filter from actual session data
- Fix all blue focus rings to ember orange
- Fix badge colors to use design system tokens

Escalation Queue:
- Add wait-time color coding (muted <1h, amber 1-4h, red >4h)
- Sort oldest-first for triage urgency
- Compact right-aligned pickup button
- Widen container, dynamic session count in subtitle
- Fix typos and non-system color tokens

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-30 00:34:38 +00:00
parent 37179096b0
commit 9ce4a8bc8e
3 changed files with 576 additions and 578 deletions

View File

@@ -3,12 +3,21 @@ import { useNavigate } from 'react-router-dom'
import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react' import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react'
import { aiSessionsApi } from '@/api' import { aiSessionsApi } from '@/api'
import type { AISessionSummary } from '@/types/ai-session' import type { AISessionSummary } from '@/types/ai-session'
import { timeAgo } from '@/lib/timeAgo'
interface EscalationQueueProps { interface EscalationQueueProps {
onPickup?: (sessionId: string) => void 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 navigate = useNavigate()
const [sessions, setSessions] = useState<AISessionSummary[]>([]) const [sessions, setSessions] = useState<AISessionSummary[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -19,7 +28,12 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
setError(null) setError(null)
try { try {
const data = await aiSessionsApi.getEscalationQueue() 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 { } catch {
setError('Failed to load escalation queue') setError('Failed to load escalation queue')
} finally { } finally {
@@ -29,6 +43,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
useEffect(() => { useEffect(() => {
loadQueue() loadQueue()
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
}, []) }, [])
const handlePickup = (sessionId: string) => { const handlePickup = (sessionId: string) => {
@@ -50,7 +65,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
if (error) { if (error) {
return ( return (
<div className="py-12 text-center"> <div className="py-12 text-center">
<p className="text-sm text-rose-400">{error}</p> <p className="text-sm text-danger">{error}</p>
<button <button
onClick={loadQueue} onClick={loadQueue}
className="mt-2 text-xs text-muted-foreground hover:text-foreground transition-colors" className="mt-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
@@ -80,7 +95,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between px-1"> <div className="flex items-center justify-between px-1">
<h3 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted"> <h3 className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
Awaiting pickup ({sessions.length}) Awaiting pickup ({sessions.length})
</h3> </h3>
<button <button
@@ -93,13 +108,13 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
</div> </div>
{sessions.map((session) => ( {sessions.map((session) => (
<div key={session.id} className="card-interactive p-3 sm:p-4 space-y-3"> <div key={session.id} className="card-flat p-3 sm:p-4 space-y-3">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{session.problem_summary || 'Untitled session'} {session.problem_summary || 'Untitled session'}
</p> </p>
{session.escalation_reason && ( {session.escalation_reason && (
<p className="mt-1 text-xs text-amber-400 line-clamp-2"> <p className="mt-1 text-xs text-warning line-clamp-2">
Reason: {session.escalation_reason} Reason: {session.escalation_reason}
</p> </p>
)} )}
@@ -107,7 +122,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{session.problem_domain && ( {session.problem_domain && (
<span className="font-sans text-xs rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-primary"> <span className="font-sans rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-accent-text">
{session.problem_domain} {session.problem_domain}
</span> </span>
)} )}
@@ -115,25 +130,30 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
<Hash size={10} /> <Hash size={10} />
{session.step_count} steps {session.step_count} steps
</span> </span>
<span className="flex items-center gap-1"> <span
className="flex items-center gap-1 font-medium"
style={{ color: waitTimeColor(session.created_at) }}
>
<Clock size={10} /> <Clock size={10} />
{new Date(session.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {timeAgo(session.created_at)}
</span> </span>
{session.psa_ticket_id && ( {session.psa_ticket_id && (
<span className="flex items-center gap-1 text-primary"> <span className="flex items-center gap-1 text-accent-text">
<Ticket size={10} /> <Ticket size={10} />
#{session.psa_ticket_id} #{session.psa_ticket_id}
</span> </span>
)} )}
</div> </div>
<div className="flex justify-end">
<button <button
onClick={() => handlePickup(session.id)} onClick={() => handlePickup(session.id)}
className="w-full min-h-[44px] rounded-lg bg-primary text-white px-4 py-2 text-sm font-semibold hover:brightness-110 active:scale-[0.98] transition-all" className="rounded-lg bg-primary text-white px-4 py-2 text-sm font-semibold hover:brightness-110 active:scale-[0.98] transition-all"
> >
Pick Up Session Pick Up
</button> </button>
</div> </div>
</div>
))} ))}
</div> </div>
) )

View File

@@ -1,20 +1,27 @@
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import { EscalationQueue } from '@/components/flowpilot' import { EscalationQueue } from '@/components/flowpilot'
export default function EscalationQueuePage() { export default function EscalationQueuePage() {
const [count, setCount] = useState<number | null>(null)
return ( return (
<div className="mx-auto max-w-3xl p-6"> <div className="mx-auto max-w-4xl p-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10"> <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-warning-dim">
<AlertTriangle size={16} className="text-amber-400" /> <AlertTriangle size={16} className="text-warning" />
</span> </span>
<div> <div>
<h1 className="font-heading text-xl font-bold text-foreground">Escalation Queue</h1> <h1 className="font-heading text-xl font-bold text-foreground">Escalation Queue</h1>
<p className="text-sm text-muted-foreground">Sessions from your team waiting for pickup</p> <p className="text-sm text-muted-foreground">
{count !== null && count > 0
? `${count} session${count !== 1 ? 's' : ''} waiting for pickup`
: 'Sessions from your team waiting for pickup'}
</p>
</div> </div>
</div> </div>
<EscalationQueue /> <EscalationQueue onCountChange={setCount} />
</div> </div>
) )
} }

View File

@@ -17,12 +17,25 @@ import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { getSessionResumePath } from '@/lib/routing' 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() { export function SessionHistoryPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [activeTab, setActiveTab] = useState<TabId>('ai')
// ── AI Session state ──
const [aiSessions, setAiSessions] = useState<AISessionSummary[]>([]) const [aiSessions, setAiSessions] = useState<AISessionSummary[]>([])
const [aiLoading, setAiLoading] = useState(false) const [aiLoading, setAiLoading] = useState(false)
const [aiLoadingMore, setAiLoadingMore] = useState(false)
const [aiHasMore, setAiHasMore] = useState(false)
const [aiSearchInput, setAiSearchInput] = useState('') const [aiSearchInput, setAiSearchInput] = useState('')
const aiSearchTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined) const aiSearchTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const [aiFilters, setAiFilters] = useState({ const [aiFilters, setAiFilters] = useState({
@@ -34,11 +47,12 @@ export function SessionHistoryPage() {
date_to: '', date_to: '',
}) })
// ── Flow Session state ──
const [sessions, setSessions] = useState<Session[]>([]) const [sessions, setSessions] = useState<Session[]>([])
const [hasMore, setHasMore] = useState(false) const [flowLoading, setFlowLoading] = useState(false)
const [flowHasMore, setFlowHasMore] = useState(false)
const [trees, setTrees] = useState<TreeListItem[]>([]) const [trees, setTrees] = useState<TreeListItem[]>([])
const [isLoading, setIsLoading] = useState(true) const [flowTab, setFlowTab] = useState<'all' | 'completed' | 'active' | 'prepared'>('active')
const [filter, setFilter] = useState<'all' | 'completed' | 'active' | 'prepared'>('active')
// Close session popover state // Close session popover state
const [closingSessionId, setClosingSessionId] = useState<string | null>(null) const [closingSessionId, setClosingSessionId] = useState<string | null>(null)
@@ -47,28 +61,19 @@ export function SessionHistoryPage() {
const [closeLoading, setCloseLoading] = useState(false) const [closeLoading, setCloseLoading] = useState(false)
const closePopoverRef = useRef<HTMLDivElement>(null) const closePopoverRef = useRef<HTMLDivElement>(null)
// Initialize filters from URL params
const [filters, setFilters] = useState<SessionFilterState>(() => { const [filters, setFilters] = useState<SessionFilterState>(() => {
const ticketNumber = searchParams.get('ticket') || '' const ticketNumber = searchParams.get('ticket') || ''
const clientName = searchParams.get('client') || '' const clientName = searchParams.get('client') || ''
const treeName = searchParams.get('tree') || '' const treeName = searchParams.get('tree') || ''
const dateType = (searchParams.get('dateType') || 'started') as 'started' | 'completed' const dateType = (searchParams.get('dateType') || 'started') as 'started' | 'completed'
const from = searchParams.get('from') const from = searchParams.get('from')
const to = searchParams.get('to') const to = searchParams.get('to')
const dateRange: DateRange | undefined = const dateRange: DateRange | undefined =
from && to ? { from: new Date(from), to: new Date(to) } : 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(() => { useEffect(() => {
if (aiSearchTimeout.current) clearTimeout(aiSearchTimeout.current) if (aiSearchTimeout.current) clearTimeout(aiSearchTimeout.current)
aiSearchTimeout.current = setTimeout(() => { aiSearchTimeout.current = setTimeout(() => {
@@ -77,54 +82,86 @@ export function SessionHistoryPage() {
return () => { if (aiSearchTimeout.current) clearTimeout(aiSearchTimeout.current) } return () => { if (aiSearchTimeout.current) clearTimeout(aiSearchTimeout.current) }
}, [aiSearchInput]) }, [aiSearchInput])
// Load trees for filter dropdown // ── AI Sessions: fetch ──
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
useEffect(() => { useEffect(() => {
let cancelled = false 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 () => { const loadMoreAiSessions = async () => {
setIsLoading(true) 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 { try {
const params: Record<string, string | boolean> = {} const params: Record<string, string | boolean> = {}
if (flowTab === 'prepared') {
// Tab filter (all/active/completed/prepared)
if (filter === 'prepared') {
params.status = 'prepared' params.status = 'prepared'
} else if (filter !== 'all') { } else if (flowTab !== 'all') {
params.completed = filter === 'completed' params.completed = flowTab === 'completed'
} }
if (filters.ticketNumber) params.ticket_number = filters.ticketNumber
// Search/filter params if (filters.clientName) params.client_name = filters.clientName
if (filters.ticketNumber) { if (filters.treeName) params.tree_name = filters.treeName
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.dateRange?.from) { if (filters.dateRange?.from) {
const fromDate = filters.dateRange.from const fromDate = filters.dateRange.from
const toDate = filters.dateRange.to || 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) const toDateEnd = new Date(toDate)
toDateEnd.setHours(23, 59, 59, 999) toDateEnd.setHours(23, 59, 59, 999)
if (filters.dateType === 'started') { if (filters.dateType === 'started') {
params.started_after = fromDate.toISOString() params.started_after = fromDate.toISOString()
params.started_before = toDateEnd.toISOString() params.started_before = toDateEnd.toISOString()
@@ -133,29 +170,24 @@ export function SessionHistoryPage() {
params.completed_before = toDateEnd.toISOString() params.completed_before = toDateEnd.toISOString()
} }
} }
const data = await sessionsApi.list({ ...params, size: PAGE_SIZE + 1 })
const sessionsData = await sessionsApi.list({ ...params, size: 51 })
if (cancelled) return if (cancelled) return
const truncated = sessionsData.length > 50 const truncated = data.length > PAGE_SIZE
setHasMore(truncated) setFlowHasMore(truncated)
setSessions(truncated ? sessionsData.slice(0, 50) : sessionsData) setSessions(truncated ? data.slice(0, PAGE_SIZE) : data)
} catch (err) { } catch {
if (cancelled) return if (!cancelled) toast.error('Failed to load sessions')
toast.error('Failed to load sessions')
console.error(err)
} finally { } finally {
if (!cancelled) setIsLoading(false) if (!cancelled) setFlowLoading(false)
} }
} }
load()
loadSessions()
return () => { cancelled = true } return () => { cancelled = true }
}, [filter, filters]) }, [activeTab, flowTab, filters])
// Update URL params when filters change // ── Flow Sessions: URL param sync ──
useEffect(() => { useEffect(() => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (filters.ticketNumber) params.set('ticket', filters.ticketNumber) if (filters.ticketNumber) params.set('ticket', filters.ticketNumber)
if (filters.clientName) params.set('client', filters.clientName) if (filters.clientName) params.set('client', filters.clientName)
if (filters.treeName) params.set('tree', filters.treeName) 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('to', (filters.dateRange.to || filters.dateRange.from).toISOString())
params.set('dateType', filters.dateType) params.set('dateType', filters.dateType)
} }
setSearchParams(params, { replace: true }) setSearchParams(params, { replace: true })
}, [filters, setSearchParams]) }, [filters, setSearchParams])
// Load AI sessions always // ── Close session handlers ──
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',
})
}
const handleCloseSession = useCallback(async () => { const handleCloseSession = useCallback(async () => {
if (!closingSessionId || !closeOutcome) return if (!closingSessionId || !closeOutcome) return
setCloseLoading(true) setCloseLoading(true)
@@ -234,7 +226,6 @@ export function SessionHistoryPage() {
} }
}, [closingSessionId, closeOutcome, closeNotes]) }, [closingSessionId, closeOutcome, closeNotes])
// Close popover on click outside
useEffect(() => { useEffect(() => {
if (!closingSessionId) return if (!closingSessionId) return
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
@@ -248,76 +239,51 @@ export function SessionHistoryPage() {
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, [closingSessionId]) }, [closingSessionId])
const formatDate = (dateString: string) => { const handleFilterChange = (newFilters: SessionFilterState) => setFilters(newFilters)
return new Date(dateString).toLocaleString() const handleClearFilters = () => setFilters({ ticketNumber: '', clientName: '', treeName: '', dateRange: undefined, dateType: 'started' })
}
const getTreeName = (session: Session): string => {
return session.tree_snapshot?.name || 'Unknown Tree'
}
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 => { const formatOutcomeLabel = (outcome: Session['outcome']): string => {
if (!outcome) return 'Not set' if (!outcome) return 'Not set'
const labels: Record<string, string> = { const labels: Record<string, string> = { resolved: 'Resolved', escalated: 'Escalated', workaround: 'Workaround', unresolved: 'Unresolved', cancelled: 'Cancelled', resolved_externally: 'Resolved Externally' }
resolved: 'Resolved',
escalated: 'Escalated',
workaround: 'Workaround',
unresolved: 'Unresolved',
cancelled: 'Cancelled',
resolved_externally: 'Resolved Externally',
}
return labels[outcome] ?? outcome 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 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) 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 ( return (
<div className="overflow-y-auto h-full"> <div className="overflow-y-auto h-full">
<PageMeta title="Sessions" /> <PageMeta title="Sessions" />
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8"> <div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-8"> {/* Page heading */}
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Sessions</h1> <div className="mb-6">
<p className="mt-2 text-muted-foreground"> <h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Session History</h1>
View and manage all your sessions <p className="mt-1 text-sm text-muted-foreground">View and manage your sessions</p>
</p>
</div> </div>
{showCombinedEmpty && ( {/* Tab bar */}
<EmptyState <div className="flex gap-1 border-b border-border mb-6 overflow-x-auto">
illustration={<SessionIllustration />} {TABS.map((tab) => (
title="No sessions yet" <button
description="Start a flow or FlowPilot session to begin. All your sessions will appear here." key={tab.id}
action={ onClick={() => setActiveTab(tab.id)}
<div className="flex gap-3"> className={cn(
<Link 'px-4 py-2 text-sm transition-colors whitespace-nowrap',
to="/trees" activeTab === tab.id
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] transition-all" ? 'border-b-2 border-primary text-foreground font-medium'
> : 'text-muted-foreground hover:text-foreground'
Start a Flow
</Link>
<Link
to="/pilot"
className="inline-flex items-center gap-2 rounded-lg border border-border bg-[rgba(255,255,255,0.04)] px-5 py-2.5 text-sm font-semibold text-foreground hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Start AI Session
</Link>
</div>
}
learnMoreLink="/guides/sessions"
/>
)} )}
>
{tab.label}
</button>
))}
</div>
{/* FlowPilot Sessions Section */} {/* ════════ AI Sessions Tab ════════ */}
{showAiSection && ( {activeTab === 'ai' && (
<> <>
<h2 className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">AI Sessions</h2>
{/* AI Session Filter Bar */} {/* AI Session Filter Bar */}
<div className="card-flat p-3 mb-4"> <div className="card-flat p-3 mb-4">
<div className="flex flex-wrap gap-3 items-center"> <div className="flex flex-wrap gap-3 items-center">
@@ -329,7 +295,7 @@ export function SessionHistoryPage() {
value={aiSearchInput} value={aiSearchInput}
onChange={(e) => setAiSearchInput(e.target.value)} onChange={(e) => setAiSearchInput(e.target.value)}
placeholder="Search sessions..." 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" 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(249,115,22,0.25)] focus:ring-1 focus:ring-[rgba(249,115,22,0.1)] focus:outline-none"
/> />
</div> </div>
@@ -351,24 +317,17 @@ export function SessionHistoryPage() {
))} ))}
</div> </div>
{/* Problem domain dropdown */} {/* Problem domain dropdown — dynamic */}
<select <select
value={aiFilters.problem_domain} value={aiFilters.problem_domain}
onChange={(e) => setAiFilters((f) => ({ ...f, problem_domain: e.target.value }))} onChange={(e) => setAiFilters((f) => ({ ...f, problem_domain: e.target.value }))}
title="Filter by problem domain" title="Filter by problem domain"
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 [&>option]:bg-card [&>option]:text-foreground" className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(249,115,22,0.25)] focus:ring-1 focus:ring-[rgba(249,115,22,0.1)] focus:outline-none [&>option]:bg-card [&>option]:text-foreground"
> >
<option value="">All domains</option> <option value="">All domains</option>
<option value="Active Directory">Active Directory</option> {problemDomains.map((d) => (
<option value="Networking">Networking</option> <option key={d} value={d}>{d}</option>
<option value="Microsoft 365">Microsoft 365</option> ))}
<option value="Hardware">Hardware</option>
<option value="Security">Security</option>
<option value="Email">Email</option>
<option value="Printing">Printing</option>
<option value="VPN / Remote Access">VPN / Remote Access</option>
<option value="Cloud Services">Cloud Services</option>
<option value="Other">Other</option>
</select> </select>
{/* Confidence tier pills */} {/* Confidence tier pills */}
@@ -378,7 +337,7 @@ export function SessionHistoryPage() {
key={tier} key={tier}
onClick={() => setAiFilters((f) => ({ ...f, confidence_tier: tier }))} onClick={() => setAiFilters((f) => ({ ...f, confidence_tier: tier }))}
className={cn( className={cn(
'rounded-full border px-3 py-1 text-xs font-sans text-xs transition-colors', 'rounded-full border px-3 py-1 text-xs font-sans transition-colors',
aiFilters.confidence_tier === tier aiFilters.confidence_tier === tier
? 'bg-accent-dim text-foreground border-primary/30' ? 'bg-accent-dim text-foreground border-primary/30'
: 'bg-card text-muted-foreground border-border hover:text-foreground hover:border-[rgba(255,255,255,0.12)]' : 'bg-card text-muted-foreground border-border hover:text-foreground hover:border-[rgba(255,255,255,0.12)]'
@@ -396,7 +355,7 @@ export function SessionHistoryPage() {
value={aiFilters.date_from} value={aiFilters.date_from}
onChange={(e) => setAiFilters((f) => ({ ...f, date_from: e.target.value }))} onChange={(e) => setAiFilters((f) => ({ ...f, date_from: e.target.value }))}
title="From date" 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]" className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(249,115,22,0.25)] focus:ring-1 focus:ring-[rgba(249,115,22,0.1)] focus:outline-none [color-scheme:dark]"
/> />
<span className="text-xs text-muted-foreground">to</span> <span className="text-xs text-muted-foreground">to</span>
<input <input
@@ -404,7 +363,7 @@ export function SessionHistoryPage() {
value={aiFilters.date_to} value={aiFilters.date_to}
onChange={(e) => setAiFilters((f) => ({ ...f, date_to: e.target.value }))} onChange={(e) => setAiFilters((f) => ({ ...f, date_to: e.target.value }))}
title="To date" 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]" className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(249,115,22,0.25)] focus:ring-1 focus:ring-[rgba(249,115,22,0.1)] focus:outline-none [color-scheme:dark]"
/> />
</div> </div>
@@ -423,11 +382,13 @@ export function SessionHistoryPage() {
</div> </div>
</div> </div>
{/* AI Session list */}
{aiLoading ? ( {aiLoading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<Spinner /> <Spinner />
</div> </div>
) : aiSessions.length === 0 ? ( ) : aiSessions.length === 0 ? (
hasAiFiltersActive ? (
<EmptyState <EmptyState
title="No sessions match your filters" title="No sessions match your filters"
description="Try adjusting your search or filters." description="Try adjusting your search or filters."
@@ -444,34 +405,62 @@ export function SessionHistoryPage() {
} }
/> />
) : ( ) : (
<EmptyState
illustration={<SessionIllustration />}
title="No AI sessions yet"
description="Start a FlowPilot or chat session to begin. All your sessions will appear here."
action={
<Link
to="/"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] transition-all"
>
Start a Session
</Link>
}
/>
)
) : (
<>
<div className="space-y-2"> <div className="space-y-2">
{aiSessions.map((s) => ( {aiSessions.map((s) => (
<AISessionListItem key={s.id} session={s} /> <AISessionListItem key={s.id} session={s} />
))} ))}
</div> </div>
)}
{/* Divider between sections */} {/* Load more / count */}
{showFlowSection && ( <div className="text-center py-4">
<div className="my-8 border-t border-border" /> {aiHasMore ? (
<button
onClick={loadMoreAiSessions}
disabled={aiLoadingMore}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:border-[var(--color-border-hover)] transition-colors disabled:opacity-50"
>
{aiLoadingMore ? <Spinner className="h-3.5 w-3.5" /> : null}
{aiLoadingMore ? 'Loading...' : 'Load more sessions'}
</button>
) : (
<p className="text-sm text-muted-foreground">
Showing all {aiSessions.length} session{aiSessions.length !== 1 ? 's' : ''}
</p>
)}
</div>
</>
)} )}
</> </>
)} )}
{/* Flow Sessions Section */} {/* ════════ Flow Sessions Tab ════════ */}
{showFlowSection && ( {activeTab === 'flows' && (
<> <>
<h2 className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">Flow Sessions</h2> {/* Flow tab sub-filters */}
{/* Filter Tabs */}
<div className="mb-6 flex gap-2 border-b border-border"> <div className="mb-6 flex gap-2 border-b border-border">
{(['active', 'prepared', 'completed', 'all'] as const).map((tab) => ( {(['active', 'prepared', 'completed', 'all'] as const).map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => setFilter(tab)} onClick={() => setFlowTab(tab)}
className={cn( className={cn(
'px-4 py-2 text-sm font-medium transition-colors', 'px-4 py-2 text-sm font-medium transition-colors',
filter === tab flowTab === tab
? 'border-b-2 border-primary text-foreground' ? 'border-b-2 border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
)} )}
@@ -490,13 +479,13 @@ export function SessionHistoryPage() {
/> />
</div> </div>
{/* Loading State */} {/* Flow session list */}
{isLoading ? ( {flowLoading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<Spinner /> <Spinner />
</div> </div>
) : sessions.length === 0 ? ( ) : sessions.length === 0 ? (
(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) ? ( hasFlowFiltersActive ? (
<EmptyState <EmptyState
title="No sessions match your filters" title="No sessions match your filters"
description="Try adjusting your search or filters." description="Try adjusting your search or filters."
@@ -509,17 +498,16 @@ export function SessionHistoryPage() {
) : ( ) : (
<EmptyState <EmptyState
illustration={<SessionIllustration />} illustration={<SessionIllustration />}
title="Your session history will appear here" title="Your flow sessions will appear here"
description="Every troubleshooting session is recorded with decisions, timing, and outcomes — ready for export or review." description="Every troubleshooting session is recorded with decisions, timing, and outcomes."
action={ action={
<Link <Link
to="/trees" to="/trees"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] transition-all" className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] transition-all"
> >
Start a Session Start a Flow
</Link> </Link>
} }
learnMoreLink="/guides/sessions"
/> />
) )
) : ( ) : (
@@ -534,24 +522,21 @@ export function SessionHistoryPage() {
)} )}
style={{ '--stagger-index': i } as React.CSSProperties} style={{ '--stagger-index': i } as React.CSSProperties}
> >
<div <div className="bg-card border border-border rounded-xl p-4 transition-all hover:border-[var(--color-border-hover)]">
className="bg-card border border-border rounded-xl p-4 transition-all hover:bg-accent/50"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1"> <div className="flex-1">
{/* Status and Ticket/Client */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span <span
className={cn( className={cn(
'inline-block h-2.5 w-2.5 rounded-full', 'inline-block h-2.5 w-2.5 rounded-full',
session.completed_at ? 'bg-green-500' : 'bg-yellow-500' session.completed_at ? 'bg-success' : 'bg-warning'
)} )}
/> />
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{session.ticket_number || 'No ticket'} {session.ticket_number || 'No ticket'}
</span> </span>
{session.client_name && ( {session.client_name && (
<span className="rounded-full bg-accent px-2.5 py-0.5 text-xs font-medium text-foreground"> <span className="rounded-full bg-accent-dim px-2.5 py-0.5 text-xs font-medium text-accent-text">
{session.client_name} {session.client_name}
</span> </span>
)} )}
@@ -559,13 +544,13 @@ export function SessionHistoryPage() {
<span <span
className={cn( className={cn(
'rounded-full px-2.5 py-0.5 text-xs font-medium', 'rounded-full px-2.5 py-0.5 text-xs font-medium',
session.outcome === 'resolved' && 'bg-emerald-500/20 text-emerald-300', session.outcome === 'resolved' && 'bg-success-dim text-success',
session.outcome === 'workaround' && 'bg-amber-500/20 text-amber-300', session.outcome === 'workaround' && 'bg-warning-dim text-warning',
session.outcome === 'escalated' && 'bg-blue-500/20 text-blue-300', session.outcome === 'escalated' && 'bg-warning-dim text-warning',
session.outcome === 'unresolved' && 'bg-rose-500/20 text-rose-300', session.outcome === 'unresolved' && 'bg-danger-dim text-danger',
session.outcome === 'cancelled' && 'bg-zinc-500/20 text-zinc-300', session.outcome === 'cancelled' && 'bg-[rgba(255,255,255,0.06)] text-muted-foreground',
session.outcome === 'resolved_externally' && 'bg-blue-500/20 text-blue-300', session.outcome === 'resolved_externally' && 'bg-success-dim text-success',
!session.outcome && 'bg-accent text-muted-foreground' !session.outcome && 'bg-[rgba(255,255,255,0.06)] text-muted-foreground'
)} )}
> >
{formatOutcomeLabel(session.outcome)} {formatOutcomeLabel(session.outcome)}
@@ -573,20 +558,15 @@ export function SessionHistoryPage() {
)} )}
</div> </div>
{/* Tree Name */}
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
<span className="font-medium">Tree:</span> {getTreeName(session)} <span className="font-medium">Tree:</span> {getTreeName(session)}
</p> </p>
{/* Timestamps */}
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Started: {session.started_at ? formatDate(session.started_at) : 'Not started'} Started: {session.started_at ? formatDate(session.started_at) : 'Not started'}
{session.completed_at && ( {session.completed_at && (
<> · Completed: {formatDate(session.completed_at)}</> <> · Completed: {formatDate(session.completed_at)}</>
)} )}
</p> </p>
{/* Stats */}
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{session.decisions.length} decision{session.decisions.length !== 1 ? 's' : ''} recorded {session.decisions.length} decision{session.decisions.length !== 1 ? 's' : ''} recorded
{session.scratchpad && session.scratchpad.trim() && ( {session.scratchpad && session.scratchpad.trim() && (
@@ -599,10 +579,7 @@ export function SessionHistoryPage() {
<div className="relative flex gap-2"> <div className="relative flex gap-2">
<button <button
onClick={() => navigate(`/sessions/${session.id}`)} onClick={() => navigate(`/sessions/${session.id}`)}
className={cn( className="rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-[var(--color-bg-elevated)] hover:text-foreground transition-colors"
'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
'hover:bg-accent hover:text-foreground'
)}
> >
View Details View Details
</button> </button>
@@ -615,19 +592,15 @@ export function SessionHistoryPage() {
setCloseNotes('') setCloseNotes('')
}} }}
className={cn( className={cn(
'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground', 'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-[var(--color-bg-elevated)] hover:text-foreground transition-colors',
'hover:bg-accent hover:text-foreground', closingSessionId === session.id && 'bg-[var(--color-bg-elevated)] text-foreground'
closingSessionId === session.id && 'bg-accent text-foreground'
)} )}
> >
Close Close
</button> </button>
<button <button
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })} onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
className={cn( className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-white hover:brightness-110 transition-all"
'rounded-md bg-primary px-3 py-2 text-sm font-medium text-white',
'hover:brightness-110'
)}
> >
Resume Resume
</button> </button>
@@ -642,12 +615,12 @@ export function SessionHistoryPage() {
> >
<p className="text-sm font-heading font-medium text-foreground mb-3">Close Session</p> <p className="text-sm font-heading font-medium text-foreground mb-3">Close Session</p>
<label className="block text-xs font-sans text-xs text-muted-foreground mb-1">Outcome</label> <label className="block text-[0.625rem] font-sans uppercase tracking-[0.1em] text-muted-foreground mb-1">Outcome</label>
<select <select
value={closeOutcome} value={closeOutcome}
onChange={(e) => setCloseOutcome(e.target.value as SessionOutcome)} onChange={(e) => setCloseOutcome(e.target.value as SessionOutcome)}
title="Session outcome" title="Session outcome"
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none mb-3" className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(249,115,22,0.25)] focus:ring-1 focus:ring-[rgba(249,115,22,0.1)] focus:outline-none mb-3"
> >
<option value="">Select outcome...</option> <option value="">Select outcome...</option>
<option value="resolved">Resolved</option> <option value="resolved">Resolved</option>
@@ -658,23 +631,19 @@ export function SessionHistoryPage() {
<option value="resolved_externally">Resolved Externally</option> <option value="resolved_externally">Resolved Externally</option>
</select> </select>
<label className="block text-xs font-sans text-xs text-muted-foreground mb-1">Notes (optional)</label> <label className="block text-[0.625rem] font-sans uppercase tracking-[0.1em] text-muted-foreground mb-1">Notes (optional)</label>
<textarea <textarea
value={closeNotes} value={closeNotes}
onChange={(e) => setCloseNotes(e.target.value)} onChange={(e) => setCloseNotes(e.target.value)}
rows={2} rows={2}
placeholder="Add closure notes..." placeholder="Add closure notes..."
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none resize-none mb-3" className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.25)] focus:ring-1 focus:ring-[rgba(249,115,22,0.1)] focus:outline-none resize-none mb-3"
/> />
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button <button
onClick={() => { onClick={() => { setClosingSessionId(null); setCloseOutcome(''); setCloseNotes('') }}
setClosingSessionId(null) className="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-[var(--color-bg-elevated)] hover:text-foreground transition-colors"
setCloseOutcome('')
setCloseNotes('')
}}
className="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
> >
Cancel Cancel
</button> </button>
@@ -699,15 +668,19 @@ export function SessionHistoryPage() {
</div> </div>
))} ))}
</div> </div>
{hasMore ? (
<p className="text-center text-sm text-muted-foreground py-4"> {/* Load more / count */}
Showing the 50 most recent sessions <div className="text-center py-4">
{flowHasMore ? (
<p className="text-sm text-muted-foreground">
Showing the {sessions.length} most recent sessions
</p> </p>
) : sessions.length > 0 ? ( ) : sessions.length > 0 ? (
<p className="text-center text-sm text-muted-foreground py-4"> <p className="text-sm text-muted-foreground">
Showing all {sessions.length} sessions Showing all {sessions.length} session{sessions.length !== 1 ? 's' : ''}
</p> </p>
) : null} ) : null}
</div>
</> </>
)} )}
</> </>
@@ -716,5 +689,3 @@ export function SessionHistoryPage() {
</div> </div>
) )
} }
export default SessionHistoryPage