feat: create FlowPilotPage with classic chat layout
Recreates the production AssistantChatPage as FlowPilotPage using the shared useAssistantSession hook. Classic chat interface with ChatMessage bubbles, TaskLane side panel, rich input with file uploads, and conclude/status update modals. ViewToggle commented out pending Task 4. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
368
frontend/src/pages/FlowPilotPage.tsx
Normal file
368
frontend/src/pages/FlowPilotPage.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText } 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 { ChatMessage } from '@/components/assistant/ChatMessage'
|
||||
import { TaskLane } from '@/components/assistant/TaskLane'
|
||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
// TODO: uncomment after Task 4
|
||||
// import { ViewToggle } from '@/components/assistant/ViewToggle'
|
||||
import { useAssistantSession } from '@/hooks/useAssistantSession'
|
||||
|
||||
export default function FlowPilotPage() {
|
||||
const session = useAssistantSession()
|
||||
|
||||
// Handle prefill from dashboard / command palette
|
||||
useEffect(() => {
|
||||
session.handlePrefill('/assistant')
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
|
||||
if (!session.activeChatId || session.loading) return
|
||||
|
||||
const parts: string[] = []
|
||||
for (const r of responses) {
|
||||
const name = r.type === 'question' ? `Q: ${r.text}` : r.label || 'Check'
|
||||
if (r.state === 'done' && r.value.trim()) {
|
||||
parts.push(`**${name}:**\n\`\`\`\n${r.value.trim()}\n\`\`\``)
|
||||
} else if (r.state === 'skipped') {
|
||||
parts.push(`**${name}:** _(skipped)_`)
|
||||
}
|
||||
}
|
||||
const userMessage = parts.join('\n\n')
|
||||
const sendChatId = session.activeChatId
|
||||
|
||||
session.setInput('')
|
||||
session.setShowTaskLane(false)
|
||||
session.setActiveQuestions([])
|
||||
session.setActiveActions([])
|
||||
|
||||
try {
|
||||
const response = await aiSessionsApi.sendChatMessage(sendChatId, { message: userMessage })
|
||||
if (session.currentChatRef.current !== sendChatId) return
|
||||
session.processResponse(response, sendChatId)
|
||||
|
||||
const hasQuestions = response.questions && response.questions.length > 0
|
||||
const hasActions = response.actions && response.actions.length > 0
|
||||
if (!hasQuestions && !hasActions) {
|
||||
session.setShowTaskLane(false)
|
||||
session.setActiveQuestions([])
|
||||
session.setActiveActions([])
|
||||
}
|
||||
} catch {
|
||||
// Error handled by processResponse guard
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="FlowPilot" />
|
||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||
{/* Chat Sidebar — 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>
|
||||
)}
|
||||
{/* Chat Sidebar — mobile */}
|
||||
<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 area */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Collapsed sidebar bar */}
|
||||
{session.sidebarCollapsed && (
|
||||
<div className="hidden sm:block">
|
||||
<ChatSidebarCollapsedBar
|
||||
chats={session.chats}
|
||||
activeChatId={session.activeChatId}
|
||||
onNewChat={session.handleNewChat}
|
||||
onExpand={session.toggleSidebarCollapse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat content row */}
|
||||
<div className="flex-1 flex min-w-0 min-h-0">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Mobile header */}
|
||||
<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} />
|
||||
Chats
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{/* TODO: uncomment after Task 4 */}
|
||||
{/* {session.activeChatId && (
|
||||
<ViewToggle currentView="flowpilot" sessionId={session.activeChatId} />
|
||||
)} */}
|
||||
<button
|
||||
onClick={session.handleNewChat}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{session.activeChatId ? (
|
||||
<>
|
||||
{/* Desktop view toggle bar */}
|
||||
<div className="hidden sm:flex items-center justify-end px-4 py-1.5 border-b border-border/50">
|
||||
{/* TODO: uncomment after Task 4 */}
|
||||
{/* <ViewToggle currentView="flowpilot" sessionId={session.activeChatId} /> */}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4">
|
||||
{session.messages.length === 0 && !session.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">
|
||||
FlowPilot
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
{session.messages.map((msg, i) => (
|
||||
<ChatMessage
|
||||
key={i}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
suggestedFlows={msg.suggestedFlows}
|
||||
/>
|
||||
))}
|
||||
{session.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>
|
||||
</div>
|
||||
)}
|
||||
<div ref={session.messagesEndRef} />
|
||||
</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)' }}>
|
||||
{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
|
||||
ref={session.inputRef}
|
||||
value={session.input}
|
||||
onChange={e => session.setInput(e.target.value)}
|
||||
onKeyDown={session.handleKeyDown}
|
||||
onPaste={session.handlePaste}
|
||||
placeholder={session.loading ? 'AI is thinking...' : 'Type a message, paste a screenshot, or drag a file...'}
|
||||
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' }}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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="Conclude session">
|
||||
<Flag size={14} />
|
||||
<span className="hidden sm:inline">Conclude</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 Senior Systems & Network Engineer. Ask anything about IT infrastructure,
|
||||
or start a new chat to get personalized help with your team's flows.
|
||||
</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"
|
||||
>
|
||||
Start a Conversation
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task lane */}
|
||||
{session.showTaskLane && (session.activeQuestions.length > 0 || session.activeActions.length > 0) && (
|
||||
<TaskLane
|
||||
questions={session.activeQuestions}
|
||||
actions={session.activeActions}
|
||||
sessionId={session.activeChatId}
|
||||
onSubmit={handleTaskSubmit}
|
||||
onClose={() => session.setShowTaskLane(false)}
|
||||
loading={session.loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conclude 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user