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) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ const PREFETCH_MAP: Record<string, () => Promise<unknown>> = {
|
||||
'/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'),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
560
frontend/src/pages/CockpitPage.tsx
Normal file
560
frontend/src/pages/CockpitPage.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useNavigate } 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 { 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'
|
||||
// TODO: uncomment after Task 4
|
||||
// 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<TriageMeta>({
|
||||
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<Set<number>>(new Set())
|
||||
const [showOnboarding, setShowOnboarding] = useState(() =>
|
||||
!localStorage.getItem('rf-cockpit-onboarded')
|
||||
)
|
||||
const splitContainerRef = useRef<HTMLDivElement>(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[]) ?? [],
|
||||
})
|
||||
}
|
||||
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])
|
||||
|
||||
// ── 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 }
|
||||
const updated = [...triageMeta.evidence_items, newItem]
|
||||
setTriageMeta(prev => ({ ...prev, evidence_items: updated }))
|
||||
if (session.activeChatId) {
|
||||
try { await aiSessionsApi.updateTriage(session.activeChatId, { evidence_items: updated }) } catch { /* best-effort */ }
|
||||
}
|
||||
}, [session.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 (session.activeChatId) {
|
||||
try { await aiSessionsApi.updateTriage(session.activeChatId, { evidence_items: updated }) } catch { /* best-effort */ }
|
||||
}
|
||||
}, [session.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 = session.activeActions.findIndex((_, i) => i > index && !completedSteps.has(i))
|
||||
if (nextIncomplete !== -1) {
|
||||
setActiveStepIndex(nextIncomplete)
|
||||
} else if (index + 1 < session.activeActions.length) {
|
||||
setActiveStepIndex(index + 1)
|
||||
}
|
||||
}, [session.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)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="FlowPilot" />
|
||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||
{/* Sidebar — hidden on mobile, collapsed to top bar or full sidebar on desktop */}
|
||||
{!session.sidebarCollapsed && (
|
||||
<div className="hidden sm:block">
|
||||
<ChatSidebar
|
||||
chats={session.chats}
|
||||
activeChatId={session.activeChatId}
|
||||
onSelectChat={session.selectChat}
|
||||
onNewChat={session.handleNewChat}
|
||||
onDeleteChat={session.handleDeleteChat}
|
||||
onTogglePin={session.handleTogglePin}
|
||||
onToggleCollapse={session.toggleSidebarCollapse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:hidden">
|
||||
<ChatSidebar
|
||||
chats={session.chats}
|
||||
activeChatId={session.activeChatId}
|
||||
onSelectChat={session.selectChat}
|
||||
onNewChat={session.handleNewChat}
|
||||
onDeleteChat={session.handleDeleteChat}
|
||||
onTogglePin={session.handleTogglePin}
|
||||
mobileOpen={session.mobileSidebarOpen}
|
||||
onMobileClose={() => session.setMobileSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main chat area + optional branch sidebar */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
|
||||
{/* Collapsed sidebar top bar — desktop only */}
|
||||
{session.sidebarCollapsed && (
|
||||
<div className="hidden sm:block">
|
||||
<ChatSidebarCollapsedBar
|
||||
chats={session.chats}
|
||||
activeChatId={session.activeChatId}
|
||||
onNewChat={session.handleNewChat}
|
||||
onExpand={session.toggleSidebarCollapse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cockpit content area */}
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0">
|
||||
{/* Mobile header with case history toggle */}
|
||||
<div className="sm:hidden flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||
<button
|
||||
onClick={() => session.setMobileSidebarOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
Cases
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={session.handleNewChat}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
+ New Case
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{session.activeChatId ? (
|
||||
<>
|
||||
{/* Incident Header */}
|
||||
<IncidentHeader
|
||||
triageMeta={triageMeta}
|
||||
psaTicketId={session.messages.length > 0 ? null : null}
|
||||
sessionId={session.activeChatId}
|
||||
onFieldSave={handleTriageFieldSave}
|
||||
onResolve={() => session.setShowConclude(true)}
|
||||
onClose={() => session.setShowConclude(true)}
|
||||
/>
|
||||
{/* TODO: uncomment after Task 4 */}
|
||||
{/* <ViewToggle currentView="cockpit" sessionId={session.activeChatId} /> */}
|
||||
|
||||
{/* Resizable work zone + conversation log split */}
|
||||
<div ref={splitContainerRef} className="flex-1 flex flex-col min-h-0 relative">
|
||||
{/* First-run onboarding overlay */}
|
||||
{showOnboarding && session.messages.length === 0 && (
|
||||
<div className="absolute inset-0 z-30 pointer-events-none">
|
||||
{/* Steps zone label */}
|
||||
<div className="absolute top-3 left-3 pointer-events-auto">
|
||||
<div className="bg-elevated border border-accent/30 rounded-lg px-3 py-2 shadow-lg max-w-[200px]">
|
||||
<p className="text-xs font-medium text-accent mb-0.5">Steps Panel</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">Troubleshooting steps appear here as FlowPilot identifies them. Click to mark done.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* FlowPilot Asks zone label */}
|
||||
<div className="absolute top-3 right-3 pointer-events-auto">
|
||||
<div className="bg-elevated border border-warning/30 rounded-lg px-3 py-2 shadow-lg max-w-[200px]">
|
||||
<p className="text-xs font-medium text-warning mb-0.5">AI Questions & Evidence</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">FlowPilot asks clarifying questions here. Evidence you confirm or rule out is tracked below.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Conversation log label */}
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 pointer-events-auto">
|
||||
<div className="bg-elevated border border-default rounded-lg px-3 py-2 shadow-lg max-w-[280px] text-center">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-0.5">Conversation Log</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">Full chat history lives here. Drag the handle above to resize.</p>
|
||||
<button
|
||||
onClick={dismissOnboarding}
|
||||
className="mt-2 text-[11px] text-accent hover:text-accent/80 transition-colors pointer-events-auto"
|
||||
>
|
||||
Got it, dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Work zone */}
|
||||
<div className="flex min-h-0 overflow-hidden" style={{ height: `${workZonePct}%` }}>
|
||||
{/* Left: Steps panel */}
|
||||
<div className="flex-[55] min-w-0 p-3 overflow-y-auto border-r border-default">
|
||||
<StepsPanel
|
||||
actions={session.activeActions}
|
||||
activeIndex={activeStepIndex}
|
||||
completedSteps={completedSteps}
|
||||
onStepComplete={handleStepComplete}
|
||||
onStepSelect={handleStepSelect}
|
||||
/>
|
||||
</div>
|
||||
{/* Right: FlowPilot Asks + What We Know */}
|
||||
<div className="flex-[45] min-w-0 p-3 overflow-y-auto flex flex-col gap-3">
|
||||
<FlowPilotAsks
|
||||
questions={session.activeQuestions}
|
||||
onAnswer={(answer) => {
|
||||
session.setInput(answer)
|
||||
setTimeout(() => session.handleSend(), 10)
|
||||
}}
|
||||
loading={session.loading}
|
||||
/>
|
||||
<WhatWeKnow
|
||||
items={triageMeta.evidence_items}
|
||||
onAdd={handleEvidenceAdd}
|
||||
onEdit={handleEvidenceEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
onMouseDown={handleDragStart}
|
||||
className="h-4 flex items-center justify-center cursor-row-resize hover:bg-elevated transition-colors flex-shrink-0 border-y border-default/50 group"
|
||||
title="Drag to resize"
|
||||
>
|
||||
<GripHorizontal size={16} className="text-muted group-hover:text-muted-foreground transition-colors" />
|
||||
</div>
|
||||
|
||||
{/* Conversation log */}
|
||||
<div className="flex-1 min-h-[180px] overflow-y-auto bg-sidebar">
|
||||
<div className="px-4 pt-2 pb-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted font-semibold">Conversation Log</span>
|
||||
</div>
|
||||
{session.messages.length === 0 && !session.loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-4 py-8">
|
||||
<Sparkles size={24} className="text-muted mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Start a new case to begin troubleshooting
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 pb-2 space-y-1.5">
|
||||
{session.messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex gap-3 text-sm leading-relaxed rounded-md px-3 py-2 border-l-2',
|
||||
msg.role === 'user'
|
||||
? 'border-l-muted-foreground/30 bg-white/[0.02]'
|
||||
: 'border-l-accent/40 bg-accent/[0.03]'
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'text-xs font-medium flex-shrink-0 mt-0.5 min-w-[58px]',
|
||||
msg.role === 'user' ? 'text-muted-foreground' : 'text-accent/70'
|
||||
)}>
|
||||
{msg.role === 'user' ? 'You' : 'FlowPilot'}
|
||||
</span>
|
||||
<span className={cn(
|
||||
msg.role === 'user' ? 'text-muted-foreground/80' : 'text-primary/80'
|
||||
)}>
|
||||
{msg.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{session.loading && (
|
||||
<div className="flex gap-3 text-sm px-3 py-2 border-l-2 border-l-accent/40 bg-accent/[0.03] rounded-md">
|
||||
<span className="text-xs font-medium text-accent/70 min-w-[58px]">FlowPilot</span>
|
||||
<Loader2 size={14} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div ref={session.messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rich Input */}
|
||||
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||||
<div
|
||||
className="max-w-3xl mx-auto"
|
||||
onDragOver={session.handleDragOver}
|
||||
onDragEnter={session.handleDragEnter}
|
||||
onDragLeave={session.handleDragLeave}
|
||||
onDrop={session.handleDrop}
|
||||
>
|
||||
<div className={cn(
|
||||
'relative rounded-xl border transition-all',
|
||||
session.loading ? 'border-border/50 opacity-50' :
|
||||
session.isDragOver ? 'border-primary/50 bg-primary/5' :
|
||||
'border-border focus-within:border-[rgba(96,165,250,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
|
||||
)} style={{ background: 'var(--color-bg-card)' }}>
|
||||
{/* Drag overlay */}
|
||||
{session.isDragOver && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-primary/50 bg-primary/5 pointer-events-none">
|
||||
<div className="flex items-center gap-2 text-sm text-primary">
|
||||
<ImagePlus size={18} />
|
||||
Drop files to attach
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={session.inputRef}
|
||||
value={session.input}
|
||||
onChange={e => session.setInput(e.target.value)}
|
||||
onKeyDown={session.handleKeyDown}
|
||||
onPaste={session.handlePaste}
|
||||
placeholder={session.loading ? 'FlowPilot is working...' : 'Describe the issue or ask FlowPilot...'}
|
||||
disabled={session.loading}
|
||||
rows={1}
|
||||
className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed"
|
||||
style={{ minHeight: '40px', maxHeight: '150px' }}
|
||||
/>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{session.pendingUploads.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap px-4 pb-1">
|
||||
{session.pendingUploads.map((upload) => (
|
||||
<div key={upload.id} className="relative w-12 h-12 rounded-lg overflow-hidden border border-border bg-background">
|
||||
{upload.preview ? (
|
||||
<img src={upload.preview} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-[0.5rem] text-muted-foreground px-1 text-center">
|
||||
{upload.file.name.split('.').pop()?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{upload.status === 'uploading' && (
|
||||
<div className="absolute inset-0 bg-background/60 flex items-center justify-center">
|
||||
<Loader2 size={12} className="animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{upload.status === 'done' && (
|
||||
<button type="button" onClick={() => session.handleRemoveUpload(upload.id)} className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-background border border-border flex items-center justify-center hover:bg-rose-500/20 transition-colors">
|
||||
<X size={8} className="text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{upload.status === 'error' && (
|
||||
<div className="absolute inset-0 bg-rose-500/20 border-2 border-rose-500 flex items-center justify-center cursor-pointer" onClick={() => session.retryUpload(upload.id)}>
|
||||
<RotateCcw size={10} className="text-rose-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs textarea */}
|
||||
{session.showLogs && (
|
||||
<div className="px-4 pb-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[0.625rem] uppercase tracking-wide text-muted-foreground font-sans">Paste logs or error output</span>
|
||||
<button type="button" onClick={() => { session.setShowLogs(false); session.setLogContent('') }} className="text-muted-foreground hover:text-foreground"><X size={14} /></button>
|
||||
</div>
|
||||
<textarea
|
||||
value={session.logContent}
|
||||
onChange={(e) => session.setLogContent(e.target.value)}
|
||||
placeholder="Paste event viewer logs, error messages, PowerShell output..."
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border/50">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input ref={session.fileInputRef} type="file" multiple accept={session.ACCEPTED_FILE_TYPES} onChange={session.handleFileSelect} className="hidden" />
|
||||
<button type="button" onClick={() => session.fileInputRef.current?.click()} disabled={session.loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-40" title="Attach files">
|
||||
<Paperclip size={14} />
|
||||
<span className="hidden sm:inline">Attach</span>
|
||||
</button>
|
||||
{!session.showLogs && (
|
||||
<button type="button" onClick={() => session.setShowLogs(true)} disabled={session.loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-40" title="Paste logs">
|
||||
<Terminal size={14} />
|
||||
<span className="hidden sm:inline">Paste Logs</span>
|
||||
</button>
|
||||
)}
|
||||
{session.messages.length >= 2 && (
|
||||
<>
|
||||
<button type="button" onClick={() => session.setShowStatusUpdate(true)} disabled={session.loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-blue-400 hover:bg-blue-500/10 transition-colors disabled:opacity-40" title="Share status update">
|
||||
<FileText size={14} />
|
||||
<span className="hidden sm:inline">Update</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => session.setShowConclude(true)} disabled={session.loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-amber-400 hover:bg-amber-400/10 transition-colors disabled:opacity-40" title="Close case">
|
||||
<Flag size={14} />
|
||||
<span className="hidden sm:inline">Close Case</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!session.showTaskLane && (session.activeQuestions.length > 0 || session.activeActions.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => session.setShowTaskLane(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-accent-text hover:text-foreground hover:bg-accent-dim transition-colors"
|
||||
title="Show task panel"
|
||||
>
|
||||
<ListChecks size={14} />
|
||||
Tasks ({session.activeQuestions.length + session.activeActions.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={session.handleSend} disabled={!session.input.trim() || session.loading} className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-all',
|
||||
session.input.trim() && !session.loading ? 'bg-primary text-white hover:brightness-110 active:scale-95' : 'bg-secondary text-muted-foreground cursor-not-allowed'
|
||||
)} title="Send message">
|
||||
<Send size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-accent-dim flex items-center justify-center mb-4">
|
||||
<Sparkles size={32} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-heading font-semibold text-foreground mb-2">
|
||||
FlowPilot
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||
Your MSP troubleshooting copilot. Start a new case to begin
|
||||
diagnosing with structured steps, evidence tracking, and AI guidance.
|
||||
</p>
|
||||
<button
|
||||
onClick={session.handleNewChat}
|
||||
className="bg-primary text-white font-semibold text-sm rounded-lg px-6 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
New Case
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>{/* close cockpit content area */}
|
||||
|
||||
{/* Close Case Session Modal */}
|
||||
<ConcludeSessionModal
|
||||
isOpen={session.showConclude}
|
||||
onClose={() => session.setShowConclude(false)}
|
||||
onConclude={session.handleConclude}
|
||||
onResumeNew={session.handleResumeNew}
|
||||
chatTitle={session.chats.find(c => c.id === session.activeChatId)?.title ?? 'Chat'}
|
||||
sessionId={session.activeChatId}
|
||||
/>
|
||||
|
||||
{/* Status Update Modal */}
|
||||
{session.activeChatId && (
|
||||
<StatusUpdateModal
|
||||
open={session.showStatusUpdate}
|
||||
onClose={() => session.setShowStatusUpdate(false)}
|
||||
onGenerate={(audience, length, context) =>
|
||||
aiSessionsApi.generateStatusUpdate(session.activeChatId!, { audience, length, context })
|
||||
}
|
||||
context="status"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -47,7 +47,7 @@ const FeedbackPage = lazyWithRetry(() => import('@/pages/FeedbackPage'))
|
||||
const StepLibraryPage = lazyWithRetry(() => import('@/pages/StepLibraryPage'))
|
||||
const ScriptLibraryPage = lazyWithRetry(() => import('@/pages/ScriptLibraryPage'))
|
||||
const ScriptManagePage = lazyWithRetry(() => import('@/pages/ScriptManagePage'))
|
||||
const AssistantChatPage = lazyWithRetry(() => import('@/pages/AssistantChatPage'))
|
||||
const CockpitPage = lazyWithRetry(() => import('@/pages/CockpitPage'))
|
||||
const FlowAssistPage = lazyWithRetry(() => import('@/pages/FlowAssistPage'))
|
||||
const FlowPilotSessionPage = lazyWithRetry(() => import('@/pages/FlowPilotSessionPage'))
|
||||
const EscalationQueuePage = lazyWithRetry(() => import('@/pages/EscalationQueuePage'))
|
||||
@@ -197,8 +197,8 @@ export const router = sentryCreateBrowserRouter([
|
||||
{ path: 'scripts/manage', element: page(ScriptManagePage) },
|
||||
{ path: 'script-builder', element: page(ScriptBuilderPage) },
|
||||
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
||||
{ path: 'assistant', element: page(AssistantChatPage) },
|
||||
{ path: 'assistant/:sessionId', element: page(AssistantChatPage) },
|
||||
{ path: 'assistant', element: page(CockpitPage) },
|
||||
{ path: 'assistant/:sessionId', element: page(CockpitPage) },
|
||||
{ path: 'flow-assist', element: page(FlowAssistPage) },
|
||||
{ path: 'pilot', element: page(FlowPilotSessionPage) },
|
||||
{ path: 'pilot/:sessionId', element: page(FlowPilotSessionPage) },
|
||||
|
||||
Reference in New Issue
Block a user