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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user