feat: refactor AssistantChatPage into cockpit layout (Phase 5)

- Stacked zone layout: incident header → work zone → drag handle →
  conversation log → compose
- IncidentHeader wired with triageMeta state and field save handlers
- Work zone: StepsPanel (left) + FlowPilotAsks + WhatWeKnow (right)
- Drag-resizable split with localStorage persistence
- Compact conversation log with you:/fp: prefixes
- Triage state populated on session load/resume
- AI triage_update merged into header via mergeTriageUpdate()
- MSP-native language: FlowPilot, New Case, Close Case

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-01 22:51:29 +00:00
parent 23a7cee1f5
commit 6ee6faa712

View File

@@ -1,10 +1,10 @@
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 } from 'lucide-react'
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 } from '@/types/ai-session'
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'
@@ -15,6 +15,10 @@ 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'
@@ -71,6 +75,16 @@ export default function AssistantChatPage() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
localStorage.getItem('rf-chat-sidebar-collapsed') === 'true'
)
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 splitContainerRef = useRef<HTMLDivElement>(null)
const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed
setSidebarCollapsed(next)
@@ -219,10 +233,11 @@ export default function AssistantChatPage() {
const selectChat = useCallback(async (chatId: string) => {
currentChatRef.current = chatId
setActiveChatId(chatId)
// Clear TaskLane when switching chats — will restore from backend if available
// 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
@@ -235,6 +250,14 @@ export default function AssistantChatPage() {
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 || []
@@ -344,6 +367,8 @@ export default function AssistantChatPage() {
setActiveActions(response.actions || [])
setShowTaskLane(true)
}
// Merge triage update from AI
if (response.triage_update) mergeTriageUpdate(response.triage_update)
} catch {
setMessages(prev => [
...prev,
@@ -475,6 +500,81 @@ export default function AssistantChatPage() {
}
}
// ── 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 handleFlowPilotAnswer = useCallback((answer: string) => {
setInput(answer)
// Trigger send after a tick so the input state is set
setTimeout(() => {
const event = new KeyboardEvent('keydown', { key: 'Enter' })
inputRef.current?.dispatchEvent(event)
}, 0)
}, [])
// 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()
@@ -557,7 +657,7 @@ export default function AssistantChatPage() {
return (
<>
<PageMeta title="AI Assistant" />
<PageMeta title="FlowPilot" />
<div className="flex h-[calc(100vh-3.5rem)]">
{/* Sidebar — hidden on mobile, collapsed to top bar or full sidebar on desktop */}
{!sidebarCollapsed && (
@@ -601,64 +701,113 @@ export default function AssistantChatPage() {
</div>
)}
{/* Chat content row: chat column + TaskLane side by side */}
<div className="flex-1 flex min-w-0 min-h-0">
<div className="flex-1 flex flex-col min-w-0">
{/* Mobile header with chat history toggle */}
{/* 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={() => 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} />
Chats
Cases
</button>
<div className="flex-1" />
<button
onClick={handleNewChat}
className="rounded-lg px-3 py-2 text-sm font-medium text-primary hover:bg-primary/10 transition-colors"
>
+ New
+ New Case
</button>
</div>
{activeChatId ? (
<>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4">
{messages.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-16 h-16 rounded-full bg-accent-dim flex items-center justify-center mb-4">
<Sparkles size={28} className="text-primary" />
</div>
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">
AI Assistant
</h2>
<p className="text-sm text-muted-foreground max-w-md">
Ask me anything about IT infrastructure, networking, Active Directory,
cloud platforms, or troubleshooting. I'll also suggest relevant flows from your team's library.
</p>
{/* Incident Header */}
<IncidentHeader
triageMeta={triageMeta}
psaTicketId={messages.length > 0 ? null : null}
sessionId={activeChatId}
onFieldSave={handleTriageFieldSave}
onResolve={() => setShowConclude(true)}
onOverflow={() => {}}
/>
{/* Resizable work zone + conversation log split */}
<div ref={splitContainerRef} className="flex-1 flex flex-col min-h-0">
{/* 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={activeActions}
activeIndex={activeStepIndex}
/>
</div>
)}
{messages.map((msg, i) => (
<ChatMessage
key={i}
role={msg.role}
content={msg.content}
suggestedFlows={msg.suggestedFlows}
/>
))}
{loading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
<Sparkles size={14} className="text-primary" />
</div>
<div className="bg-input border border-border rounded-2xl px-4 py-3">
<Loader2 size={16} className="animate-spin text-primary" />
</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={activeQuestions}
onAnswer={(answer) => {
setInput(answer)
setTimeout(() => handleSend(), 10)
}}
loading={loading}
/>
<WhatWeKnow
items={triageMeta.evidence_items}
onAdd={handleEvidenceAdd}
onEdit={handleEvidenceEdit}
/>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Drag handle */}
<div
onMouseDown={handleDragStart}
className="h-2 flex items-center justify-center cursor-row-resize hover:bg-elevated/50 transition-colors flex-shrink-0 border-y border-default/50"
>
<GripHorizontal size={14} className="text-muted" />
</div>
{/* Conversation log */}
<div className="flex-1 min-h-0 overflow-y-auto" style={{ background: '#13151c' }}>
<div className="px-4 pt-2 pb-1">
<span className="text-[10px] uppercase tracking-wider text-muted font-semibold">Conversation Log</span>
</div>
{messages.length === 0 && !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">
{messages.map((msg, i) => (
<div key={i} className="flex gap-2 text-xs leading-relaxed">
<span className={cn(
'font-mono flex-shrink-0 mt-px',
msg.role === 'user' ? 'text-muted' : 'text-accent/50'
)}>
{msg.role === 'user' ? 'you:' : 'fp:'}
</span>
<span className={cn(
msg.role === 'user' ? 'text-muted-foreground/70' : 'text-muted-foreground'
)}>
{msg.content.length > 300 ? msg.content.slice(0, 300) + '…' : msg.content}
</span>
</div>
))}
{loading && (
<div className="flex gap-2 text-xs">
<span className="font-mono text-accent/50">fp:</span>
<Loader2 size={12} className="animate-spin text-muted-foreground" />
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
</div>
{/* Rich Input */}
@@ -693,7 +842,7 @@ export default function AssistantChatPage() {
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={loading ? 'AI is thinking...' : 'Type a message, paste a screenshot, or drag a file...'}
placeholder={loading ? 'FlowPilot is working...' : 'Describe finding, paste log output, or ask FlowPilot...'}
disabled={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"
@@ -769,9 +918,9 @@ export default function AssistantChatPage() {
<FileText size={14} />
<span className="hidden sm:inline">Update</span>
</button>
<button type="button" onClick={() => setShowConclude(true)} disabled={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="Conclude session">
<button type="button" onClick={() => setShowConclude(true)} disabled={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">Conclude</span>
<span className="hidden sm:inline">Close Case</span>
</button>
</>
)}
@@ -804,43 +953,25 @@ export default function AssistantChatPage() {
<Sparkles size={32} className="text-primary" />
</div>
<h2 className="text-xl font-heading font-semibold text-foreground mb-2">
AI Assistant
FlowPilot
</h2>
<p className="text-sm text-muted-foreground max-w-md mb-6">
Your Senior Systems & Network Engineer. Ask anything about IT infrastructure,
or start a new chat to get personalized help with your team's flows.
Your MSP troubleshooting copilot. Start a new case to begin
diagnosing with structured steps, evidence tracking, and AI guidance.
</p>
<button
onClick={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"
>
Start a Conversation
New Case
</button>
</div>
)}
</div>
{/* Task lane — slides in when AI sends questions or actions */}
{showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
<TaskLane
questions={activeQuestions}
actions={activeActions}
sessionId={activeChatId}
onSubmit={handleTaskSubmit}
onClose={() => {
setShowTaskLane(false)
}}
loading={loading}
/>
)}
</div>{/* close cockpit content area */}
{/* Branch map hidden — branching is now silent/background only.
Branches are tracked in the DB but not shown to the user.
The AI manages branch context internally. */}
</div>{/* close chat content row */}
</div>{/* close outer flex-col */}
{/* Conclude Session Modal */}
{/* Close Case Session Modal */}
<ConcludeSessionModal
isOpen={showConclude}
onClose={() => setShowConclude(false)}