import { useState, useRef, useCallback, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, GripHorizontal } from 'lucide-react' import { cn } from '@/lib/utils' import { PageMeta } from '@/components/common/PageMeta' import { aiSessionsApi } from '@/api/aiSessions' import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar' import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal' import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal' import { IncidentHeader } from '@/components/assistant/IncidentHeader' import { StepsPanel } from '@/components/assistant/StepsPanel' import { FlowPilotAsks } from '@/components/assistant/FlowPilotAsks' import { WhatWeKnow } from '@/components/assistant/WhatWeKnow' import { ViewToggle } from '@/components/assistant/ViewToggle' import { useAssistantSession } from '@/hooks/useAssistantSession' import { useFeatureFlag } from '@/hooks/useFeatureFlag' import type { TriageMeta, EvidenceItem, TriageUpdate } from '@/types/ai-session' export default function CockpitPage() { const navigate = useNavigate() const hasCockpit = useFeatureFlag('flowpilot_cockpit') const session = useAssistantSession() // ── Cockpit-specific state ── const [triageMeta, setTriageMeta] = useState({ client_name: null, asset_name: null, issue_category: null, triage_hypothesis: null, evidence_items: [], }) const [psaTicketId, setPsaTicketId] = useState(null) const [workZonePct, setWorkZonePct] = useState(() => { const saved = localStorage.getItem('rf-assistant-work-zone-height') return saved ? parseFloat(saved) : 55 }) const [activeStepIndex, setActiveStepIndex] = useState(0) const [completedSteps, setCompletedSteps] = useState>(new Set()) const [showOnboarding, setShowOnboarding] = useState(() => !localStorage.getItem('rf-cockpit-onboarded') ) const splitContainerRef = useRef(null) const prevMessageCountRef = useRef(session.messages.length) const dismissOnboarding = () => { setShowOnboarding(false) localStorage.setItem('rf-cockpit-onboarded', '1') } // ── Feature flag redirect ── useEffect(() => { if (!hasCockpit && session.activeChatId) { navigate(`/assistant/${session.activeChatId}`, { replace: true }) } else if (!hasCockpit) { navigate('/assistant', { replace: true }) } }, [hasCockpit, session.activeChatId, navigate]) // ── Wire triage data from session loads ── useEffect(() => { session.onSessionLoadedRef.current = (detail) => { setTriageMeta({ client_name: detail.client_name ?? null, asset_name: detail.asset_name ?? null, issue_category: detail.issue_category ?? null, triage_hypothesis: detail.triage_hypothesis ?? null, evidence_items: (detail.evidence_items as EvidenceItem[]) ?? [], }) setPsaTicketId(detail.psa_ticket_id ?? null) } return () => { session.onSessionLoadedRef.current = null } }, [session.onSessionLoadedRef]) // ── Wire triage updates from AI responses ── useEffect(() => { session.onTriageUpdateRef.current = mergeTriageUpdate return () => { session.onTriageUpdateRef.current = null } }, [session.onTriageUpdateRef]) // eslint-disable-line react-hooks/exhaustive-deps // ── Handle prefill from command palette / dashboard handoff ── useEffect(() => { session.handlePrefill('/cockpit') }, []) // eslint-disable-line react-hooks/exhaustive-deps // ── Dismiss onboarding when first message is sent ── useEffect(() => { if (showOnboarding && session.messages.length > prevMessageCountRef.current) { dismissOnboarding() } prevMessageCountRef.current = session.messages.length }, [session.messages.length, showOnboarding]) // Reset all cockpit-local state when switching cases. // Triage data gets repopulated via onSessionLoadedRef after the async fetch. useEffect(() => { setActiveStepIndex(0) setCompletedSteps(new Set()) setTriageMeta({ client_name: null, asset_name: null, issue_category: null, triage_hypothesis: null, evidence_items: [], }) setPsaTicketId(null) }, [session.activeChatId]) // Reset step UI when a new action set arrives from AI. useEffect(() => { setActiveStepIndex(0) setCompletedSteps(new Set()) }, [session.activeActions]) // ── Triage handlers ── const handleTriageFieldSave = useCallback(async (field: keyof TriageMeta, value: string) => { if (!session.activeChatId) return try { await aiSessionsApi.updateTriage(session.activeChatId, { [field]: value }) setTriageMeta(prev => ({ ...prev, [field]: value })) } catch { // best-effort — toast handled by caller if needed } }, [session.activeChatId]) const handleEvidenceAdd = useCallback(async (text: string, status: EvidenceItem['status']) => { const newItem: EvidenceItem = { text, status } let updated: EvidenceItem[] = [] setTriageMeta(prev => { updated = [...prev.evidence_items, newItem] return { ...prev, evidence_items: updated } }) if (session.activeChatId) { try { await aiSessionsApi.updateTriage(session.activeChatId, { evidence_items: updated }) } catch { /* best-effort */ } } }, [session.activeChatId]) const handleEvidenceEdit = useCallback(async (index: number, text: string, status: EvidenceItem['status']) => { let updated: EvidenceItem[] = [] setTriageMeta(prev => { updated = prev.evidence_items.map((item, i) => i === index ? { text, status } : item) return { ...prev, evidence_items: updated } }) if (session.activeChatId) { try { await aiSessionsApi.updateTriage(session.activeChatId, { evidence_items: updated }) } catch { /* best-effort */ } } }, [session.activeChatId]) const handleStepComplete = useCallback((index: number) => { setCompletedSteps(prev => { const next = new Set(prev) next.add(index) // Auto-advance using the updated set (avoids stale closure) const nextIncomplete = session.activeActions.findIndex((_, i) => i > index && !next.has(i)) if (nextIncomplete !== -1) { setActiveStepIndex(nextIncomplete) } else if (index + 1 < session.activeActions.length) { setActiveStepIndex(index + 1) } return next }) }, [session.activeActions]) const handleStepSelect = useCallback((index: number) => { setActiveStepIndex(index) }, []) // Merge triage_update from AI response into local state const mergeTriageUpdate = useCallback((update: TriageUpdate) => { setTriageMeta(prev => { const merged = { ...prev } // AI only fills null fields (manual edits win) if (update.client_name && !prev.client_name) merged.client_name = update.client_name if (update.asset_name && !prev.asset_name) merged.asset_name = update.asset_name if (update.issue_category && !prev.issue_category) merged.issue_category = update.issue_category if (update.triage_hypothesis && !prev.triage_hypothesis) merged.triage_hypothesis = update.triage_hypothesis // Append new evidence items if (update.evidence_items && update.evidence_items.length > 0) { merged.evidence_items = [...prev.evidence_items, ...update.evidence_items] } return merged }) }, []) // Drag handle for work zone / chat split const handleDragStart = useCallback((e: React.MouseEvent) => { e.preventDefault() const container = splitContainerRef.current if (!container) return const rect = container.getBoundingClientRect() const onMove = (ev: MouseEvent) => { const pct = ((ev.clientY - rect.top) / rect.height) * 100 const clamped = Math.max(25, Math.min(75, pct)) setWorkZonePct(clamped) } const onUp = () => { document.removeEventListener('mousemove', onMove) document.removeEventListener('mouseup', onUp) setWorkZonePct(prev => { localStorage.setItem('rf-assistant-work-zone-height', String(prev)); return prev }) } document.addEventListener('mousemove', onMove) document.addEventListener('mouseup', onUp) }, []) return ( <>
{/* Sidebar — hidden on mobile, collapsed to top bar or full sidebar on desktop */} {!session.sidebarCollapsed && (
)}
session.setMobileSidebarOpen(false)} />
{/* Main chat area + optional branch sidebar */}
{/* Collapsed sidebar top bar — desktop only */} {session.sidebarCollapsed && (
)} {/* Cockpit content area */}
{/* Mobile header with case history toggle */}
{/* View tab bar — persistent when a session is active */} {session.activeChatId && ( )} {session.activeChatId ? ( <> {/* Incident Header */} session.setShowConclude(true)} onStatusUpdate={session.messages.length >= 2 ? () => session.setShowStatusUpdate(true) : undefined} onClose={() => session.setShowConclude(true)} /> {/* Resizable work zone + conversation log split */}
{/* First-run onboarding overlay */} {showOnboarding && session.messages.length === 0 && (
{/* Steps zone label */}

Steps Panel

Troubleshooting steps appear here as FlowPilot identifies them. Click to mark done.

{/* FlowPilot Asks zone label */}

AI Questions & Evidence

FlowPilot asks clarifying questions here. Evidence you confirm or rule out is tracked below.

{/* Conversation log label */}

Conversation Log

Full chat history lives here. Drag the handle above to resize.

)} {/* Work zone */}
{/* Left: Steps panel */}
{session.loading && session.activeActions.length === 0 ? (

FlowPilot is analyzing the issue...

) : ( )}
{/* Right: FlowPilot Asks + What We Know */}
{session.loading && session.activeQuestions.length === 0 && triageMeta.evidence_items.length === 0 ? (

Questions and evidence will appear here

) : ( <> { void session.sendMessage(answer, { clearComposer: false }) }} loading={session.loading} /> )}
{/* Drag handle */}
{/* Conversation log */}
Conversation Log
{session.messages.length === 0 && !session.loading && (

Start a new case to begin troubleshooting

)}
{session.messages.map((msg, i) => (
{msg.role === 'user' ? 'You' : 'FlowPilot'} {msg.content}
))} {session.loading && (
FlowPilot
)}
{/* Rich Input */}
{/* Drag overlay */} {session.isDragOver && (
Drop files to attach
)} {/* Textarea */}