|
|
|
|
@@ -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<TabId>('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 [aiFilters, setAiFilters] = useState({
|
|
|
|
|
@@ -34,11 +47,12 @@ export function SessionHistoryPage() {
|
|
|
|
|
date_to: '',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ── Flow Session state ──
|
|
|
|
|
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 [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<string | null>(null)
|
|
|
|
|
@@ -47,28 +61,19 @@ export function SessionHistoryPage() {
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
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<string, string | boolean> = {}
|
|
|
|
|
|
|
|
|
|
// 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,76 +239,51 @@ 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<string, string> = {
|
|
|
|
|
resolved: 'Resolved',
|
|
|
|
|
escalated: 'Escalated',
|
|
|
|
|
workaround: 'Workaround',
|
|
|
|
|
unresolved: 'Unresolved',
|
|
|
|
|
cancelled: 'Cancelled',
|
|
|
|
|
resolved_externally: 'Resolved Externally',
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
// Determine section visibility
|
|
|
|
|
const showAiSection = aiLoading || aiSessions.length > 0 || hasAiFiltersActive
|
|
|
|
|
const showFlowSection = isLoading || sessions.length > 0 || hasFlowFiltersActive
|
|
|
|
|
const showCombinedEmpty = !showAiSection && !showFlowSection
|
|
|
|
|
|
|
|
|
|
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">
|
|
|
|
|
<div className="mb-8">
|
|
|
|
|
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Sessions</h1>
|
|
|
|
|
<p className="mt-2 text-muted-foreground">
|
|
|
|
|
View and manage all your sessions
|
|
|
|
|
</p>
|
|
|
|
|
{/* Page heading */}
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
{showCombinedEmpty && (
|
|
|
|
|
<EmptyState
|
|
|
|
|
illustration={<SessionIllustration />}
|
|
|
|
|
title="No sessions yet"
|
|
|
|
|
description="Start a flow or FlowPilot session to begin. All your sessions will appear here."
|
|
|
|
|
action={
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
<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>
|
|
|
|
|
<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 bar */}
|
|
|
|
|
<div className="flex gap-1 border-b border-border mb-6 overflow-x-auto">
|
|
|
|
|
{TABS.map((tab) => (
|
|
|
|
|
<button
|
|
|
|
|
key={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>
|
|
|
|
|
|
|
|
|
|
{/* FlowPilot Sessions Section */}
|
|
|
|
|
{showAiSection && (
|
|
|
|
|
{/* ════════ AI Sessions Tab ════════ */}
|
|
|
|
|
{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 */}
|
|
|
|
|
<div className="card-flat p-3 mb-4">
|
|
|
|
|
<div className="flex flex-wrap gap-3 items-center">
|
|
|
|
|
@@ -329,7 +295,7 @@ export function SessionHistoryPage() {
|
|
|
|
|
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(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>
|
|
|
|
|
|
|
|
|
|
@@ -351,24 +317,17 @@ export function SessionHistoryPage() {
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Problem domain dropdown */}
|
|
|
|
|
{/* 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(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="Active Directory">Active Directory</option>
|
|
|
|
|
<option value="Networking">Networking</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>
|
|
|
|
|
{problemDomains.map((d) => (
|
|
|
|
|
<option key={d} value={d}>{d}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
|
|
|
|
|
{/* Confidence tier pills */}
|
|
|
|
|
@@ -378,7 +337,7 @@ export function SessionHistoryPage() {
|
|
|
|
|
key={tier}
|
|
|
|
|
onClick={() => setAiFilters((f) => ({ ...f, confidence_tier: tier }))}
|
|
|
|
|
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
|
|
|
|
|
? '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)]'
|
|
|
|
|
@@ -396,7 +355,7 @@ export function SessionHistoryPage() {
|
|
|
|
|
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(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>
|
|
|
|
|
<input
|
|
|
|
|
@@ -404,7 +363,7 @@ export function SessionHistoryPage() {
|
|
|
|
|
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(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>
|
|
|
|
|
|
|
|
|
|
@@ -423,11 +382,13 @@ export function SessionHistoryPage() {
|
|
|
|
|
</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."
|
|
|
|
|
@@ -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">
|
|
|
|
|
{aiSessions.map((s) => (
|
|
|
|
|
<AISessionListItem key={s.id} session={s} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Divider between sections */}
|
|
|
|
|
{showFlowSection && (
|
|
|
|
|
<div className="my-8 border-t border-border" />
|
|
|
|
|
{/* 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 Section */}
|
|
|
|
|
{showFlowSection && (
|
|
|
|
|
{/* ════════ Flow Sessions Tab ════════ */}
|
|
|
|
|
{activeTab === 'flows' && (
|
|
|
|
|
<>
|
|
|
|
|
<h2 className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">Flow Sessions</h2>
|
|
|
|
|
|
|
|
|
|
{/* Filter Tabs */}
|
|
|
|
|
{/* 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={() => setFilter(tab)}
|
|
|
|
|
onClick={() => setFlowTab(tab)}
|
|
|
|
|
className={cn(
|
|
|
|
|
'px-4 py-2 text-sm font-medium transition-colors',
|
|
|
|
|
filter === tab
|
|
|
|
|
flowTab === tab
|
|
|
|
|
? 'border-b-2 border-primary text-foreground'
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
|
|
|
)}
|
|
|
|
|
@@ -490,13 +479,13 @@ export function SessionHistoryPage() {
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Loading State */}
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
{/* Flow session list */}
|
|
|
|
|
{flowLoading ? (
|
|
|
|
|
<div className="flex justify-center py-12">
|
|
|
|
|
<Spinner />
|
|
|
|
|
</div>
|
|
|
|
|
) : sessions.length === 0 ? (
|
|
|
|
|
(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) ? (
|
|
|
|
|
hasFlowFiltersActive ? (
|
|
|
|
|
<EmptyState
|
|
|
|
|
title="No sessions match your filters"
|
|
|
|
|
description="Try adjusting your search or filters."
|
|
|
|
|
@@ -509,17 +498,16 @@ export function SessionHistoryPage() {
|
|
|
|
|
) : (
|
|
|
|
|
<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."
|
|
|
|
|
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 Session
|
|
|
|
|
Start a Flow
|
|
|
|
|
</Link>
|
|
|
|
|
}
|
|
|
|
|
learnMoreLink="/guides/sessions"
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
) : (
|
|
|
|
|
@@ -534,24 +522,21 @@ export function SessionHistoryPage() {
|
|
|
|
|
)}
|
|
|
|
|
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="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">
|
|
|
|
|
{/* 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'
|
|
|
|
|
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 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}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
@@ -559,13 +544,13 @@ export function SessionHistoryPage() {
|
|
|
|
|
<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-blue-500/20 text-blue-300',
|
|
|
|
|
!session.outcome && 'bg-accent text-muted-foreground'
|
|
|
|
|
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)}
|
|
|
|
|
@@ -573,20 +558,15 @@ export function SessionHistoryPage() {
|
|
|
|
|
)}
|
|
|
|
|
</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() && (
|
|
|
|
|
@@ -599,10 +579,7 @@ export function SessionHistoryPage() {
|
|
|
|
|
<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'
|
|
|
|
|
)}
|
|
|
|
|
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>
|
|
|
|
|
@@ -615,19 +592,15 @@ export function SessionHistoryPage() {
|
|
|
|
|
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'
|
|
|
|
|
'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
|
|
|
|
|
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
|
|
|
|
className={cn(
|
|
|
|
|
'rounded-md bg-primary px-3 py-2 text-sm font-medium text-white',
|
|
|
|
|
'hover:brightness-110'
|
|
|
|
|
)}
|
|
|
|
|
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-white hover:brightness-110 transition-all"
|
|
|
|
|
>
|
|
|
|
|
Resume
|
|
|
|
|
</button>
|
|
|
|
|
@@ -642,12 +615,12 @@ export function SessionHistoryPage() {
|
|
|
|
|
>
|
|
|
|
|
<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
|
|
|
|
|
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(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="resolved">Resolved</option>
|
|
|
|
|
@@ -658,23 +631,19 @@ export function SessionHistoryPage() {
|
|
|
|
|
<option value="resolved_externally">Resolved Externally</option>
|
|
|
|
|
</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
|
|
|
|
|
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(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">
|
|
|
|
|
<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"
|
|
|
|
|
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>
|
|
|
|
|
@@ -699,15 +668,19 @@ export function SessionHistoryPage() {
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{hasMore ? (
|
|
|
|
|
<p className="text-center text-sm text-muted-foreground py-4">
|
|
|
|
|
Showing the 50 most recent sessions
|
|
|
|
|
|
|
|
|
|
{/* 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-center text-sm text-muted-foreground py-4">
|
|
|
|
|
Showing all {sessions.length} sessions
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Showing all {sessions.length} session{sessions.length !== 1 ? 's' : ''}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
@@ -716,5 +689,3 @@ export function SessionHistoryPage() {
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default SessionHistoryPage
|
|
|
|
|
|