Files
resolutionflow/frontend/src/pages/SessionHistoryPage.tsx
chihlasm 5494816b06 feat(ai-session): add FlowPilot AI-powered troubleshooting sessions
Implements Phase 1 of the FlowPilot-First pivot — the core AI session
experience where engineers describe a problem and FlowPilot guides them
through structured diagnosis with selectable options, free-text escape
hatches, and auto-generated documentation on resolution.

Backend: AISession + AISessionStep models, FlowPilot Engine (LLM
orchestration with structured JSON output), Flow Matching Engine v1
(semantic + keyword + recency scoring), 8 API endpoints with auth,
rate limiting, and AI quota enforcement.

Frontend: Intake screen, conversational session view with sidebar,
step cards with options/actions/resolution suggestions, resolve/escalate
modals, documentation view with rating, session history integration,
and /pilot route with sidebar navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:27:36 +00:00

569 lines
22 KiB
TypeScript

import { useEffect, useState, useRef, useCallback } from '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'
export function SessionHistoryPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
// Top-level tab: flow sessions vs AI sessions
const [sessionType, setSessionType] = useState<'flow' | 'ai'>('flow')
const [aiSessions, setAiSessions] = useState<AISessionSummary[]>([])
const [aiLoading, setAiLoading] = useState(false)
const [sessions, setSessions] = useState<Session[]>([])
const [hasMore, setHasMore] = useState(false)
const [trees, setTrees] = useState<TreeListItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [filter, setFilter] = 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)
// Initialize filters from URL params
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,
}
})
// 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
useEffect(() => {
let cancelled = false
const loadSessions = async () => {
setIsLoading(true)
try {
const params: Record<string, string | boolean> = {}
// Tab filter (all/active/completed/prepared)
if (filter === 'prepared') {
params.status = 'prepared'
} else if (filter !== 'all') {
params.completed = filter === '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.dateRange?.from) {
const fromDate = filters.dateRange.from
const toDate = filters.dateRange.to || filters.dateRange.from
if (filters.dateType === 'started') {
params.started_after = fromDate.toISOString()
params.started_before = toDate.toISOString()
} else {
params.completed_after = fromDate.toISOString()
params.completed_before = toDate.toISOString()
}
}
const sessionsData = await sessionsApi.list({ ...params, size: 51 })
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)
} finally {
if (!cancelled) setIsLoading(false)
}
}
loadSessions()
return () => { cancelled = true }
}, [filter, filters])
// Update URL params when filters change
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])
// Load AI sessions when tab is active
useEffect(() => {
if (sessionType !== 'ai') return
let cancelled = false
const loadAiSessions = async () => {
setAiLoading(true)
try {
const data = await aiSessionsApi.listSessions({ limit: 50 })
if (!cancelled) setAiSessions(data)
} catch {
if (!cancelled) toast.error('Failed to load AI sessions')
} finally {
if (!cancelled) setAiLoading(false)
}
}
loadAiSessions()
return () => { cancelled = true }
}, [sessionType])
const handleFilterChange = (newFilters: SessionFilterState) => {
setFilters(newFilters)
}
const handleClearFilters = () => {
setFilters({
ticketNumber: '',
clientName: '',
treeName: '',
dateRange: undefined,
dateType: 'started',
})
}
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])
// Close popover on click outside
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 formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString()
}
const getTreeName = (session: Session): string => {
return 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
}
return (
<div className="overflow-y-auto h-full">
<PageMeta title="Session History" />
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-8">
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Session History</h1>
<p className="mt-2 text-muted-foreground">
Search and filter your troubleshooting sessions
</p>
</div>
{/* Session type toggle */}
<div className="mb-4 flex gap-1 rounded-lg bg-card/50 p-1 w-fit border border-border">
<button
onClick={() => setSessionType('flow')}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
sessionType === 'flow'
? 'bg-primary/10 text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
Flow Sessions
</button>
<button
onClick={() => setSessionType('ai')}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
sessionType === 'ai'
? 'bg-primary/10 text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
AI Sessions
</button>
</div>
{/* Filter Tabs (flow sessions only) */}
{sessionType === 'flow' && (
<div className="mb-6 flex gap-2 border-b border-border">
{(['active', 'prepared', 'completed', 'all'] as const).map((tab) => (
<button
key={tab}
onClick={() => setFilter(tab)}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors',
filter === tab
? 'border-b-2 border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
)}
{/* AI Sessions view */}
{sessionType === 'ai' && (
aiLoading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
) : aiSessions.length === 0 ? (
<EmptyState
title="No AI sessions yet"
description="Start a FlowPilot session to get AI-guided troubleshooting. Sessions will appear here."
action={
<Link
to="/pilot"
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
>
Start AI Session
</Link>
}
/>
) : (
<div className="space-y-2">
{aiSessions.map((s) => (
<AISessionListItem key={s.id} session={s} />
))}
</div>
)
)}
{/* Flow Sessions Content */}
{sessionType === 'flow' && (
<>
<div className="mb-6">
<SessionFilters
filters={filters}
onChange={handleFilterChange}
onClear={handleClearFilters}
trees={trees}
/>
</div>
{/* Loading State */}
{isLoading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
) : sessions.length === 0 ? (
(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) ? (
<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 session history will appear here"
description="Every troubleshooting session is recorded with decisions, timing, and outcomes — ready for export or review."
action={
<Link
to="/trees"
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
>
Start a Session
</Link>
}
learnMoreLink="/guides/sessions"
/>
)
) : (
<>
<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
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-1">
{/* Status and Ticket/Client */}
<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-green-500' : 'bg-yellow-500'
)}
/>
<span className="font-medium text-foreground">
{session.ticket_number || 'No ticket'}
</span>
{session.client_name && (
<span className="rounded-full bg-accent px-2.5 py-0.5 text-xs font-medium text-foreground">
{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-emerald-500/20 text-emerald-300',
session.outcome === 'workaround' && 'bg-amber-500/20 text-amber-300',
session.outcome === 'escalated' && 'bg-blue-500/20 text-blue-300',
session.outcome === 'unresolved' && 'bg-rose-500/20 text-rose-300',
session.outcome === 'cancelled' && 'bg-zinc-500/20 text-zinc-300',
session.outcome === 'resolved_externally' && 'bg-cyan-500/20 text-cyan-300',
!session.outcome && 'bg-accent text-muted-foreground'
)}
>
{formatOutcomeLabel(session.outcome)}
</span>
)}
</div>
{/* Tree Name */}
<p className="mt-1 text-sm text-muted-foreground">
<span className="font-medium">Tree:</span> {getTreeName(session)}
</p>
{/* Timestamps */}
<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>
{/* Stats */}
<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={cn(
'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
'hover:bg-accent hover:text-foreground'
)}
>
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-accent hover:text-foreground',
closingSessionId === session.id && 'bg-accent text-foreground'
)}
>
Close
</button>
<button
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
className={cn(
'rounded-md bg-gradient-brand px-3 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
'hover:opacity-90'
)}
>
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-xs font-label 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(6,182,212,0.3)] 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-xs font-label 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(6,182,212,0.3)] 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-accent hover:text-foreground"
>
Cancel
</button>
<button
onClick={handleCloseSession}
disabled={!closeOutcome || closeLoading}
className={cn(
'rounded-lg px-4 py-1.5 text-sm font-medium shadow-lg shadow-primary/20 transition-opacity',
closeOutcome
? 'bg-gradient-brand text-[#101114] hover:opacity-90'
: 'bg-gradient-brand text-[#101114] opacity-50 cursor-not-allowed'
)}
>
{closeLoading ? 'Closing...' : 'Confirm'}
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
))}
</div>
{hasMore ? (
<p className="text-center text-sm text-muted-foreground py-4">
Showing the 50 most recent sessions
</p>
) : sessions.length > 0 ? (
<p className="text-center text-sm text-muted-foreground py-4">
Showing all {sessions.length} sessions
</p>
) : null}
</>
)}
</>
)}
</div>
</div>
)
}
export default SessionHistoryPage