From 912075cd431f7b75023aad9dc0ef0da2db2f96f4 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sun, 29 Mar 2026 17:06:30 -0400 Subject: [PATCH 01/13] =?UTF-8?q?refactor:=20dashboard=20design=20critique?= =?UTF-8?q?=20=E2=80=94=20eliminate=20redundancy,=20differentiate=20sectio?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove GreetingStatStrip (duplicated PerformanceCards data) - Strip left-border accent from stat cards (AI slop pattern) - Redesign KnowledgeBaseCards: icon grid → compact row list with icon badges - Redesign TeamSummary: distinct inline-row layout, no longer identical twin - Differentiate hover: stat cards use subtle border-hover, sessions keep springy lift - Add loading skeletons to PerformanceCards, KnowledgeBaseCards, TeamSummary - Add error state to PerformanceCards - Extract timeAgo() to shared lib/timeAgo.ts (replaced 4 duplicates) - Fix Skeleton bg-brand-border (undefined CSS var) → border-default - Fix double text-xs text-[0.5625rem] class conflicts across dashboard Co-Authored-By: Claude Opus 4.6 (1M context) --- .impeccable.md | 2 +- .../dashboard/ActiveFlowPilotSessions.tsx | 13 +----- .../dashboard/KnowledgeBaseCards.tsx | 41 +++++++++++++------ .../dashboard/PendingEscalations.tsx | 11 +---- .../components/dashboard/PerformanceCards.tsx | 32 +++++++++++++-- .../dashboard/RecentFlowPilotSessions.tsx | 13 +----- .../src/components/dashboard/TeamSummary.tsx | 32 +++++++++------ .../components/layout/NotificationsPanel.tsx | 9 +--- frontend/src/components/ui/Skeleton.tsx | 2 +- frontend/src/lib/timeAgo.ts | 14 +++++++ frontend/src/pages/QuickStartPage.tsx | 21 ++++------ 11 files changed, 105 insertions(+), 85 deletions(-) create mode 100644 frontend/src/lib/timeAgo.ts diff --git a/.impeccable.md b/.impeccable.md index 5b5813d6..b3ce9531 100644 --- a/.impeccable.md +++ b/.impeccable.md @@ -34,7 +34,7 @@ **Theme:** Dark mode primary (charcoal palette). Light mode planned but not yet implemented. -**Accent:** Ember orange (#f97316) — conveys urgency fitting a troubleshooting context. Used sparingly (max 5% of UI). Warning uses yellow (#eab308), not amber, to stay distinct. +**Accent:** Electric blue (#60a5fa dark / #2563eb light) — conveys trust, precision, and reliability fitting a troubleshooting tool MSP engineers depend on during outages. Used sparingly (max 5% of UI). Warning uses amber (#fbbf24), info uses cyan (#67e8f9). **Hard rules:** No glassmorphism, no gradient surfaces, no ambient orbs, no backdrop blur, no decorative shadows at rest. Elevation = lighter surface + border, not shadow. diff --git a/frontend/src/components/dashboard/ActiveFlowPilotSessions.tsx b/frontend/src/components/dashboard/ActiveFlowPilotSessions.tsx index 41093810..3f6e8b08 100644 --- a/frontend/src/components/dashboard/ActiveFlowPilotSessions.tsx +++ b/frontend/src/components/dashboard/ActiveFlowPilotSessions.tsx @@ -4,16 +4,7 @@ import { Clock, ArrowRight, Route, MessageCircle } from 'lucide-react' import { aiSessionsApi } from '@/api/aiSessions' import type { AISessionSummary } from '@/types/ai-session' import { cn } from '@/lib/utils' - -function timeAgo(dateStr: string): string { - const diffMs = Date.now() - new Date(dateStr).getTime() - const minutes = Math.floor(diffMs / 60000) - if (minutes < 1) return 'just now' - if (minutes < 60) return `${minutes}m ago` - const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h ago` - return `${Math.floor(hours / 24)}d ago` -} +import { timeAgo } from '@/lib/timeAgo' export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: boolean }) { const [sessions, setSessions] = useState([]) @@ -68,7 +59,7 @@ export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: b )} { sidebarApi.getStats() .then((stats) => setFlowCount(stats.tree_counts.total)) .catch(() => {}) + .finally(() => setLoading(false)) }, []) const items = [ @@ -33,23 +36,35 @@ export function KnowledgeBaseCards() { Browse -
- {items.map((item) => ( +
+ {loading ? Array.from({ length: 3 }).map((_, i) => ( +
+ + + +
+ )) : items.map((item, i) => ( - ))} + )))}
) diff --git a/frontend/src/components/dashboard/PendingEscalations.tsx b/frontend/src/components/dashboard/PendingEscalations.tsx index 2d36a561..7db7955a 100644 --- a/frontend/src/components/dashboard/PendingEscalations.tsx +++ b/frontend/src/components/dashboard/PendingEscalations.tsx @@ -3,16 +3,7 @@ import { Link, useNavigate } from 'react-router-dom' import { AlertTriangle } from 'lucide-react' import { aiSessionsApi } from '@/api/aiSessions' import type { AISessionSummary } from '@/types/ai-session' - -function timeAgo(dateStr: string): string { - const diffMs = Date.now() - new Date(dateStr).getTime() - const minutes = Math.floor(diffMs / 60000) - if (minutes < 1) return 'just now' - if (minutes < 60) return `${minutes}m ago` - const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h ago` - return `${Math.floor(hours / 24)}d ago` -} +import { timeAgo } from '@/lib/timeAgo' export function PendingEscalations() { const [escalations, setEscalations] = useState([]) diff --git a/frontend/src/components/dashboard/PerformanceCards.tsx b/frontend/src/components/dashboard/PerformanceCards.tsx index 6ae67aa6..c8dbb701 100644 --- a/frontend/src/components/dashboard/PerformanceCards.tsx +++ b/frontend/src/components/dashboard/PerformanceCards.tsx @@ -4,6 +4,7 @@ import { CheckCircle, Clock, TrendingUp, Timer } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { sidebarApi } from '@/api' import { cn } from '@/lib/utils' +import { Skeleton } from '@/components/ui/Skeleton' interface StatCard { label: string @@ -19,6 +20,8 @@ export function PerformanceCards() { const [resolved, setResolved] = useState(0) const [active, setActive] = useState(0) const [totalMinutes, setTotalMinutes] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) useEffect(() => { sidebarApi.getStats() @@ -27,9 +30,31 @@ export function PerformanceCards() { setActive(stats.active_count) setTotalMinutes(stats.total_session_minutes_today) }) - .catch(() => {}) + .catch(() => setError(true)) + .finally(() => setLoading(false)) }, []) + if (loading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+ ) + } + + if (error) { + return ( +
+

Unable to load performance data

+
+ ) + } + const avgMttr = resolved > 0 ? Math.round(totalMinutes / resolved) : 0 const cards: StatCard[] = [ @@ -70,14 +95,13 @@ export function PerformanceCards() { -
- {items.map((item) => ( +
+ {loading ? Array.from({ length: 3 }).map((_, i) => ( +
+ + + +
+ )) : items.map((item) => ( - ))} + )))}
) diff --git a/frontend/src/components/layout/NotificationsPanel.tsx b/frontend/src/components/layout/NotificationsPanel.tsx index 8f634173..a261a068 100644 --- a/frontend/src/components/layout/NotificationsPanel.tsx +++ b/frontend/src/components/layout/NotificationsPanel.tsx @@ -10,14 +10,7 @@ import { } from 'lucide-react' import { notificationsApi } from '@/api/notifications' import type { AppNotification } from '@/types/notification' - -function timeAgo(dateStr: string): string { - const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000) - if (diff < 60) return 'just now' - if (diff < 3600) return `${Math.floor(diff / 60)}m ago` - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` - return `${Math.floor(diff / 86400)}d ago` -} +import { timeAgo } from '@/lib/timeAgo' function EventIcon({ event }: { event: string }) { switch (event) { diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx index 9890d317..c3c66b15 100644 --- a/frontend/src/components/ui/Skeleton.tsx +++ b/frontend/src/components/ui/Skeleton.tsx @@ -6,7 +6,7 @@ export function Skeleton({ className, ...props }: SkeletonProps) { return (
- {/* Hero: Greeting + Stat Strip */} -
-
-

- {dayOfWeek}, {formattedDate} -

-

- Good {greeting},
- {firstName}. -

-
- + {/* Hero: Greeting */} +
+

+ {dayOfWeek}, {formattedDate} +

+

+ Good {greeting}, {firstName}. +

{/* Chat-style input */} -- 2.49.1 From cc51d21300c811b0476abd1b7299ae008ef2c13e Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sun, 29 Mar 2026 17:11:22 -0400 Subject: [PATCH 02/13] fix: correct ternary closing brackets in KnowledgeBaseCards and TeamSummary Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/dashboard/KnowledgeBaseCards.tsx | 2 +- frontend/src/components/dashboard/TeamSummary.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/dashboard/KnowledgeBaseCards.tsx b/frontend/src/components/dashboard/KnowledgeBaseCards.tsx index 04a3a9f8..579dffba 100644 --- a/frontend/src/components/dashboard/KnowledgeBaseCards.tsx +++ b/frontend/src/components/dashboard/KnowledgeBaseCards.tsx @@ -64,7 +64,7 @@ export function KnowledgeBaseCards() { - )))} + ))}
) diff --git a/frontend/src/components/dashboard/TeamSummary.tsx b/frontend/src/components/dashboard/TeamSummary.tsx index cf08c0f3..b0557de4 100644 --- a/frontend/src/components/dashboard/TeamSummary.tsx +++ b/frontend/src/components/dashboard/TeamSummary.tsx @@ -62,7 +62,7 @@ export function TeamSummary() { {item.value} - )))} + ))}
) -- 2.49.1 From 37179096b06968e49a19d169747cd7cfd2dd6b7d Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sun, 29 Mar 2026 17:47:58 -0400 Subject: [PATCH 03/13] =?UTF-8?q?refactor:=20assistant=20page=20=E2=80=94?= =?UTF-8?q?=20TaskLane=20UX=20+=20ChatSidebar=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskLane: - Simplify action buttons: merge "Paste Result"/"Type Answer" into single primary button, make "Skip" icon-only (reduces decision points from 3→1) - Strengthen done state: solid left-border success green + checkmark icon instead of faint tint that nearly disappears - Boost progress bar: 3px→5px, better contrast colors, inline count label - Differentiate from ChatSidebar: use bg-page instead of bg-sidebar, add accent top-border to signal "active workspace" - Make skipped tasks clickable to un-skip (matching done→reopen pattern) - Fix slide-in animation: add slide-in-from-right keyframe - Fix duplicate style props, stray quote from replace_all - Consolidate 6 grip dot divs to Array.from loop ChatSidebar: - Add inline delete confirmation ("Delete? Yes / No") instead of immediate destructive action - Fix text-xs text-[0.625rem] double class conflict on Pinned header Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/assistant/ChatSidebar.tsx | 75 ++++++++++----- .../src/components/assistant/TaskLane.tsx | 93 ++++++++++--------- frontend/src/index.css | 4 + 3 files changed, 102 insertions(+), 70 deletions(-) diff --git a/frontend/src/components/assistant/ChatSidebar.tsx b/frontend/src/components/assistant/ChatSidebar.tsx index 133f0a60..bea51f18 100644 --- a/frontend/src/components/assistant/ChatSidebar.tsx +++ b/frontend/src/components/assistant/ChatSidebar.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { Plus, Pin, Trash2, MessageSquare, History, X } from 'lucide-react' import { cn } from '@/lib/utils' import type { ChatListItem } from '@/types/assistant-chat' @@ -84,7 +85,7 @@ export function ChatSidebar({
{pinnedChats.length > 0 && (
- + Pinned
@@ -184,39 +185,65 @@ function ChatItem({ onDelete: () => void onTogglePin: () => void }) { + const [confirming, setConfirming] = useState(false) + return (
-
{chat.title}
-
- {chat.message_count} messages + {confirming ? ( +
+ Delete? + + +
+ ) : ( + <> +
{chat.title}
+
+ {chat.message_count} messages +
+ + )} +
+ {!confirming && ( +
+ +
-
-
- - -
+ )}
) } diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx index 7a6a5850..590f34b3 100644 --- a/frontend/src/components/assistant/TaskLane.tsx +++ b/frontend/src/components/assistant/TaskLane.tsx @@ -225,8 +225,8 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa return (
{/* Resize grip handle */}
-
-
-
-
-
-
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))}
{/* Header */} -
+

Tasks 0 && (
-
+
Questions @@ -282,16 +279,19 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa if (q.state === 'done') { return ( -
updateTask(idx, { state: 'active' })}> -
{q.text}
-
"{q.value}"
+
updateTask(idx, { state: 'active' })}> +
+ + {q.text} +
+
"{q.value}"
) } if (q.state === 'skipped') { return ( -
+
updateTask(idx, { state: 'pending' })} title="Click to restore">
{q.text}
Skipped @@ -342,9 +342,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
)} @@ -357,7 +358,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa {/* ── Checks Section ── */} {actionTasks.length > 0 && (
-
+
Diagnostic Checks @@ -401,10 +402,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa if (a.state === 'done') { return ( -
updateTask(idx, { state: 'active' })}> -
-
{a.label}
- ✓ Done +
updateTask(idx, { state: 'active' })}> +
+ + {a.label}
) @@ -412,7 +413,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa if (a.state === 'skipped') { return ( -
+
updateTask(idx, { state: 'pending' })} title="Click to restore">
{a.label}
Skipped @@ -464,24 +465,19 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
) : ( -
+
-
)} @@ -495,19 +491,24 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa {/* Footer */}
{/* Progress bar */} -
- {tasks.map((t, i) => ( -
- ))} +
+
+ {tasks.map((t, i) => ( +
+ ))} +
+ + {handledCount}/{totalCount} +
{/* Collapsible preview */} {anyHandled && ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 2fb1f923..8405b22f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -82,6 +82,7 @@ --animate-fade-in: fade-in 200ms ease-out both; --animate-fade-in-up: fade-in-up 200ms ease-out both; --animate-slide-in-left: slide-in-from-left 200ms ease-out; + --animate-slide-in-right: slide-in-from-right 200ms ease-out both; --animate-slide-in-bottom: slide-in-from-bottom 200ms ease-out both; --animate-scale-in: scale-in 150ms ease-out both; --animate-fade: fadeIn 300ms ease both; @@ -95,6 +96,9 @@ @keyframes slide-in-from-left { from { transform: translateX(-100%); } to { transform: translateX(0); } } + @keyframes slide-in-from-right { + from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: translateX(0); } + } @keyframes slide-in-from-bottom { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } -- 2.49.1 From 9ce4a8bc8ecdd7504d5dc5144cd32cfcdbd09eee Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 30 Mar 2026 00:34:38 +0000 Subject: [PATCH 04/13] 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) --- .../components/flowpilot/EscalationQueue.tsx | 52 +- frontend/src/pages/EscalationQueuePage.tsx | 17 +- frontend/src/pages/SessionHistoryPage.tsx | 1085 ++++++++--------- 3 files changed, 576 insertions(+), 578 deletions(-) diff --git a/frontend/src/components/flowpilot/EscalationQueue.tsx b/frontend/src/components/flowpilot/EscalationQueue.tsx index af2de015..20e865f1 100644 --- a/frontend/src/components/flowpilot/EscalationQueue.tsx +++ b/frontend/src/components/flowpilot/EscalationQueue.tsx @@ -3,12 +3,21 @@ import { useNavigate } from 'react-router-dom' import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react' import { aiSessionsApi } from '@/api' import type { AISessionSummary } from '@/types/ai-session' +import { timeAgo } from '@/lib/timeAgo' interface EscalationQueueProps { onPickup?: (sessionId: string) => void + onCountChange?: (count: number) => void } -export function EscalationQueue({ onPickup }: EscalationQueueProps) { +function waitTimeColor(createdAt: string): string { + const hours = (Date.now() - new Date(createdAt).getTime()) / 3_600_000 + if (hours >= 4) return '#f87171' // danger + if (hours >= 1) return '#fbbf24' // warning/amber + return '#848b9b' // muted +} + +export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProps) { const navigate = useNavigate() const [sessions, setSessions] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -19,7 +28,12 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) { setError(null) try { const data = await aiSessionsApi.getEscalationQueue() - setSessions(data) + // Sort oldest-first — longest waiting = most urgent + const sorted = [...data].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ) + setSessions(sorted) + onCountChange?.(sorted.length) } catch { setError('Failed to load escalation queue') } finally { @@ -29,6 +43,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) { useEffect(() => { loadQueue() + // eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount }, []) const handlePickup = (sessionId: string) => { @@ -50,7 +65,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) { if (error) { return (
-

{error}

+

{error}

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

Escalation Queue

-

Sessions from your team waiting for pickup

+

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

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

Sessions

-

- View and manage all your sessions -

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

AI Sessions

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

Flow Sessions

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

Session History

+

View and manage your sessions

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

Close Session

- - - - - -