Files
resolutionflow/frontend/src/pages/SessionHistoryPage.tsx

709 lines
33 KiB
TypeScript

import { useEffect, useState, useRef, useCallback } from 'react'
import { Search } from 'lucide-react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { sessionsApi } from '@/api/sessions'
import { aiSessionsApi } from '@/api/aiSessions'
import { treesApi } from '@/api/trees'
import type { Session, TreeListItem, SessionOutcome, AISessionSummary } from '@/types'
import type { DateRange } from 'react-day-picker'
import { SessionFilters } from '@/components/session/SessionFilters'
import type { SessionFilterState } from '@/components/session/SessionFilters'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
import { SessionIllustration } from '@/components/common/EmptyStateIllustrations'
import { AISessionListItem } from '@/components/flowpilot/AISessionListItem'
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 default function SessionHistoryPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [activeTab, setActiveTab] = useState<TabId>(() => {
// If URL params target flow session filters, start on flows tab
const hasFlowParams = searchParams.get('ticket') || searchParams.get('client') || searchParams.get('tree')
return hasFlowParams ? 'flows' : 'ai'
})
// ── AI Session state ──
const [aiSessions, setAiSessions] = useState<AISessionSummary[]>([])
const [aiLoading, setAiLoading] = useState(false)
const [aiLoadingMore, setAiLoadingMore] = useState(false)
const [aiHasMore, setAiHasMore] = useState(false)
const [aiSearchInput, setAiSearchInput] = useState('')
const aiSearchTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const aiFilterGenRef = useRef(0)
const [aiFilters, setAiFilters] = useState({
q: '',
session_type: '',
problem_domain: '',
confidence_tier: '',
date_from: '',
date_to: '',
})
// ── Flow Session state ──
const [sessions, setSessions] = useState<Session[]>([])
const [flowLoading, setFlowLoading] = useState(false)
const [flowHasMore, setFlowHasMore] = useState(false)
const [trees, setTrees] = useState<TreeListItem[]>([])
const [flowTab, setFlowTab] = useState<'all' | 'completed' | 'active' | 'prepared'>('active')
// Close session popover state
const [closingSessionId, setClosingSessionId] = useState<string | null>(null)
const [closeOutcome, setCloseOutcome] = useState<SessionOutcome | ''>('')
const [closeNotes, setCloseNotes] = useState('')
const [closeLoading, setCloseLoading] = useState(false)
const closePopoverRef = useRef<HTMLDivElement>(null)
const [filters, setFilters] = useState<SessionFilterState>(() => {
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 }
})
// ── AI Sessions: debounce search ──
useEffect(() => {
if (aiSearchTimeout.current) clearTimeout(aiSearchTimeout.current)
aiSearchTimeout.current = setTimeout(() => {
setAiFilters(prev => ({ ...prev, q: aiSearchInput }))
}, 400)
return () => { if (aiSearchTimeout.current) clearTimeout(aiSearchTimeout.current) }
}, [aiSearchInput])
// ── AI Sessions: fetch ──
useEffect(() => {
let cancelled = false
const gen = ++aiFilterGenRef.current
setAiSessions([])
setAiHasMore(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 && gen === aiFilterGenRef.current) {
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 loadMoreAiSessions = async () => {
const gen = aiFilterGenRef.current
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,
})
if (gen === aiFilterGenRef.current) {
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<string, string | boolean> = {}
if (flowTab === 'prepared') {
params.status = 'prepared'
} else if (flowTab !== 'all') {
params.completed = flowTab === 'completed'
}
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
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()
} else {
params.completed_after = fromDate.toISOString()
params.completed_before = toDateEnd.toISOString()
}
}
const data = await sessionsApi.list({ ...params, size: PAGE_SIZE + 1 })
if (cancelled) return
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) setFlowLoading(false)
}
}
load()
return () => { cancelled = true }
}, [activeTab, flowTab, filters])
// ── 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)
if (filters.dateRange?.from) {
params.set('from', filters.dateRange.from.toISOString())
params.set('to', (filters.dateRange.to || filters.dateRange.from).toISOString())
params.set('dateType', filters.dateType)
}
setSearchParams(params, { replace: true })
}, [filters, setSearchParams])
// ── Close session handlers ──
const handleCloseSession = useCallback(async () => {
if (!closingSessionId || !closeOutcome) return
setCloseLoading(true)
try {
await sessionsApi.complete(closingSessionId, {
outcome: closeOutcome,
outcome_notes: closeNotes || undefined,
})
setSessions(prev =>
prev.map(s =>
s.id === closingSessionId
? { ...s, completed_at: new Date().toISOString(), outcome: closeOutcome, outcome_notes: closeNotes || null }
: s
)
)
toast.success('Session closed')
setClosingSessionId(null)
setCloseOutcome('')
setCloseNotes('')
} catch {
toast.error('Failed to close session')
} finally {
setCloseLoading(false)
}
}, [closingSessionId, closeOutcome, closeNotes])
useEffect(() => {
if (!closingSessionId) return
const handleClickOutside = (e: MouseEvent) => {
if (closePopoverRef.current && !closePopoverRef.current.contains(e.target as Node)) {
setClosingSessionId(null)
setCloseOutcome('')
setCloseNotes('')
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [closingSessionId])
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<string, string> = { 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)
return (
<div className="overflow-y-auto h-full">
<PageMeta title="Sessions" />
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Page heading */}
<div className="mb-6" data-testid="session-history-heading">
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Session History</h1>
<p className="mt-1 text-sm text-muted-foreground">View and manage your sessions</p>
</div>
{/* Tab bar */}
<div className="flex gap-1 border-b border-border mb-6 overflow-x-auto">
{TABS.map((tab) => (
<button
key={tab.id}
data-testid={`session-history-tab-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={cn(
'px-4 py-2 text-sm transition-colors whitespace-nowrap',
activeTab === tab.id
? 'border-b-2 border-primary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
>
{tab.label}
</button>
))}
</div>
{/* ════════ AI Sessions Tab ════════ */}
{activeTab === 'ai' && (
<>
{/* AI Session Filter Bar */}
<div className="card-flat p-3 mb-4">
<div className="flex flex-wrap gap-3 items-center">
{/* Search input */}
<div className="relative flex-1 min-w-[180px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<input
type="text"
value={aiSearchInput}
onChange={(e) => 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(249,115,22,0.25)] focus:ring-1 focus:ring-[rgba(249,115,22,0.1)] focus:outline-none"
/>
</div>
{/* Session type pills */}
<div className="flex gap-1">
{(['', 'guided', 'chat'] as const).map((t) => (
<button
key={t}
onClick={() => setAiFilters((f) => ({ ...f, session_type: t }))}
className={cn(
'rounded-full border px-3 py-1 text-xs font-sans transition-colors',
aiFilters.session_type === t
? '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)]'
)}
>
{t === '' ? 'All' : t === 'guided' ? 'Guided' : 'Chat'}
</button>
))}
</div>
{/* Problem domain dropdown — dynamic */}
<select
value={aiFilters.problem_domain}
onChange={(e) => setAiFilters((f) => ({ ...f, problem_domain: e.target.value }))}
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(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>
{problemDomains.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
{/* Confidence tier pills */}
<div className="flex gap-1">
{(['', 'guided', 'exploring', 'discovery'] as const).map((tier) => (
<button
key={tier}
onClick={() => setAiFilters((f) => ({ ...f, confidence_tier: tier }))}
className={cn(
'rounded-full border px-3 py-1 text-xs font-sans transition-colors',
aiFilters.confidence_tier === tier
? '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)]'
)}
>
{tier === '' ? 'All' : tier.charAt(0).toUpperCase() + tier.slice(1)}
</button>
))}
</div>
{/* Date range inputs */}
<div className="flex items-center gap-2">
<input
type="date"
value={aiFilters.date_from}
onChange={(e) => 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(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>
<input
type="date"
value={aiFilters.date_to}
onChange={(e) => 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(249,115,22,0.25)] focus:ring-1 focus:ring-[rgba(249,115,22,0.1)] focus:outline-none [color-scheme:dark]"
/>
</div>
{/* Clear filters */}
{hasAiFiltersActive && (
<button
onClick={() => {
setAiSearchInput('')
setAiFilters({ q: '', session_type: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
)}
</div>
</div>
{/* AI Session list */}
{aiLoading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
) : aiSessions.length === 0 ? (
hasAiFiltersActive ? (
<EmptyState
title="No sessions match your filters"
description="Try adjusting your search or filters."
action={
<button
onClick={() => {
setAiSearchInput('')
setAiFilters({ q: '', session_type: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
}}
className="text-foreground hover:underline text-sm"
>
Clear all filters
</button>
}
/>
) : (
<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">
{aiSessions.map((s) => (
<AISessionListItem key={s.id} session={s} />
))}
</div>
{/* Load more / count */}
<div className="text-center py-4">
{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 Tab ════════ */}
{activeTab === 'flows' && (
<>
{/* Flow tab sub-filters */}
<div className="mb-6 flex gap-2 border-b border-border">
{(['active', 'prepared', 'completed', 'all'] as const).map((tab) => (
<button
key={tab}
onClick={() => setFlowTab(tab)}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors',
flowTab === tab
? 'border-b-2 border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
<div className="mb-6">
<SessionFilters
filters={filters}
onChange={handleFilterChange}
onClear={handleClearFilters}
trees={trees}
/>
</div>
{/* Flow session list */}
{flowLoading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
) : sessions.length === 0 ? (
hasFlowFiltersActive ? (
<EmptyState
title="No sessions match your filters"
description="Try adjusting your search or filters."
action={
<button onClick={handleClearFilters} className="text-foreground hover:underline text-sm">
Clear all filters
</button>
}
/>
) : (
<EmptyState
illustration={<SessionIllustration />}
title="Your flow sessions will appear here"
description="Every troubleshooting session is recorded with decisions, timing, and outcomes."
action={
<Link
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"
>
Start a Flow
</Link>
}
/>
)
) : (
<>
<div className="space-y-4">
{sessions.map((session, i) => (
<div
key={session.id}
className={cn(
'stagger-item',
closingSessionId === session.id && 'relative z-50'
)}
style={{ '--stagger-index': i } as React.CSSProperties}
>
<div
data-testid="flow-session-card"
data-session-id={session.id}
className="bg-card border border-border rounded-xl p-4 transition-all hover:border-[var(--color-border-hover)]"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1">
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
'inline-block h-2.5 w-2.5 rounded-full',
session.completed_at ? 'bg-success' : 'bg-warning'
)}
/>
<span className="font-medium text-foreground">
{session.ticket_number || 'No ticket'}
</span>
{session.client_name && (
<span className="rounded-full bg-accent-dim px-2.5 py-0.5 text-xs font-medium text-accent-text">
{session.client_name}
</span>
)}
{session.completed_at && (
<span
className={cn(
'rounded-full px-2.5 py-0.5 text-xs font-medium',
session.outcome === 'resolved' && 'bg-success-dim text-success',
session.outcome === 'workaround' && 'bg-warning-dim text-warning',
session.outcome === 'escalated' && 'bg-warning-dim text-warning',
session.outcome === 'unresolved' && 'bg-danger-dim text-danger',
session.outcome === 'cancelled' && 'bg-[rgba(255,255,255,0.06)] text-muted-foreground',
session.outcome === 'resolved_externally' && 'bg-success-dim text-success',
!session.outcome && 'bg-[rgba(255,255,255,0.06)] text-muted-foreground'
)}
>
{formatOutcomeLabel(session.outcome)}
</span>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
<span className="font-medium">Tree:</span> {getTreeName(session)}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Started: {session.started_at ? formatDate(session.started_at) : 'Not started'}
{session.completed_at && (
<> · Completed: {formatDate(session.completed_at)}</>
)}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{session.decisions.length} decision{session.decisions.length !== 1 ? 's' : ''} recorded
{session.scratchpad && session.scratchpad.trim() && (
<span> · Has notes</span>
)}
</p>
</div>
{/* Actions */}
<div className="relative flex gap-2">
<button
onClick={() => navigate(`/sessions/${session.id}`)}
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"
>
View Details
</button>
{!session.completed_at && session.started_at && (
<>
<button
onClick={() => {
setClosingSessionId(closingSessionId === session.id ? null : session.id)
setCloseOutcome('')
setCloseNotes('')
}}
className={cn(
'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',
closingSessionId === session.id && 'bg-[var(--color-bg-elevated)] text-foreground'
)}
>
Close
</button>
<button
data-testid="flow-session-resume"
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-white hover:brightness-110 transition-all"
>
Resume
</button>
</>
)}
{/* Close Session Popover */}
{closingSessionId === session.id && (
<div
ref={closePopoverRef}
className="absolute right-0 top-full z-20 mt-2 w-72 rounded-xl border border-border bg-card p-4 shadow-xl"
>
<p className="text-sm font-heading font-medium text-foreground mb-3">Close Session</p>
<label className="block text-[0.625rem] font-sans uppercase tracking-[0.1em] text-muted-foreground mb-1">Outcome</label>
<select
value={closeOutcome}
onChange={(e) => setCloseOutcome(e.target.value as SessionOutcome)}
title="Session outcome"
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="resolved">Resolved</option>
<option value="escalated">Escalated</option>
<option value="workaround">Workaround</option>
<option value="unresolved">Unresolved</option>
<option value="cancelled">Cancelled</option>
<option value="resolved_externally">Resolved Externally</option>
</select>
<label className="block text-[0.625rem] font-sans uppercase tracking-[0.1em] text-muted-foreground mb-1">Notes (optional)</label>
<textarea
value={closeNotes}
onChange={(e) => setCloseNotes(e.target.value)}
rows={2}
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(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">
<button
onClick={() => { setClosingSessionId(null); setCloseOutcome(''); setCloseNotes('') }}
className="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-[var(--color-bg-elevated)] hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={handleCloseSession}
disabled={!closeOutcome || closeLoading}
className={cn(
'rounded-lg px-4 py-1.5 text-sm font-medium transition-opacity',
closeOutcome
? 'bg-primary text-white hover:brightness-110'
: 'bg-primary text-white opacity-50 cursor-not-allowed'
)}
>
{closeLoading ? 'Closing...' : 'Confirm'}
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
))}
</div>
{/* Load more / count */}
<div className="text-center py-4">
{flowHasMore ? (
<p className="text-sm text-muted-foreground">
Showing the {sessions.length} most recent sessions
</p>
) : sessions.length > 0 ? (
<p className="text-sm text-muted-foreground">
Showing all {sessions.length} session{sessions.length !== 1 ? 's' : ''}
</p>
) : null}
</div>
</>
)}
</>
)}
</div>
</div>
)
}