# FlowPilot / FlowPilot Cockpit Side-by-Side — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Run the production AI chat page (FlowPilot) and the cockpit triage page (FlowPilot Cockpit) side-by-side on separate routes with shared sessions and a view toggle. **Architecture:** Extract shared session logic from the 1000+ line `AssistantChatPage` into a `useAssistantSession` hook. Create two thin page components — `FlowPilotPage` (classic chat + TaskLane) and `CockpitPage` (cockpit layout) — that both consume the hook. Wire up routing, sidebar nav, view toggle, and dashboard launch preference. **Tech Stack:** React 19, TypeScript, Zustand, React Router v7, Tailwind CSS v4, Lucide icons **Design spec:** `docs/superpowers/specs/2026-04-02-flowpilot-cockpit-side-by-side-design.md` --- ## File Structure | File | Responsibility | |------|---------------| | `frontend/src/hooks/useAssistantSession.ts` | **New.** All shared session logic: CRUD, chat messaging, file uploads, branching, conclude, prefill, race-condition guards | | `frontend/src/pages/FlowPilotPage.tsx` | **New.** Classic chat layout (ChatMessage bubbles, TaskLane side panel). Consumes `useAssistantSession` | | `frontend/src/pages/CockpitPage.tsx` | **Rename** from `AssistantChatPage.tsx`. Cockpit layout (IncidentHeader, StepsPanel, FlowPilotAsks, WhatWeKnow, conversation log). Consumes `useAssistantSession` | | `frontend/src/components/assistant/ViewToggle.tsx` | **New.** Segmented control to switch between `/assistant/:id` and `/cockpit/:id` | | `frontend/src/router.tsx` | **Modify.** Add `/cockpit` routes, update `/assistant` routes to `FlowPilotPage` | | `frontend/src/components/layout/Sidebar.tsx` | **Modify.** Add FlowPilot rail entry with cockpit flyout child | | `frontend/src/store/userPreferencesStore.ts` | **Modify.** Add `preferredFlowPilotView` preference | | `frontend/src/components/dashboard/StartSessionInput.tsx` | **Modify.** Add launch view toggle when cockpit flag is enabled | | `frontend/src/hooks/useFeatureFlag.ts` | **Exists.** Already created in sub-project 1. Used for cockpit gating | --- ### Task 1: Extract `useAssistantSession` hook This is the foundation task. Extract all shared session management logic from the current `AssistantChatPage.tsx` into a reusable hook so both pages can consume it. **Files:** - Create: `frontend/src/hooks/useAssistantSession.ts` - Reference: `frontend/src/pages/AssistantChatPage.tsx` (current cockpit version on this branch) The hook must expose everything both pages need. Study the current `AssistantChatPage.tsx` to extract these pieces: **State the hook manages:** - `chats`, `activeChatId`, `messages` — session list and active conversation - `input`, `loading` — compose area state - `showConclude`, `showStatusUpdate` — modal visibility - `branching` — from `useBranching()` hook - `mobileSidebarOpen` — mobile sidebar toggle - `showLogs`, `logContent` — log paste area - `pendingUploads`, `isDragOver` — file upload state - `activeQuestions`, `activeActions`, `showTaskLane` — task lane / AI question state - `sidebarCollapsed` — chat sidebar collapse state **Functions the hook exposes:** - `loadChats`, `selectChat`, `handleNewChat`, `handleDeleteChat`, `handleTogglePin` - `handleSend`, `handleConclude`, `handleResumeNew` - `handleKeyDown`, `handlePaste`, `handleDragOver`, `handleDragEnter`, `handleDragLeave`, `handleDrop`, `handleFileSelect`, `handleRemoveUpload`, `retryUpload` - `setInput`, `setShowConclude`, `setShowStatusUpdate`, `setMobileSidebarOpen`, `setShowLogs`, `setLogContent`, `setShowTaskLane` **Refs the hook manages:** - `messagesEndRef`, `inputRef`, `fileInputRef`, `dragCounterRef`, `prefillHandledRef`, `currentChatRef` **What the hook does NOT include (page-specific):** - Cockpit triage state (`triageMeta`, `workZonePct`, `activeStepIndex`, `completedSteps`, `showOnboarding`, `mergeTriageUpdate`, `handleTriageFieldSave`, etc.) - TaskLane `handleTaskSubmit` (only production page uses TaskLane component; cockpit uses StepsPanel) - All JSX rendering - [ ] **Step 1: Create the hook file with the full extracted logic** Create `frontend/src/hooks/useAssistantSession.ts`: ```typescript import { useState, useEffect, useRef, useCallback } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { uploadsApi } from '@/api/uploads' import type { PendingUpload } from '@/types/upload' import type { ForkMetadata, ActionItem, QuestionItem, ChatMessageResponse, TriageUpdate } from '@/types/ai-session' import { aiSessionsApi } from '@/api/aiSessions' import { useBranching } from '@/hooks/useBranching' import { analytics } from '@/lib/analytics' import { toast } from '@/lib/toast' import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat' import type { SuggestedFlow } from '@/types/copilot' export interface MessageWithMeta { role: 'user' | 'assistant' content: string suggestedFlows?: SuggestedFlow[] fork?: ForkMetadata | null actions?: ActionItem[] | null questions?: QuestionItem[] | null } const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx' export function useAssistantSession() { const location = useLocation() const navigate = useNavigate() const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>() const [chats, setChats] = useState([]) const [activeChatId, setActiveChatId] = useState(() => { if (urlSessionId) return urlSessionId try { return sessionStorage.getItem('rf-active-chat-id') } catch { return null } }) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [showConclude, setShowConclude] = useState(false) const [showStatusUpdate, setShowStatusUpdate] = useState(false) const branching = useBranching() const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) const [showLogs, setShowLogs] = useState(false) const [logContent, setLogContent] = useState('') const [pendingUploads, setPendingUploads] = useState([]) const [isDragOver, setIsDragOver] = useState(false) const [activeQuestions, setActiveQuestions] = useState(() => { try { const saved = sessionStorage.getItem('rf-tasklane-meta') if (saved) { const d = JSON.parse(saved); if (d.chatId === urlSessionId || d.chatId === sessionStorage.getItem('rf-active-chat-id')) return d.questions || [] } } catch { /* ignore */ } return [] }) const [activeActions, setActiveActions] = useState(() => { try { const saved = sessionStorage.getItem('rf-tasklane-meta') if (saved) { const d = JSON.parse(saved); if (d.chatId === urlSessionId || d.chatId === sessionStorage.getItem('rf-active-chat-id')) return d.actions || [] } } catch { /* ignore */ } return [] }) const [showTaskLane, setShowTaskLane] = useState(() => { try { const saved = sessionStorage.getItem('rf-tasklane-meta') if (saved) { const d = JSON.parse(saved); return d.show === true && (d.chatId === urlSessionId || d.chatId === sessionStorage.getItem('rf-active-chat-id')) } } catch { /* ignore */ } return false }) const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem('rf-chat-sidebar-collapsed') === 'true' ) const messagesEndRef = useRef(null) const inputRef = useRef(null) const fileInputRef = useRef(null) const dragCounterRef = useRef(0) const prefillHandledRef = useRef(false) const currentChatRef = useRef(activeChatId) const toggleSidebarCollapse = () => { const next = !sidebarCollapsed setSidebarCollapsed(next) localStorage.setItem('rf-chat-sidebar-collapsed', String(next)) } // Persist active chat ID to sessionStorage useEffect(() => { try { if (activeChatId) sessionStorage.setItem('rf-active-chat-id', activeChatId) else sessionStorage.removeItem('rf-active-chat-id') } catch { /* ignore */ } }, [activeChatId]) // Load chat list on mount useEffect(() => { loadChats() }, []) // If URL has a session ID, load it useEffect(() => { if (urlSessionId && urlSessionId !== activeChatId) { selectChat(urlSessionId) } }, [urlSessionId]) // eslint-disable-line react-hooks/exhaustive-deps // Restore session from sessionStorage on mount (when URL has no session ID) useEffect(() => { if (!urlSessionId && activeChatId) { selectChat(activeChatId) } }, []) // eslint-disable-line react-hooks/exhaustive-deps // Persist task lane metadata to sessionStorage useEffect(() => { try { sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({ show: showTaskLane, chatId: activeChatId, questions: activeQuestions, actions: activeActions, })) } catch { /* ignore */ } }, [showTaskLane, activeChatId, activeQuestions, activeActions]) // Auto-scroll useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) // Auto-grow textarea useEffect(() => { const el = inputRef.current if (!el) return el.style.height = 'auto' el.style.height = `${Math.min(el.scrollHeight, 150)}px` }, [input]) // Cleanup blob URLs on unmount useEffect(() => { return () => { pendingUploads.forEach((u) => { if (u.preview) URL.revokeObjectURL(u.preview) }) } }, []) // eslint-disable-line react-hooks/exhaustive-deps const loadChats = async () => { try { const sessions = await aiSessionsApi.listSessions({ session_type: 'chat', limit: 100 }) setChats(sessions.map(s => ({ id: s.id, title: s.title || s.problem_summary || 'New Chat', message_count: s.step_count, pinned: false, created_at: s.created_at, updated_at: s.created_at, }))) } catch { // silently handle } } // Callback for pages to handle triage data from selectChat. // Pages that care about triage (CockpitPage) set this; FlowPilotPage ignores it. const onSessionLoadedRef = useRef<((detail: { client_name: string | null asset_name: string | null issue_category: string | null triage_hypothesis: string | null evidence_items: Array<{ text: string; status: string }> | null }) => void) | null>(null) const selectChat = useCallback(async (chatId: string) => { currentChatRef.current = chatId setActiveChatId(chatId) setShowTaskLane(false) setActiveQuestions([]) setActiveActions([]) try { const detail = await aiSessionsApi.getSession(chatId) if (currentChatRef.current !== chatId) return setMessages( (detail.conversation_messages || []).map(m => ({ role: m.role as 'user' | 'assistant', content: m.content, })) ) // Notify page of triage data onSessionLoadedRef.current?.({ 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 ?? null, }) // Restore task lane from persisted state if (detail.pending_task_lane) { const q = detail.pending_task_lane.questions || [] const a = detail.pending_task_lane.actions || [] if (q.length > 0 || a.length > 0) { const responses = (detail.pending_task_lane as Record).responses as unknown[] | undefined if (responses && responses.length > 0) { try { sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses)) } catch { /* ignore */ } } setActiveQuestions(q) setActiveActions(a) setShowTaskLane(true) } } } catch { setMessages([]) } }, []) const handleNewChat = async () => { try { const session = await aiSessionsApi.createChatSession({ intake_type: 'free_text', intake_content: { text: '' }, }) const chatItem: ChatListItem = { id: session.session_id, title: session.title, message_count: 0, pinned: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), } currentChatRef.current = session.session_id setChats(prev => [chatItem, ...prev]) setActiveChatId(session.session_id) setMessages([]) setShowTaskLane(false) setActiveQuestions([]) setActiveActions([]) } catch { toast.error('Failed to create chat') } } const handleDeleteChat = async (chatId: string) => { try { await aiSessionsApi.deleteSession(chatId) setChats(prev => prev.filter(c => c.id !== chatId)) if (activeChatId === chatId) { setActiveChatId(null) setMessages([]) } } catch { toast.error('Failed to delete chat') } } const handleTogglePin = async () => { toast.info('Pin feature coming soon') } // Process an AI chat response — updates messages, task lane, branching. // Returns the response for page-specific handling (e.g. triage_update). const processResponse = useCallback((response: ChatMessageResponse, chatId: string) => { if (currentChatRef.current !== chatId) return null setMessages(prev => [ ...prev, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions, }, ]) if (response.fork && chatId) { branching.loadBranches(chatId) } const hasQuestions = response.questions && response.questions.length > 0 const hasActions = response.actions && response.actions.length > 0 if (hasQuestions || hasActions) { setActiveQuestions(response.questions || []) setActiveActions(response.actions || []) setShowTaskLane(true) } return response }, [branching]) // Callback for pages to handle triage updates from chat responses. const onTriageUpdateRef = useRef<((update: TriageUpdate) => void) | null>(null) const handleSend = async () => { if (!input.trim() || !activeChatId || loading) return const sendChatId = activeChatId const userMessage = input.trim() const completedUploadIds = pendingUploads .filter((u) => u.status === 'done' && u.result?.id) .map((u) => u.result!.id) setInput('') setPendingUploads([]) setMessages(prev => [...prev, { role: 'user', content: userMessage }]) setLoading(true) try { const response = await aiSessionsApi.sendChatMessage(sendChatId, { message: userMessage, upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined, }) if (currentChatRef.current !== sendChatId) return analytics.aiFeatureUsed({ feature: 'assistant_chat' }) processResponse(response, sendChatId) setChats(prev => prev.map(c => c.id === sendChatId ? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() } : c ) ) if (response.triage_update) onTriageUpdateRef.current?.(response.triage_update) } catch { if (currentChatRef.current !== sendChatId) return setMessages(prev => [ ...prev, { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' }, ]) } finally { if (currentChatRef.current === sendChatId) { setLoading(false) requestAnimationFrame(() => inputRef.current?.focus()) } } } // Handle prefill from command palette / dashboard handoff const handlePrefill = useCallback((prefillRoute: string) => { const state = location.state as { prefill?: string; uploadIds?: string[] } | null const prefill = state?.prefill const uploadIds = state?.uploadIds if (!prefill || prefillHandledRef.current) return prefillHandledRef.current = true navigate(location.pathname, { replace: true, state: {} }) const sendPrefill = async () => { setShowTaskLane(false) setActiveQuestions([]) setActiveActions([]) try { const session = await aiSessionsApi.createChatSession({ intake_type: 'free_text', intake_content: { text: prefill }, }) const prefillChatId = session.session_id currentChatRef.current = prefillChatId const chatItem: ChatListItem = { id: prefillChatId, title: session.title, message_count: 0, pinned: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), } setChats(prev => [chatItem, ...prev]) setActiveChatId(prefillChatId) setMessages([{ role: 'user', content: prefill }]) setLoading(true) const response = await aiSessionsApi.sendChatMessage(prefillChatId, { message: prefill, upload_ids: uploadIds?.length ? uploadIds : undefined, }) if (currentChatRef.current !== prefillChatId) return processResponse(response, prefillChatId) setChats(prev => prev.map(c => c.id === prefillChatId ? { ...c, message_count: 2, title: prefill.slice(0, 100), updated_at: new Date().toISOString() } : c ) ) if (response.triage_update) onTriageUpdateRef.current?.(response.triage_update) } catch { toast.error('Failed to start AI conversation') } finally { setLoading(false) } } sendPrefill() }, [location.state, navigate, processResponse]) const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise => { if (!activeChatId) throw new Error('No active chat') if (outcome === 'resolved') { await aiSessionsApi.resolveSession(activeChatId, { resolution_summary: _notes || 'Resolved via assistant chat', }) return activeChatId } else if (outcome === 'escalated') { await aiSessionsApi.escalateSession(activeChatId, { escalation_reason: _notes || 'Escalated from assistant chat', }) return activeChatId } else { await aiSessionsApi.pauseSession(activeChatId) return activeChatId } } const handleResumeNew = async (summary: string) => { try { const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.` const session = await aiSessionsApi.createChatSession({ intake_type: 'free_text', intake_content: { text: resumePrompt }, }) const chatItem: ChatListItem = { id: session.session_id, title: session.title, message_count: 0, pinned: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), } currentChatRef.current = session.session_id setChats(prev => [chatItem, ...prev]) setActiveChatId(session.session_id) setMessages([{ role: 'user', content: resumePrompt }]) setLoading(true) const resumeChatId = session.session_id const response = await aiSessionsApi.sendChatMessage(resumeChatId, { message: resumePrompt }) if (currentChatRef.current !== resumeChatId) return processResponse(response, resumeChatId) setChats(prev => prev.map(c => c.id === resumeChatId ? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() } : c ) ) if (response.triage_update) onTriageUpdateRef.current?.(response.triage_update) } catch { toast.error('Failed to create resume chat') } finally { setLoading(false) } } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSend() } } // ── File handling ────────────────────────────── const processFiles = useCallback((files: File[]) => { if (files.length === 0) return const newUploads: PendingUpload[] = files.map((file) => ({ id: crypto.randomUUID(), file, preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : '', status: 'uploading' as const, })) setPendingUploads((prev) => [...prev, ...newUploads]) newUploads.forEach((upload) => { uploadsApi.upload(upload.file) .then((result) => { setPendingUploads((prev) => prev.map((u) => u.id === upload.id ? { ...u, status: 'done' as const, result } : u)) }) .catch((err) => { const is503 = err?.response?.status === 503 if (is503) { toast.warning('Image attachments are not available yet — describe the issue in text instead') } else { toast.error(`Upload failed: ${err?.message || 'Unknown error'}`) } setPendingUploads((prev) => prev.filter((u) => u.id !== upload.id)) }) }) }, []) const handlePaste = useCallback((e: React.ClipboardEvent) => { const items = e.clipboardData?.items if (!items) return const imageFiles: File[] = [] for (let i = 0; i < items.length; i++) { if (items[i].type.startsWith('image/')) { const file = items[i].getAsFile() if (file) imageFiles.push(file) } } if (imageFiles.length > 0) { e.preventDefault() processFiles(imageFiles) } }, [processFiles]) const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }, []) const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current++; if (dragCounterRef.current === 1) setIsDragOver(true) }, []) const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current--; if (dragCounterRef.current === 0) setIsDragOver(false) }, []) const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current = 0; setIsDragOver(false); processFiles(Array.from(e.dataTransfer.files)) }, [processFiles]) const handleFileSelect = useCallback((e: React.ChangeEvent) => { if (e.target.files) { processFiles(Array.from(e.target.files)); e.target.value = '' } }, [processFiles]) const handleRemoveUpload = useCallback((uploadId: string) => { setPendingUploads((prev) => { const toRemove = prev.find((u) => u.id === uploadId); if (toRemove?.preview) URL.revokeObjectURL(toRemove.preview); return prev.filter((u) => u.id !== uploadId) }) }, []) const retryUpload = useCallback((uploadId: string) => { const upload = pendingUploads.find((u) => u.id === uploadId) if (!upload) return setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'uploading' as const, error: undefined } : u)) uploadsApi.upload(upload.file) .then((result) => { setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'done' as const, result } : u)) }) .catch((err) => { setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'error' as const, error: err?.message || 'Upload failed' } : u)) }) }, [pendingUploads]) return { // State chats, activeChatId, messages, input, loading, showConclude, showStatusUpdate, branching, mobileSidebarOpen, showLogs, logContent, pendingUploads, isDragOver, activeQuestions, activeActions, showTaskLane, sidebarCollapsed, // Setters setInput, setShowConclude, setShowStatusUpdate, setMobileSidebarOpen, setShowLogs, setLogContent, setShowTaskLane, setActiveQuestions, setActiveActions, // Handlers selectChat, handleNewChat, handleDeleteChat, handleTogglePin, handleSend, handleConclude, handleResumeNew, handleKeyDown, handlePaste, handleDragOver, handleDragEnter, handleDragLeave, handleDrop, handleFileSelect, handleRemoveUpload, retryUpload, toggleSidebarCollapse, handlePrefill, processResponse, // Refs messagesEndRef, inputRef, fileInputRef, currentChatRef, // Page-specific callbacks onSessionLoadedRef, onTriageUpdateRef, // Constants ACCEPTED_FILE_TYPES, } } ``` - [ ] **Step 2: Verify the hook compiles** Run: `cd frontend && npx tsc --noEmit` Expected: No errors from `useAssistantSession.ts`. There may be pre-existing errors elsewhere — focus only on the new file. - [ ] **Step 3: Commit** ```bash git add frontend/src/hooks/useAssistantSession.ts git commit -m "feat: extract useAssistantSession hook from AssistantChatPage" ``` --- ### Task 2: Create `FlowPilotPage.tsx` (classic chat layout) This page recreates the production `AssistantChatPage` from `origin/main` using the shared hook. It's the classic chat interface with ChatMessage bubbles and a TaskLane side panel. **Files:** - Create: `frontend/src/pages/FlowPilotPage.tsx` - Reference: `git show origin/main:frontend/src/pages/AssistantChatPage.tsx` (production version) - Reference: `frontend/src/hooks/useAssistantSession.ts` (from Task 1) Key differences from the cockpit version: - Uses `ChatMessage` component for message bubbles (not compact conversation log) - Uses `TaskLane` side panel (not StepsPanel/FlowPilotAsks/WhatWeKnow) - Has `handleTaskSubmit` for TaskLane responses - No IncidentHeader, no triage state, no drag-resizable split - Page title: "FlowPilot" - Empty state heading: "FlowPilot" - [ ] **Step 1: Create the FlowPilotPage component** Create `frontend/src/pages/FlowPilotPage.tsx`: ```typescript 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' 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('') // We need to directly call the API here since handleSend reads from input state session.setShowTaskLane(false) session.setActiveQuestions([]) session.setActiveActions([]) try { // Add user message to messages manually // (This is page-specific flow — not in the shared hook) 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 ( <>
{/* Chat Sidebar — desktop */} {!session.sidebarCollapsed && (
)} {/* Chat Sidebar — mobile */}
session.setMobileSidebarOpen(false)} />
{/* Main area */}
{/* Collapsed sidebar bar */} {session.sidebarCollapsed && (
)} {/* Chat content row */}
{/* Mobile header */}
{session.activeChatId && ( )}
{session.activeChatId ? ( <> {/* Desktop view toggle bar */}
{/* Messages */}
{session.messages.length === 0 && !session.loading && (

FlowPilot

Ask me anything about IT infrastructure, networking, Active Directory, cloud platforms, or troubleshooting. I'll also suggest relevant flows from your team's library.

)} {session.messages.map((msg, i) => ( ))} {session.loading && (
)}
{/* Rich Input — same as production */}
{session.isDragOver && (
Drop files to attach
)}