10-task plan covering hook extraction, page split, view toggle, routing, sidebar nav, dashboard preference, and UI renaming. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
62 KiB
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 conversationinput,loading— compose area stateshowConclude,showStatusUpdate— modal visibilitybranching— fromuseBranching()hookmobileSidebarOpen— mobile sidebar toggleshowLogs,logContent— log paste areapendingUploads,isDragOver— file upload stateactiveQuestions,activeActions,showTaskLane— task lane / AI question statesidebarCollapsed— chat sidebar collapse state
Functions the hook exposes:
loadChats,selectChat,handleNewChat,handleDeleteChat,handleTogglePinhandleSend,handleConclude,handleResumeNewhandleKeyDown,handlePaste,handleDragOver,handleDragEnter,handleDragLeave,handleDrop,handleFileSelect,handleRemoveUpload,retryUploadsetInput,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:
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<ChatListItem[]>([])
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
if (urlSessionId) return urlSessionId
try { return sessionStorage.getItem('rf-active-chat-id') } catch { return null }
})
const [messages, setMessages] = useState<MessageWithMeta[]>([])
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<PendingUpload[]>([])
const [isDragOver, setIsDragOver] = useState(false)
const [activeQuestions, setActiveQuestions] = useState<QuestionItem[]>(() => {
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<ActionItem[]>(() => {
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<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounterRef = useRef(0)
const prefillHandledRef = useRef(false)
const currentChatRef = useRef<string | null>(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<string, unknown>).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<string> => {
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<HTMLTextAreaElement>) => {
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<HTMLInputElement>) => { 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
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
ChatMessagecomponent for message bubbles (not compact conversation log) -
Uses
TaskLaneside panel (not StepsPanel/FlowPilotAsks/WhatWeKnow) -
Has
handleTaskSubmitfor 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:
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 (
<>
<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" />
{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">
<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 — same as production */}
<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>
</>
)
}
- Step 2: Verify FlowPilotPage compiles
Run: cd frontend && npx tsc --noEmit
Note: ViewToggle doesn't exist yet — expect an error for that import. Comment it out temporarily or create a stub. All other imports should resolve.
- Step 3: Commit
git add frontend/src/pages/FlowPilotPage.tsx
git commit -m "feat: create FlowPilotPage with classic chat layout"
Task 3: Rename AssistantChatPage to CockpitPage and refactor to use the hook
Files:
- Rename:
frontend/src/pages/AssistantChatPage.tsx→frontend/src/pages/CockpitPage.tsx - Modify:
frontend/src/pages/CockpitPage.tsx(refactor to useuseAssistantSession)
The cockpit page keeps its current layout (IncidentHeader, StepsPanel, FlowPilotAsks, WhatWeKnow, drag-resizable conversation log) but replaces all the inline session management with the shared hook.
- Step 1: Rename the file
cd frontend && git mv src/pages/AssistantChatPage.tsx src/pages/CockpitPage.tsx
- Step 2: Refactor CockpitPage to use useAssistantSession
Edit frontend/src/pages/CockpitPage.tsx:
- Change the default export from
AssistantChatPagetoCockpitPage - Import and call
useAssistantSession() - Remove all the duplicated state and handlers that now live in the hook (everything from
useStatecalls forchats,messages,input,loading, etc. through the file handling functions) - Keep cockpit-specific state:
triageMeta,workZonePct,activeStepIndex,completedSteps,showOnboarding,splitContainerRef - Keep cockpit-specific handlers:
handleTriageFieldSave,handleEvidenceAdd,handleEvidenceEdit,handleStepComplete,handleStepSelect,mergeTriageUpdate,handleDragStart(for the split handle),dismissOnboarding - Wire
onSessionLoadedRefto populate triage state on session load - Wire
onTriageUpdateRefto callmergeTriageUpdateon AI responses - Add
ViewToggleimport (will be created in Task 4) - Add feature flag redirect: if
useFeatureFlag('flowpilot_cockpit')returns false, redirect to/assistant/:sessionId
The structure becomes:
import { useState, useRef, useCallback, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, GripHorizontal } 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 { 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 { ViewToggle } from '@/components/assistant/ViewToggle'
import { useAssistantSession } from '@/hooks/useAssistantSession'
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
import type { TriageMeta, EvidenceItem, TriageUpdate } from '@/types/ai-session'
export default function CockpitPage() {
const navigate = useNavigate()
const hasCockpit = useFeatureFlag('flowpilot_cockpit')
const session = useAssistantSession()
// Cockpit-specific state
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 [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set())
const [showOnboarding, setShowOnboarding] = useState(() =>
!localStorage.getItem('rf-cockpit-onboarded')
)
const splitContainerRef = useRef<HTMLDivElement>(null)
// Redirect if user doesn't have cockpit access
useEffect(() => {
if (!hasCockpit && session.activeChatId) {
navigate(`/assistant/${session.activeChatId}`, { replace: true })
} else if (!hasCockpit) {
navigate('/assistant', { replace: true })
}
}, [hasCockpit, session.activeChatId, navigate])
// Wire up triage data from session loads
useEffect(() => {
session.onSessionLoadedRef.current = (detail) => {
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 as EvidenceItem[]) ?? [],
})
}
return () => { session.onSessionLoadedRef.current = null }
}, [session.onSessionLoadedRef])
// Wire up triage updates from AI responses
useEffect(() => {
session.onTriageUpdateRef.current = mergeTriageUpdate
return () => { session.onTriageUpdateRef.current = null }
}, [session.onTriageUpdateRef]) // eslint-disable-line react-hooks/exhaustive-deps
// Handle prefill
useEffect(() => {
session.handlePrefill('/cockpit')
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// ... cockpit-specific handlers (handleTriageFieldSave, handleEvidenceAdd,
// handleEvidenceEdit, handleStepComplete, handleStepSelect,
// mergeTriageUpdate, handleDragStart, dismissOnboarding)
// — keep exactly as they are in the current AssistantChatPage
// ... JSX rendering — keep the cockpit layout exactly as-is,
// but replace direct state references with session.xxx
// and add <ViewToggle currentView="cockpit" sessionId={session.activeChatId} />
// in the header area
}
The implementer should:
- Keep ALL existing cockpit-specific handlers verbatim
- Keep ALL existing cockpit JSX layout verbatim
- Replace references like
activeChatIdwithsession.activeChatId,messageswithsession.messages, etc. - Add the feature flag redirect
useEffect - Add
ViewTogglenext to the existing action buttons in the IncidentHeader area
- Step 3: Verify CockpitPage compiles
Run: cd frontend && npx tsc --noEmit
Expect ViewToggle import error (not created yet). All other imports should resolve.
- Step 4: Commit
git add -A
git commit -m "refactor: rename AssistantChatPage to CockpitPage, consume useAssistantSession hook"
Task 4: Create ViewToggle component
Files:
- Create:
frontend/src/components/assistant/ViewToggle.tsx
A segmented control that lets users switch between FlowPilot and FlowPilot Cockpit.
- Step 1: Create the ViewToggle component
Create frontend/src/components/assistant/ViewToggle.tsx:
import { useNavigate } from 'react-router-dom'
import { cn } from '@/lib/utils'
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
interface ViewToggleProps {
currentView: 'flowpilot' | 'cockpit'
sessionId: string
}
export function ViewToggle({ currentView, sessionId }: ViewToggleProps) {
const navigate = useNavigate()
const hasCockpit = useFeatureFlag('flowpilot_cockpit')
// Only show toggle if user has cockpit access
if (!hasCockpit) return null
const handleSwitch = (view: 'flowpilot' | 'cockpit') => {
if (view === currentView) return
const path = view === 'cockpit'
? `/cockpit/${sessionId}`
: `/assistant/${sessionId}`
navigate(path)
}
return (
<div className="flex items-center rounded-lg border border-border bg-card p-0.5 text-xs">
<button
onClick={() => handleSwitch('flowpilot')}
className={cn(
'rounded-md px-2.5 py-1 font-medium transition-colors',
currentView === 'flowpilot'
? 'bg-elevated text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
FlowPilot
</button>
<button
onClick={() => handleSwitch('cockpit')}
className={cn(
'rounded-md px-2.5 py-1 font-medium transition-colors',
currentView === 'cockpit'
? 'bg-elevated text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
Cockpit
</button>
</div>
)
}
- Step 2: Verify it compiles
Run: cd frontend && npx tsc --noEmit
Expected: No errors from ViewToggle.tsx.
- Step 3: Commit
git add frontend/src/components/assistant/ViewToggle.tsx
git commit -m "feat: add ViewToggle component for FlowPilot/Cockpit switching"
Task 5: Update router with new routes
Files:
-
Modify:
frontend/src/router.tsx -
Step 1: Add lazy imports and routes
In frontend/src/router.tsx:
- Add lazy import for
CockpitPageandFlowPilotPage:
const FlowPilotPage = lazyWithRetry(() => import('@/pages/FlowPilotPage'))
const CockpitPage = lazyWithRetry(() => import('@/pages/CockpitPage'))
-
Update the
AssistantChatPageimport line — remove it entirely (the file no longer exists). -
Update the route entries inside the
childrenarray. Find these lines:
{ path: 'assistant', element: page(AssistantChatPage) },
{ path: 'assistant/:sessionId', element: page(AssistantChatPage) },
Replace with:
{ path: 'assistant', element: page(FlowPilotPage) },
{ path: 'assistant/:sessionId', element: page(FlowPilotPage) },
{ path: 'cockpit', element: page(CockpitPage) },
{ path: 'cockpit/:sessionId', element: page(CockpitPage) },
- Step 2: Verify it compiles
Run: cd frontend && npx tsc --noEmit
Expected: PASS (no references to AssistantChatPage remain).
- Step 3: Commit
git add frontend/src/router.tsx
git commit -m "feat: add /cockpit routes, update /assistant to use FlowPilotPage"
Task 6: Add FlowPilot to sidebar navigation
Files:
-
Modify:
frontend/src/components/layout/Sidebar.tsx -
Step 1: Add FlowPilot rail entry
In frontend/src/components/layout/Sidebar.tsx:
- Add import at the top:
import { Sparkles } from 'lucide-react'
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
- Inside the
Sidebarcomponent, add feature flag check:
const hasCockpit = useFeatureFlag('flowpilot_cockpit')
- In the
railGroupsarray, add a new entry as the second item (after Home, before History):
{
href: '/assistant', icon: Sparkles, label: 'FlowPilot', shortLabel: 'FP',
matchPaths: ['/assistant', '/cockpit'],
children: [
{ href: '/assistant', label: 'FlowPilot' },
...(hasCockpit ? [{ href: '/cockpit', label: 'FlowPilot Cockpit' }] : []),
],
},
- In the
sectionsarray (used when sidebar is pinned), add under the "RESOLVE" section items:
{
href: '/assistant', icon: Sparkles, label: 'FlowPilot', shortLabel: 'FP',
matchPaths: ['/assistant', '/cockpit'],
},
And if hasCockpit is true, add a second entry below it:
...(hasCockpit ? [{
href: '/cockpit', icon: Sparkles, label: 'FlowPilot Cockpit', shortLabel: 'Cockpit',
matchPaths: ['/cockpit'],
}] : []),
- Step 2: Verify it compiles
Run: cd frontend && npx tsc --noEmit
Expected: PASS.
- Step 3: Commit
git add frontend/src/components/layout/Sidebar.tsx
git commit -m "feat: add FlowPilot and FlowPilot Cockpit to sidebar navigation"
Task 7: Add preferredFlowPilotView to user preferences store
Files:
-
Modify:
frontend/src/store/userPreferencesStore.ts -
Step 1: Add the preference
In frontend/src/store/userPreferencesStore.ts:
- Add type:
type FlowPilotView = 'flowpilot' | 'cockpit'
- Add to interface:
preferredFlowPilotView: FlowPilotView
setPreferredFlowPilotView: (view: FlowPilotView) => void
- Add to store implementation:
preferredFlowPilotView: 'flowpilot',
setPreferredFlowPilotView: (view) => set({ preferredFlowPilotView: view }),
- Step 2: Verify it compiles
Run: cd frontend && npx tsc --noEmit
Expected: PASS.
- Step 3: Commit
git add frontend/src/store/userPreferencesStore.ts
git commit -m "feat: add preferredFlowPilotView to user preferences store"
Task 8: Update StartSessionInput with launch view toggle
Files:
-
Modify:
frontend/src/components/dashboard/StartSessionInput.tsx -
Step 1: Add view preference toggle
In frontend/src/components/dashboard/StartSessionInput.tsx:
- Add imports:
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
- Inside the component, add:
const hasCockpit = useFeatureFlag('flowpilot_cockpit')
const preferredView = useUserPreferencesStore(s => s.preferredFlowPilotView)
const setPreferredView = useUserPreferencesStore(s => s.setPreferredFlowPilotView)
- Update
handleSubmit— change the navigate target:
// Replace: navigate('/assistant', { state })
const target = hasCockpit && preferredView === 'cockpit' ? '/cockpit' : '/assistant'
navigate(target, { state })
- Update
handleSuggestionClicksimilarly:
const handleSuggestionClick = (suggestion: string) => {
const target = hasCockpit && preferredView === 'cockpit' ? '/cockpit' : '/assistant'
navigate(target, { state: { prefill: suggestion } })
}
- Add a view toggle near the submit button. Inside the bottom toolbar
<div>, before the send button, add:
{hasCockpit && (
<div className="flex items-center rounded-lg border border-border bg-card p-0.5 text-xs mr-2">
<button
type="button"
onClick={() => setPreferredView('flowpilot')}
className={cn(
'rounded-md px-2 py-1 font-medium transition-colors',
preferredView === 'flowpilot'
? 'bg-elevated text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
FlowPilot
</button>
<button
type="button"
onClick={() => setPreferredView('cockpit')}
className={cn(
'rounded-md px-2 py-1 font-medium transition-colors',
preferredView === 'cockpit'
? 'bg-elevated text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
Cockpit
</button>
</div>
)}
- Step 2: Verify it compiles
Run: cd frontend && npx tsc --noEmit
Expected: PASS.
- Step 3: Commit
git add frontend/src/components/dashboard/StartSessionInput.tsx
git commit -m "feat: add launch view preference toggle to StartSessionInput"
Task 9: Rename "AI Assistant" to "FlowPilot" in remaining UI labels
Files:
-
Search and update any remaining references to "AI Assistant" in user-facing text
-
Step 1: Find all "AI Assistant" references
Run:
cd frontend && grep -rn "AI Assistant" src/ --include="*.tsx" --include="*.ts"
For each hit, replace "AI Assistant" with "FlowPilot" in user-facing strings (page titles, headings, descriptions, placeholders). Do NOT rename file names, variable names, or comments in this task — only user-facing text.
Common locations to check:
-
PageMeta titleprops -
Empty state headings
-
Sidebar labels
-
Breadcrumbs
-
Any tooltip or aria-label text
-
Step 2: Verify it compiles
Run: cd frontend && npx tsc --noEmit
Expected: PASS.
- Step 3: Commit
git add -A
git commit -m "chore: rename 'AI Assistant' to 'FlowPilot' in user-facing text"
Task 10: Final build verification
- Step 1: Run full TypeScript build
Run: cd frontend && npm run build
This is stricter than npx tsc --noEmit — it enforces noUnusedLocals and noUnusedParameters. Fix any errors.
- Step 2: Verify no stale AssistantChatPage references
Run:
cd frontend && grep -rn "AssistantChatPage" src/ --include="*.tsx" --include="*.ts"
Expected: Zero results. If any remain, update them to reference either FlowPilotPage or CockpitPage.
- Step 3: Commit any fixes
git add -A
git commit -m "fix: resolve build errors from side-by-side refactor"