From fc51ceb61070aa321e75674f7f670d5d168e7432 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 2 Apr 2026 17:29:42 +0000 Subject: [PATCH] refactor: rename AssistantChatPage to CockpitPage, consume useAssistantSession hook Replace all inline session management with the shared useAssistantSession hook. Keep cockpit-specific state (triageMeta, workZonePct, steps, onboarding) and handlers. Wire onSessionLoadedRef/onTriageUpdateRef callbacks. Add feature flag redirect for flowpilot_cockpit. Update router and prefetch references. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/routePrefetch.ts | 2 +- frontend/src/pages/AssistantChatPage.tsx | 1026 ---------------------- frontend/src/pages/CockpitPage.tsx | 560 ++++++++++++ frontend/src/router.tsx | 6 +- 4 files changed, 564 insertions(+), 1030 deletions(-) delete mode 100644 frontend/src/pages/AssistantChatPage.tsx create mode 100644 frontend/src/pages/CockpitPage.tsx diff --git a/frontend/src/lib/routePrefetch.ts b/frontend/src/lib/routePrefetch.ts index 1259f7f5..7e42c841 100644 --- a/frontend/src/lib/routePrefetch.ts +++ b/frontend/src/lib/routePrefetch.ts @@ -9,7 +9,7 @@ const PREFETCH_MAP: Record Promise> = { '/shares': () => import('@/pages/MySharesPage'), '/analytics': () => import('@/pages/TeamAnalyticsPage'), '/analytics/me': () => import('@/pages/MyAnalyticsPage'), - '/assistant': () => import('@/pages/AssistantChatPage'), + '/assistant': () => import('@/pages/CockpitPage'), '/step-library': () => import('@/pages/StepLibraryPage'), '/guides': () => import('@/pages/GuidesHubPage'), '/feedback': () => import('@/pages/FeedbackPage'), diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx deleted file mode 100644 index c173c7aa..00000000 --- a/frontend/src/pages/AssistantChatPage.tsx +++ /dev/null @@ -1,1026 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import { useLocation, useNavigate, useParams } from 'react-router-dom' -import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, GripHorizontal } from 'lucide-react' -import { cn } from '@/lib/utils' -import { uploadsApi } from '@/api/uploads' -import type { PendingUpload } from '@/types/upload' -import type { ForkMetadata, ActionItem, QuestionItem, TriageMeta, EvidenceItem, TriageUpdate } from '@/types/ai-session' -import { PageMeta } from '@/components/common/PageMeta' -import { aiSessionsApi } from '@/api/aiSessions' -import { useBranching } from '@/hooks/useBranching' -import { analytics } from '@/lib/analytics' -import { toast } from '@/lib/toast' -import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar' -// ChatMessage and TaskLane kept in codebase but no longer imported here -// import { ChatMessage } from '@/components/assistant/ChatMessage' -// import { TaskLane } from '@/components/assistant/TaskLane' -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 type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat' -import type { SuggestedFlow } from '@/types/copilot' - -interface MessageWithMeta { - role: 'user' | 'assistant' - content: string - suggestedFlows?: SuggestedFlow[] - fork?: ForkMetadata | null - actions?: ActionItem[] | null - questions?: QuestionItem[] | null -} - -export default function AssistantChatPage() { - const location = useLocation() - const navigate = useNavigate() - const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>() - const [chats, setChats] = useState([]) - const [activeChatId, setActiveChatId] = useState(() => { - if (urlSessionId) return urlSessionId - try { return sessionStorage.getItem('rf-active-chat-id') } catch { return null } - }) - const [messages, setMessages] = useState([]) - const [input, setInput] = useState('') - const [loading, setLoading] = useState(false) - const [showConclude, setShowConclude] = useState(false) - const [showStatusUpdate, setShowStatusUpdate] = useState(false) - const branching = useBranching() - const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) - const [showLogs, setShowLogs] = useState(false) - const [logContent, setLogContent] = useState('') - const [pendingUploads, setPendingUploads] = useState([]) - const [isDragOver, setIsDragOver] = useState(false) - const [activeQuestions, setActiveQuestions] = useState(() => { - try { - const saved = sessionStorage.getItem('rf-tasklane-meta') - if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.questions || [] } - } catch { /* ignore */ } - return [] - }) - const [activeActions, setActiveActions] = useState(() => { - try { - const saved = sessionStorage.getItem('rf-tasklane-meta') - if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.actions || [] } - } catch { /* ignore */ } - return [] - }) - const [showTaskLane, setShowTaskLane] = useState(() => { - try { - const saved = sessionStorage.getItem('rf-tasklane-meta') - if (saved) { const d = JSON.parse(saved); return d.show === true && d.chatId === activeChatId } - } catch { /* ignore */ } - return false - }) - const [sidebarCollapsed, setSidebarCollapsed] = useState(() => - localStorage.getItem('rf-chat-sidebar-collapsed') === 'true' - ) - const [triageMeta, setTriageMeta] = useState({ - client_name: null, asset_name: null, issue_category: null, - triage_hypothesis: null, evidence_items: [], - }) - 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 dismissOnboarding = () => { - setShowOnboarding(false) - localStorage.setItem('rf-cockpit-onboarded', '1') - } - const splitContainerRef = useRef(null) - const toggleSidebarCollapse = () => { - const next = !sidebarCollapsed - setSidebarCollapsed(next) - localStorage.setItem('rf-chat-sidebar-collapsed', String(next)) - } - const messagesEndRef = useRef(null) - const inputRef = useRef(null) - const fileInputRef = useRef(null) - const dragCounterRef = useRef(0) - const prefillHandledRef = useRef(false) - // Tracks the most recently requested active chat ID so in-flight selectChat - // calls that complete after the user switches chats don't clobber new state. - const currentChatRef = useRef(activeChatId) - - // Persist active chat ID to sessionStorage - useEffect(() => { - try { - if (activeChatId) sessionStorage.setItem('rf-active-chat-id', activeChatId) - else sessionStorage.removeItem('rf-active-chat-id') - } catch { /* ignore */ } - }, [activeChatId]) - - // Load chat list from ai_sessions - useEffect(() => { - loadChats() - }, []) - - // If URL has a session ID, load it - useEffect(() => { - if (urlSessionId && urlSessionId !== activeChatId) { - selectChat(urlSessionId) - } - }, [urlSessionId]) // eslint-disable-line react-hooks/exhaustive-deps - - // Restore session from sessionStorage on mount (when URL has no session ID) - useEffect(() => { - if (!urlSessionId && activeChatId) { - selectChat(activeChatId) - } - }, []) // eslint-disable-line react-hooks/exhaustive-deps - - // Handle prefill from command palette / dashboard handoff - useEffect(() => { - const state = location.state as { prefill?: string; uploadIds?: string[] } | null - const prefill = state?.prefill - const uploadIds = state?.uploadIds - if (!prefill || prefillHandledRef.current) return - prefillHandledRef.current = true - - navigate(location.pathname, { replace: true, state: {} }) - - const sendPrefill = async () => { - // Clear stale task lane from previous session - setShowTaskLane(false) - setActiveQuestions([]) - setActiveActions([]) - - try { - const session = await aiSessionsApi.createChatSession({ - intake_type: 'free_text', - intake_content: { text: prefill }, - }) - const prefillChatId = session.session_id - currentChatRef.current = prefillChatId - const chatItem: ChatListItem = { - id: prefillChatId, - title: session.title, - message_count: 0, - pinned: false, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - } - setChats(prev => [chatItem, ...prev]) - setActiveChatId(prefillChatId) - setMessages([{ role: 'user', content: prefill }]) - setLoading(true) - - const response = await aiSessionsApi.sendChatMessage(prefillChatId, { - message: prefill, - upload_ids: uploadIds?.length ? uploadIds : undefined, - }) - // Guard: discard if user switched sessions during the API call - if (currentChatRef.current !== prefillChatId) return - setMessages(prev => [ - ...prev, - { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions }, - ]) - setChats(prev => - prev.map(c => - c.id === prefillChatId - ? { ...c, message_count: 2, title: prefill.slice(0, 100), updated_at: new Date().toISOString() } - : c - ) - ) - // Show task lane if AI sent questions or actions - if (response.fork && prefillChatId) { - branching.loadBranches(prefillChatId) - } - const hasQuestions = response.questions && response.questions.length > 0 - const hasActions = response.actions && response.actions.length > 0 - if (hasQuestions || hasActions) { - setActiveQuestions(response.questions || []) - setActiveActions(response.actions || []) - setShowTaskLane(true) - } - } catch { - toast.error('Failed to start AI conversation') - } finally { - setLoading(false) - } - } - - sendPrefill() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // Persist task lane metadata to sessionStorage - useEffect(() => { - try { - sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({ - show: showTaskLane, - chatId: activeChatId, - questions: activeQuestions, - actions: activeActions, - })) - } catch { /* ignore */ } - }, [showTaskLane, activeChatId, activeQuestions, activeActions]) - - // Auto-scroll - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) - - const loadChats = async () => { - try { - const sessions = await aiSessionsApi.listSessions({ session_type: 'chat', limit: 100 }) - setChats(sessions.map(s => ({ - id: s.id, - title: s.title || s.problem_summary || 'New Chat', - message_count: s.step_count, - pinned: false, - created_at: s.created_at, - updated_at: s.created_at, - }))) - } catch { - // silently handle - } - } - - const selectChat = useCallback(async (chatId: string) => { - currentChatRef.current = chatId - setActiveChatId(chatId) - // Clear TaskLane and triage when switching chats — will restore from backend - setShowTaskLane(false) - setActiveQuestions([]) - setActiveActions([]) - setTriageMeta({ client_name: null, asset_name: null, issue_category: null, triage_hypothesis: null, evidence_items: [] }) - try { - const detail = await aiSessionsApi.getSession(chatId) - // Guard: if the user switched to a different chat while this API call was - // in flight (e.g. clicked "New Chat"), discard stale results so we don't - // clobber the new session's task lane state. - if (currentChatRef.current !== chatId) return - setMessages( - (detail.conversation_messages || []).map(m => ({ - role: m.role as 'user' | 'assistant', - content: m.content, - })) - ) - // Restore triage metadata from session - 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 ?? [], - }) - // Restore task lane from persisted state - if (detail.pending_task_lane) { - const q = detail.pending_task_lane.questions || [] - const a = detail.pending_task_lane.actions || [] - if (q.length > 0 || a.length > 0) { - // Pre-load user's saved responses into sessionStorage BEFORE setting props - // so TaskLane can restore them on mount/prop-change - const responses = (detail.pending_task_lane as Record).responses as unknown[] | undefined - if (responses && responses.length > 0) { - try { - sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses)) - } catch { /* ignore */ } - } - setActiveQuestions(q) - setActiveActions(a) - setShowTaskLane(true) - } - } - } catch { - setMessages([]) - } - }, []) - - const handleNewChat = async () => { - try { - const session = await aiSessionsApi.createChatSession({ - intake_type: 'free_text', - intake_content: { text: '' }, - }) - const chatItem: ChatListItem = { - id: session.session_id, - title: session.title, - message_count: 0, - pinned: false, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - } - currentChatRef.current = session.session_id - setChats(prev => [chatItem, ...prev]) - setActiveChatId(session.session_id) - setMessages([]) - // Clear TaskLane from previous session - setShowTaskLane(false) - setActiveQuestions([]) - setActiveActions([]) - } catch { - toast.error('Failed to create chat') - } - } - - const handleDeleteChat = async (chatId: string) => { - try { - await aiSessionsApi.deleteSession(chatId) - setChats(prev => prev.filter(c => c.id !== chatId)) - if (activeChatId === chatId) { - setActiveChatId(null) - setMessages([]) - } - } catch { - toast.error('Failed to delete chat') - } - } - - const handleTogglePin = async () => { - // Pin/unpin not yet supported on unified sessions — no-op for now - toast.info('Pin feature coming soon') - } - - const handleSend = async () => { - if (!input.trim() || !activeChatId || loading) return - - const sendChatId = activeChatId - const userMessage = input.trim() - const completedUploadIds = pendingUploads - .filter((u) => u.status === 'done' && u.result?.id) - .map((u) => u.result!.id) - setInput('') - setPendingUploads([]) - if (showOnboarding) dismissOnboarding() - setMessages(prev => [...prev, { role: 'user', content: userMessage }]) - setLoading(true) - - try { - const response = await aiSessionsApi.sendChatMessage(sendChatId, { - message: userMessage, - upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined, - }) - // Guard: if the user switched sessions while the API call was in flight, - // discard stale results to prevent overwriting the new session's state - if (currentChatRef.current !== sendChatId) return - analytics.aiFeatureUsed({ feature: 'assistant_chat' }) - setMessages(prev => [ - ...prev, - { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions }, - ]) - setChats(prev => - prev.map(c => - c.id === sendChatId - ? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() } - : c - ) - ) - // Load branches if fork was created - if (response.fork && sendChatId) { - branching.loadBranches(sendChatId) - } - // Show task lane if AI sent questions or actions - const hasQuestions = response.questions && response.questions.length > 0 - const hasActions = response.actions && response.actions.length > 0 - if (hasQuestions || hasActions) { - setActiveQuestions(response.questions || []) - setActiveActions(response.actions || []) - setShowTaskLane(true) - } - // Merge triage update from AI - if (response.triage_update) mergeTriageUpdate(response.triage_update) - } catch { - if (currentChatRef.current !== sendChatId) return - setMessages(prev => [ - ...prev, - { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' }, - ]) - } finally { - if (currentChatRef.current === sendChatId) { - setLoading(false) - requestAnimationFrame(() => inputRef.current?.focus()) - } - } - } - - const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise => { - if (!activeChatId) throw new Error('No active chat') - - if (outcome === 'resolved') { - await aiSessionsApi.resolveSession(activeChatId, { - resolution_summary: _notes || 'Resolved via assistant chat', - }) - return activeChatId - } else if (outcome === 'escalated') { - await aiSessionsApi.escalateSession(activeChatId, { - escalation_reason: _notes || 'Escalated from assistant chat', - }) - return activeChatId - } else { - await aiSessionsApi.pauseSession(activeChatId) - return activeChatId - } - } - - const handleResumeNew = async (summary: string) => { - try { - const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.` - const session = await aiSessionsApi.createChatSession({ - intake_type: 'free_text', - intake_content: { text: resumePrompt }, - }) - const chatItem: ChatListItem = { - id: session.session_id, - title: session.title, - message_count: 0, - pinned: false, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - } - currentChatRef.current = session.session_id - setChats(prev => [chatItem, ...prev]) - setActiveChatId(session.session_id) - setMessages([{ role: 'user', content: resumePrompt }]) - setLoading(true) - - const resumeChatId = session.session_id - const response = await aiSessionsApi.sendChatMessage(resumeChatId, { message: resumePrompt }) - // Guard: discard if user switched sessions during the API call - if (currentChatRef.current !== resumeChatId) return - setMessages(prev => [ - ...prev, - { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions }, - ]) - setChats(prev => - prev.map(c => - c.id === resumeChatId - ? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() } - : c - ) - ) - // Show task lane if AI sent questions or actions - if (response.fork && resumeChatId) { - branching.loadBranches(resumeChatId) - } - const hasQuestions = response.questions && response.questions.length > 0 - const hasActions = response.actions && response.actions.length > 0 - if (hasQuestions || hasActions) { - setActiveQuestions(response.questions || []) - setActiveActions(response.actions || []) - setShowTaskLane(true) - } - } catch { - toast.error('Failed to create resume chat') - } finally { - setLoading(false) - } - } - - // ── Triage handlers ── - - const handleTriageFieldSave = useCallback(async (field: keyof TriageMeta, value: string) => { - if (!activeChatId) return - try { - await aiSessionsApi.updateTriage(activeChatId, { [field]: value }) - setTriageMeta(prev => ({ ...prev, [field]: value })) - } catch { - toast.error('Failed to save field') - } - }, [activeChatId]) - - const handleEvidenceAdd = useCallback(async (text: string, status: EvidenceItem['status']) => { - const newItem: EvidenceItem = { text, status } - const updated = [...triageMeta.evidence_items, newItem] - setTriageMeta(prev => ({ ...prev, evidence_items: updated })) - if (activeChatId) { - try { await aiSessionsApi.updateTriage(activeChatId, { evidence_items: updated }) } catch { /* best-effort */ } - } - }, [activeChatId, triageMeta.evidence_items]) - - const handleEvidenceEdit = useCallback(async (index: number, text: string, status: EvidenceItem['status']) => { - const updated = triageMeta.evidence_items.map((item, i) => i === index ? { text, status } : item) - setTriageMeta(prev => ({ ...prev, evidence_items: updated })) - if (activeChatId) { - try { await aiSessionsApi.updateTriage(activeChatId, { evidence_items: updated }) } catch { /* best-effort */ } - } - }, [activeChatId, triageMeta.evidence_items]) - - const handleStepComplete = useCallback((index: number) => { - setCompletedSteps(prev => { - const next = new Set(prev) - next.add(index) - return next - }) - // Auto-advance to the next incomplete step - const nextIncomplete = activeActions.findIndex((_, i) => i > index && !completedSteps.has(i)) - if (nextIncomplete !== -1) { - setActiveStepIndex(nextIncomplete) - } else if (index + 1 < activeActions.length) { - setActiveStepIndex(index + 1) - } - }, [activeActions, completedSteps]) - - 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) - }, []) - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSend() - } - } - - // Auto-grow textarea - useEffect(() => { - const el = inputRef.current - if (!el) return - el.style.height = 'auto' - el.style.height = `${Math.min(el.scrollHeight, 150)}px` - }, [input]) - - // ── File handling ────────────────────────────── - const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx' - - const processFiles = useCallback((files: File[]) => { - if (files.length === 0) return - const newUploads: PendingUpload[] = files.map((file) => ({ - id: crypto.randomUUID(), - file, - preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : '', - status: 'uploading' as const, - })) - setPendingUploads((prev) => [...prev, ...newUploads]) - newUploads.forEach((upload) => { - uploadsApi.upload(upload.file) - .then((result) => { - setPendingUploads((prev) => prev.map((u) => u.id === upload.id ? { ...u, status: 'done' as const, result } : u)) - }) - .catch((err) => { - const is503 = err?.response?.status === 503 - if (is503) { - toast.warning('Image attachments are not available yet — describe the issue in text instead') - } else { - toast.error(`Upload failed: ${err?.message || 'Unknown error'}`) - } - setPendingUploads((prev) => prev.filter((u) => u.id !== upload.id)) - }) - }) - }, []) - - const handlePaste = useCallback((e: React.ClipboardEvent) => { - const items = e.clipboardData?.items - if (!items) return - const imageFiles: File[] = [] - for (let i = 0; i < items.length; i++) { - if (items[i].type.startsWith('image/')) { - const file = items[i].getAsFile() - if (file) imageFiles.push(file) - } - } - if (imageFiles.length > 0) { - e.preventDefault() - processFiles(imageFiles) - } - }, [processFiles]) - - const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }, []) - const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current++; if (dragCounterRef.current === 1) setIsDragOver(true) }, []) - const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current--; if (dragCounterRef.current === 0) setIsDragOver(false) }, []) - const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current = 0; setIsDragOver(false); processFiles(Array.from(e.dataTransfer.files)) }, [processFiles]) - const handleFileSelect = useCallback((e: React.ChangeEvent) => { if (e.target.files) { processFiles(Array.from(e.target.files)); e.target.value = '' } }, [processFiles]) - const handleRemoveUpload = useCallback((uploadId: string) => { - setPendingUploads((prev) => { const toRemove = prev.find((u) => u.id === uploadId); if (toRemove?.preview) URL.revokeObjectURL(toRemove.preview); return prev.filter((u) => u.id !== uploadId) }) - }, []) - const retryUpload = useCallback((uploadId: string) => { - const upload = pendingUploads.find((u) => u.id === uploadId) - if (!upload) return - setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'uploading' as const, error: undefined } : u)) - uploadsApi.upload(upload.file) - .then((result) => { setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'done' as const, result } : u)) }) - .catch((err) => { setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'error' as const, error: err?.message || 'Upload failed' } : u)) }) - }, [pendingUploads]) - - // Cleanup blob URLs on unmount - useEffect(() => { return () => { pendingUploads.forEach((u) => { if (u.preview) URL.revokeObjectURL(u.preview) }) } }, []) // eslint-disable-line react-hooks/exhaustive-deps - - return ( - <> - -
- {/* Sidebar — hidden on mobile, collapsed to top bar or full sidebar on desktop */} - {!sidebarCollapsed && ( -
- -
- )} -
- setMobileSidebarOpen(false)} - /> -
- - {/* Main chat area + optional branch sidebar */} -
- - {/* Collapsed sidebar top bar — desktop only */} - {sidebarCollapsed && ( -
- -
- )} - - {/* Cockpit content area */} -
- {/* Mobile header with case history toggle */} -
- -
- -
- - {activeChatId ? ( - <> - {/* Incident Header */} - 0 ? null : null} - sessionId={activeChatId} - onFieldSave={handleTriageFieldSave} - onResolve={() => setShowConclude(true)} - onClose={() => setShowConclude(true)} - /> - - {/* Resizable work zone + conversation log split */} -
- {/* First-run onboarding overlay */} - {showOnboarding && 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 */} -
- -
- {/* Right: FlowPilot Asks + What We Know */} -
- { - setInput(answer) - setTimeout(() => handleSend(), 10) - }} - loading={loading} - /> - -
-
- - {/* Drag handle */} -
- -
- - {/* Conversation log */} -
-
- Conversation Log -
- {messages.length === 0 && !loading && ( -
- -

- Start a new case to begin troubleshooting -

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