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:
chihlasm
2026-04-02 17:20:34 +00:00
parent 2ed02607a8
commit 4cc6ee4797

View 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&apos;ll also suggest relevant flows from your team&apos;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 &amp; Network Engineer. Ask anything about IT infrastructure,
or start a new chat to get personalized help with your team&apos;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>
</>
)
}