Files
resolutionflow/docs/superpowers/plans/2026-04-02-flowpilot-cockpit-side-by-side.md
chihlasm df9e069452 docs: add FlowPilot / Cockpit side-by-side implementation plan
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>
2026-04-02 16:58:49 +00:00

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 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:

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 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:

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.tsxfrontend/src/pages/CockpitPage.tsx
  • Modify: frontend/src/pages/CockpitPage.tsx (refactor to use useAssistantSession)

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:

  1. Change the default export from AssistantChatPage to CockpitPage
  2. Import and call useAssistantSession()
  3. Remove all the duplicated state and handlers that now live in the hook (everything from useState calls for chats, messages, input, loading, etc. through the file handling functions)
  4. Keep cockpit-specific state: triageMeta, workZonePct, activeStepIndex, completedSteps, showOnboarding, splitContainerRef
  5. Keep cockpit-specific handlers: handleTriageFieldSave, handleEvidenceAdd, handleEvidenceEdit, handleStepComplete, handleStepSelect, mergeTriageUpdate, handleDragStart (for the split handle), dismissOnboarding
  6. Wire onSessionLoadedRef to populate triage state on session load
  7. Wire onTriageUpdateRef to call mergeTriageUpdate on AI responses
  8. Add ViewToggle import (will be created in Task 4)
  9. 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:

  1. Keep ALL existing cockpit-specific handlers verbatim
  2. Keep ALL existing cockpit JSX layout verbatim
  3. Replace references like activeChatId with session.activeChatId, messages with session.messages, etc.
  4. Add the feature flag redirect useEffect
  5. Add ViewToggle next 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:

  1. Add lazy import for CockpitPage and FlowPilotPage:
const FlowPilotPage = lazyWithRetry(() => import('@/pages/FlowPilotPage'))
const CockpitPage = lazyWithRetry(() => import('@/pages/CockpitPage'))
  1. Update the AssistantChatPage import line — remove it entirely (the file no longer exists).

  2. Update the route entries inside the children array. 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:

  1. Add import at the top:
import { Sparkles } from 'lucide-react'
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
  1. Inside the Sidebar component, add feature flag check:
const hasCockpit = useFeatureFlag('flowpilot_cockpit')
  1. In the railGroups array, 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' }] : []),
  ],
},
  1. In the sections array (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:

  1. Add type:
type FlowPilotView = 'flowpilot' | 'cockpit'
  1. Add to interface:
preferredFlowPilotView: FlowPilotView
setPreferredFlowPilotView: (view: FlowPilotView) => void
  1. 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:

  1. Add imports:
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
  1. Inside the component, add:
const hasCockpit = useFeatureFlag('flowpilot_cockpit')
const preferredView = useUserPreferencesStore(s => s.preferredFlowPilotView)
const setPreferredView = useUserPreferencesStore(s => s.setPreferredFlowPilotView)
  1. Update handleSubmit — change the navigate target:
// Replace: navigate('/assistant', { state })
const target = hasCockpit && preferredView === 'cockpit' ? '/cockpit' : '/assistant'
navigate(target, { state })
  1. Update handleSuggestionClick similarly:
const handleSuggestionClick = (suggestion: string) => {
  const target = hasCockpit && preferredView === 'cockpit' ? '/cockpit' : '/assistant'
  navigate(target, { state: { prefill: suggestion } })
}
  1. 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 title props

  • 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"