diff --git a/docs/superpowers/plans/2026-04-02-flowpilot-cockpit-side-by-side.md b/docs/superpowers/plans/2026-04-02-flowpilot-cockpit-side-by-side.md new file mode 100644 index 00000000..1356eff8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-flowpilot-cockpit-side-by-side.md @@ -0,0 +1,1545 @@ +# 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 +
+
+ )} + +