import { useState, useEffect, useRef, useCallback } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause } from 'lucide-react'
import { cn } from '@/lib/utils'
import { uploadsApi } from '@/api/uploads'
import type { PendingUpload } from '@/types/upload'
import type { ForkMetadata, ActionItem, QuestionItem } from '@/types/ai-session'
import { PageMeta } from '@/components/common/PageMeta'
import { aiSessionsApi } from '@/api/aiSessions'
import { useBranching } from '@/hooks/useBranching'
import { analytics } from '@/lib/analytics'
import { toast } from '@/lib/toast'
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
import { ChatMessage } from '@/components/assistant/ChatMessage'
import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow'
import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview'
import { ProposalBanner } from '@/components/pilot/ProposalBanner'
import type { BannerMode } from '@/components/pilot/ProposalBanner'
import { EscalateInterceptDialog } from '@/components/pilot/EscalateInterceptDialog'
import type { InterceptChoice } from '@/components/pilot/EscalateInterceptDialog'
import { TemplateMatchPanel } from '@/components/pilot/script/TemplateMatchPanel'
import { TemplatizePrompt } from '@/components/pilot/script/TemplatizePrompt'
import { ChatTabStrip, type ChatTab } from '@/components/pilot/ChatTabStrip'
import { ScriptBuilderTab } from '@/components/pilot/ScriptBuilderTab'
import { InlineNoTemplateDialog } from '@/components/pilot/InlineNoTemplateDialog'
import { ShortcutsHelpOverlay } from '@/components/pilot/ShortcutsHelpOverlay'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import { useMediaQuery } from '@/hooks/useMediaQuery'
import {
  draftTemplatesApi,
  accountPreferencesApi,
  type DraftTemplate,
} from '@/api/draftTemplates'
import { PILOT_INLINE_SCRIPT_EVENT } from '@/lib/pilotEvents'
import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts'
import {
  sessionSuggestedFixesApi,
  type SessionSuggestedFix,
  type ResolutionNotePreview as ResolutionNotePreviewData,
  type UserDecision,
  type FixOutcome,
} from '@/api/sessionSuggestedFixes'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
import type { SuggestedFlow } from '@/types/copilot'

interface MessageWithMeta {
  role: 'user' | 'assistant'
  content: string
  suggestedFlows?: SuggestedFlow[]
  fork?: ForkMetadata | null
  actions?: ActionItem[] | null
  questions?: QuestionItem[] | null
  imageUrls?: string[]
}

export default function AssistantChatPage() {
  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 === activeChatId) 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 === activeChatId) 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 === activeChatId }
    } catch { /* ignore */ }
    return false
  })
  const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
    localStorage.getItem('rf-chat-sidebar-collapsed') === 'true'
  )
  const [activeSessionStatus, setActiveSessionStatus] = useState<string | null>(null)
  const [activePsaTicketId, setActivePsaTicketId] = useState<string | null>(null)
  // Phase 2: "What we know" facts for the active session. Refreshed on
  // selectChat and after each chat send (the AI may have emitted [PROMOTE]
  // markers that synthesized new facts server-side).
  const [facts, setFacts] = useState<SessionFact[]>([])
  // Phase 3: active suggested fix; Phase 4 extends the preview popover to
  // support both Resolve and Escalate (kind-parameterized, one active at a time).
  const [activeFix, setActiveFix] = useState<SessionSuggestedFix | null>(null)
  const [previewKind, setPreviewKind] = useState<'resolve' | 'escalate' | null>(null)
  const [previewData, setPreviewData] = useState<ResolutionNotePreviewData | null>(null)
  const [previewLoading, setPreviewLoading] = useState(false)
  const [previewError, setPreviewError] = useState<string | null>(null)
  const [previewPosting, setPreviewPosting] = useState(false)
  // Debounce timer for preview refresh — Phase 3 spec calls for 500ms client-
  // side debounce so rapid edits don't fan out to the LLM (cache absorbs the
  // dups, but the request itself still costs HTTP RTT).
  const previewDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  const previewOpen = previewKind !== null
  // Phase 5: inline Script Generator panel state. Open <=> the engineer
  // clicked the Suggested Fix card. Which panel renders is decided by
  // whether the active fix has a script_template_id.
  const [scriptPanelOpen, setScriptPanelOpen] = useState(false)
  const [scriptDecisionBusy, setScriptDecisionBusy] = useState(false)
  // Phase 6: post-resolve "save as template?" queue. After Resolve succeeds
  // we fetch pending drafts for this session and show the modal one at a
  // time; the user accepts, rejects, or toggles "don't ask again", and we
  // advance to the next pending draft.
  const [templatizeQueue, setTemplatizeQueue] = useState<DraftTemplate[]>([])
  const [showOverflow, setShowOverflow] = useState(false)
  // Phase 7: keyboard-shortcut help overlay.
  const [shortcutsHelpOpen, setShortcutsHelpOpen] = useState(false)
  // Phase 7: below 1200px the task lane collapses to a bottom drawer per the
  // migration spec. Above, it's the standard right-side panel.
  const isNarrow = useMediaQuery('(max-width: 1199px)')
  // Phase 8: ProposalBanner + EscalateInterceptDialog state.
  const [bannerCollapsed, setBannerCollapsed] = useState(false)
  const [postApplyMsgCount, setPostApplyMsgCount] = useState(0)
  const [nudgeSilenced, setNudgeSilenced] = useState(false)
  const [escalateIntercept, setEscalateIntercept] = useState<{ fixId: string; fixTitle: string } | null>(null)
  // Phase 9: ChatTabStrip + ScriptBuilderTab state.
  const [chatTab, setChatTab] = useState<ChatTab>('chat')
  const [scriptBuilderHasProgress, setScriptBuilderHasProgress] = useState(false)
  // Phase 8: compute the current banner mode from activeFix.
  // applied_at is now persisted on the server (stamped by POST /apply),
  // so bannerMode is derived entirely from server state — no client-side flag.
  const bannerMode: BannerMode | null = (() => {
    if (!activeFix) return null
    if (activeFix.status === 'dismissed') return null
    if (activeFix.ai_outcome_proposal) return 'ai_confirming'
    if (activeFix.status === 'applied_partial') return 'partial'
    if (activeFix.status === 'applied_success' || activeFix.status === 'applied_failed') return null
    if (activeFix.applied_at) {
      if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge'
      return 'verifying'
    }
    return 'proposed'
  })()

  // Phase 9: show the tab strip when the fix needs a script drafted (no template,
  // no drafted script yet, and still in a live state).
  const showTabStrip =
    activeFix != null
    && activeFix.status !== 'dismissed'
    && activeFix.status !== 'applied_success'
    && activeFix.status !== 'applied_failed'
    && !activeFix.script_template_id
    && !activeFix.ai_drafted_script

  // Defensive: if the strip hides (fix resolved/dismissed/script-drafted),
  // snap back to the Chat tab so the user doesn't land on a blank panel.
  useEffect(() => {
    if (!showTabStrip && chatTab === 'script_builder') setChatTab('chat')
  }, [showTabStrip, chatTab])

  const toggleSidebarCollapse = () => {
    const next = !sidebarCollapsed
    setSidebarCollapsed(next)
    localStorage.setItem('rf-chat-sidebar-collapsed', String(next))
  }
  const messagesEndRef = useRef<HTMLDivElement>(null)
  const inputRef = useRef<HTMLTextAreaElement>(null)
  const fileInputRef = useRef<HTMLInputElement>(null)
  const dragCounterRef = useRef(0)
  const prefillHandledRef = useRef(false)
  // Tracks the most recently requested active chat ID so in-flight selectChat
  // calls that complete after the user switches chats don't clobber new state.
  const currentChatRef = useRef<string | null>(activeChatId)

  // 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 from ai_sessions
  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

  // Handle prefill from command palette / dashboard handoff
  useEffect(() => {
    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 () => {
      // Clear stale task lane from previous session
      resetSessionDerivedState()
      setActiveSessionStatus('active')
      setActivePsaTicketId(null)

      try {
        const session = await aiSessionsApi.createChatSession({
          intake_type: 'free_text',
          intake_content: { text: prefill },
        })
        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(),
        }
        setChats(prev => [chatItem, ...prev])
        setActiveChatId(session.session_id)
        setMessages([{ role: 'user', content: prefill }])
        setLoading(true)

        const response = await aiSessionsApi.sendChatMessage(session.session_id, {
          message: prefill,
          upload_ids: uploadIds?.length ? uploadIds : undefined,
        })
        setMessages(prev => [
          ...prev,
          { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
        ])
        setChats(prev =>
          prev.map(c =>
            c.id === session.session_id
              ? { ...c, message_count: 2, title: prefill.slice(0, 100), updated_at: new Date().toISOString() }
              : c
          )
        )
        // Show task lane if AI sent questions or actions
        if (response.fork && session.session_id) {
          branching.loadBranches(session.session_id)
        }
        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)
        }
        // Refetch facts + active fix — the AI may have emitted markers.
        refreshSessionDerived(session.session_id)
      } catch {
        toast.error('Failed to start AI conversation')
      } finally {
        setLoading(false)
      }
    }

    sendPrefill()
  // eslint-disable-next-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])

  // Phase 5: Cmd+K → "Open inline Script Generator". Only acts when there
  // is an active suggested fix on this session — otherwise we'd open an
  // empty panel.
  useEffect(() => {
    const handler = () => {
      if (activeFix) {
        setScriptPanelOpen(true)
      } else {
        toast.info('No active suggested fix yet — wait for the AI to propose a resolution.')
      }
    }
    window.addEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
    return () => window.removeEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
  }, [activeFix])

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

  // Single source of truth for "wipe every per-chat UI state field" before
  // switching to a different chat. Called from selectChat, handleNewChat,
  // sendPrefill, and handleResumeNew so adding new chat-scoped state in future
  // phases only requires touching this one helper. Forgetting to clear a field
  // leaks the previous chat's data into the new one — first noticed as a task
  // lane regression (Phase 5), surfaced again as the conversation pane showing
  // the previous chat's messages while the sidebar entry said "0 messages".
  // `messages` belongs in here too: selectChat clears it asynchronously when
  // getSession returns, but the gap between switching and that response is
  // exactly when the previous chat's content stays visible.
  const resetSessionDerivedState = useCallback(() => {
    setMessages([])
    setShowTaskLane(false)
    setActiveQuestions([])
    setActiveActions([])
    setFacts([])
    setActiveFix(null)
    setPreviewKind(null)
    setPreviewData(null)
    setPreviewError(null)
    setPreviewPosting(false)
    setScriptPanelOpen(false)
    // Phase 8: banner state reset
    setBannerCollapsed(false)
    setPostApplyMsgCount(0)
    setNudgeSilenced(false)
    setEscalateIntercept(null)
    // Phase 9: tab strip reset
    setChatTab('chat')
    setScriptBuilderHasProgress(false)
  }, [])

  // Phase 2 facts — fetch + handlers. `refreshFacts` is called from selectChat
  // and after each chat send, because the AI may have emitted [PROMOTE] markers
  // that synthesized new facts server-side (see unified_chat_service.
  // _persist_promote_items).
  const refreshFacts = useCallback(async (chatId: string) => {
    try {
      const list = await sessionFactsApi.list(chatId)
      // Guard: discard stale fetch if the user switched chats mid-flight.
      if (currentChatRef.current !== chatId) return
      setFacts(list)
      // Auto-open the task lane when the session has facts so the engineer
      // can see them — without this, a session with only facts (no open
      // questions) would hide the lane and the facts would be invisible.
      if (list.length > 0) setShowTaskLane(true)
    } catch {
      // Best-effort — facts are accessory state. Surfacing a toast on every
      // refetch failure would be noisy; the empty state explains the absence.
    }
  }, [])

  // Phase 3 — active suggested fix + resolution-note preview.
  // Declared BEFORE refreshSessionDerived / handleAddNote so the useCallback
  // dep arrays don't hit a temporal dead zone on React's synchronous render.
  const refreshActiveFix = useCallback(async (chatId: string) => {
    try {
      const fix = await sessionSuggestedFixesApi.getActive(chatId)
      if (currentChatRef.current !== chatId) return
      setActiveFix((prev) => {
        // If the active fix changed (AI emitted a new SUGGEST_FIX that
        // superseded the prior), close the script panel so the engineer
        // isn't acting on stale draft state.
        if (prev?.id !== fix?.id) setScriptPanelOpen(false)
        return fix
      })
    } catch {
      // No-fix-yet (404) is normalized to null inside the client. Genuine
      // failures stay silent — accessory state, not load-bearing.
    }
  }, [])

  // Kind-aware preview fetch: Resolve hits /resolution-note/preview,
  // Escalate hits /escalation-package/preview. They're cached separately
  // on the backend, so switching kinds never returns stale markdown.
  const refreshPreview = useCallback(async (chatId: string, kind?: 'resolve' | 'escalate') => {
    const effectiveKind = kind ?? previewKind
    if (!effectiveKind) return
    setPreviewLoading(true)
    setPreviewError(null)
    try {
      const p = effectiveKind === 'resolve'
        ? await sessionSuggestedFixesApi.getResolutionNotePreview(chatId)
        : await sessionSuggestedFixesApi.getEscalationPackagePreview(chatId)
      if (currentChatRef.current !== chatId) return
      setPreviewData(p)
    } catch (err: unknown) {
      const status = (err as { response?: { status?: number } })?.response?.status
      setPreviewError(
        status === 502
          ? 'AI provider error drafting the note. Try again in a few seconds.'
          : 'Could not load preview.',
      )
    } finally {
      setPreviewLoading(false)
    }
  }, [previewKind])

  // Trigger preview refresh with a 500ms debounce. The backend cache short-
  // circuits same-state calls, but the network round-trip is still avoidable
  // when the user is typing quickly (e.g. editing a fact).
  const schedulePreviewRefresh = useCallback((chatId: string) => {
    if (previewDebounceRef.current) clearTimeout(previewDebounceRef.current)
    previewDebounceRef.current = setTimeout(() => {
      if (previewOpen && currentChatRef.current === chatId) {
        refreshPreview(chatId)
      }
    }, 500)
  }, [previewOpen, refreshPreview])

  // Phase 3: convenience helper — refresh fact list, active fix, and (if open)
  // schedule a preview refresh. Called after every chat send so the new state
  // (PROMOTE-synthesized facts, new SUGGEST_FIX) appears in the lane.
  const refreshSessionDerived = useCallback(async (chatId: string) => {
    await Promise.all([refreshFacts(chatId), refreshActiveFix(chatId)])
    if (previewOpen) schedulePreviewRefresh(chatId)
  }, [refreshFacts, refreshActiveFix, previewOpen, schedulePreviewRefresh])

  const handleAddNote = async (text: string, summary: string | null) => {
    if (!activeChatId) return
    try {
      const fact = await sessionFactsApi.create(activeChatId, { text, summary })
      setFacts(prev => [...prev, fact])
      schedulePreviewRefresh(activeChatId)
    } catch {
      toast.error('Failed to add note')
    }
  }

  const handleUpdateFact = async (factId: string, text: string, summary: string | null) => {
    if (!activeChatId) return
    try {
      const updated = await sessionFactsApi.update(activeChatId, factId, { text, summary })
      setFacts(prev => prev.map(f => f.id === factId ? updated : f))
      schedulePreviewRefresh(activeChatId)
    } catch {
      toast.error('Failed to update fact')
    }
  }

  const handleDeleteFact = async (factId: string) => {
    if (!activeChatId) return
    try {
      await sessionFactsApi.remove(activeChatId, factId)
      setFacts(prev => prev.filter(f => f.id !== factId))
      schedulePreviewRefresh(activeChatId)
    } catch {
      toast.error('Failed to remove fact')
    }
  }

  // Phase 5: handle a path choice from NoTemplateDialog. one_off and
  // draft_template just record the decision (returning the rendered script
  // for display); build_template returns a redirect_path to the Script
  // Builder, which we navigate to.
  const handleScriptDecision = async (
    decision: UserDecision,
    options: { editedScript: string; parametersUsed: Record<string, string> },
  ) => {
    if (!activeChatId || !activeFix) return
    setScriptDecisionBusy(true)
    try {
      const out = await sessionSuggestedFixesApi.recordDecision(
        activeChatId, activeFix.id, decision,
        { editedScript: options.editedScript, parametersUsed: options.parametersUsed },
      )
      // Decision endpoint bumps state_version — reflect in preview.
      schedulePreviewRefresh(activeChatId)

      if (decision === 'build_template' && out.redirect_path) {
        navigate(out.redirect_path)
        return
      }
      if (decision === 'one_off') {
        toast.success('Recorded as one-off — script not added to library')
      } else if (decision === 'draft_template') {
        toast.success('Draft template queued — review after Resolve')
      }
      // Phase 9 §5: one_off and draft_template declare a run ("Run now, …").
      // Stamp applied_at to transition the fix into Verifying.
      // build_template does NOT run — no stamp.
      if (decision === 'one_off' || decision === 'draft_template') {
        try {
          const updated = await sessionSuggestedFixesApi.applyFix(
            activeChatId, activeFix.id,
          )
          setActiveFix(updated)
        } catch { /* non-fatal: engineer can still mark outcome later */ }
      }
      // Keep the panel open so the engineer can copy the rendered script.
    } catch {
      toast.error('Failed to record decision')
    } finally {
      setScriptDecisionBusy(false)
    }
  }

  // handleOpenPreview is declared before handleSetOutcome so it can be listed
  // as a useCallback dep without a temporal dead zone.
  const handleOpenPreview = useCallback((kind: 'resolve' | 'escalate') => {
    if (!activeChatId) return
    // Opening a different kind clobbers the cached markdown so the popover
    // doesn't flash stale content while the new kind fetches.
    if (previewKind !== kind) setPreviewData(null)
    setPreviewKind(kind)
    setPreviewError(null)
    refreshPreview(activeChatId, kind)
  }, [activeChatId, previewKind, refreshPreview])

  // Phase 9: handleApplyFix — routes to the appropriate surface based on
  // fix state. applyFix() call site moves to Task 13 (handleScriptDecision
  // and TemplateMatchPanel.onMarkRun).
  const handleApplyFix = useCallback(() => {
    if (!activeFix) return
    if (activeFix.script_template_id) {
      setScriptPanelOpen(true) // existing TemplateMatchPanel flow in task lane
      return
    }
    if (activeFix.ai_drafted_script) {
      setScriptPanelOpen(true) // InlineNoTemplateDialog, now in chat region (Step 5)
      return
    }
    // No draft, no template — route to the Script Builder tab.
    setChatTab('script_builder')
  }, [activeFix])

  // Phase 9 Task 13: TemplateMatchPanel "I ran this" — stamps applied_at so the
  // ProposalBanner transitions from Proposed to Verifying. Shared useCallback so
  // both render sites (narrow-drawer + side-panel) are identical.
  const handleMarkRun = useCallback(async () => {
    if (!activeFix || !activeChatId) return
    try {
      const updated = await sessionSuggestedFixesApi.applyFix(
        activeChatId, activeFix.id,
      )
      setActiveFix(updated)
      setScriptPanelOpen(false)
    } catch { /* non-fatal: engineer can still mark outcome later */ }
  }, [activeFix, activeChatId])

  // Phase 8: record a terminal outcome for the active fix. Updates local state
  // on success. For applied_success also opens the Resolve preview.
  const handleSetOutcome = useCallback(async (outcome: FixOutcome, notes?: string) => {
    if (!activeChatId || !activeFix) return
    try {
      const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes)
      setActiveFix(updated)
      // Reset apply tracking state since we now have a terminal outcome.
      setPostApplyMsgCount(0)
      setNudgeSilenced(false)
      if (outcome === 'applied_success') {
        // Open the Resolve note preview so the engineer can post to PSA.
        handleOpenPreview('resolve')
      }
    } catch (err: unknown) {
      const status = (err as { response?: { status?: number; data?: { detail?: string } } })?.response?.status
      if (status === 409) {
        toast.warning('Outcome already recorded — session may already be in a terminal state.')
      } else {
        toast.error('Failed to record outcome')
      }
    }
  }, [activeChatId, activeFix, handleOpenPreview])

  // Phase 8: accept the AI-proposed outcome. Translates AI proposal outcome
  // names to FixOutcome values, then delegates to handleSetOutcome.
  // For partial, a non-empty notes string is required by the backend (400 on
  // empty). Fall back to a generic note if the AI's reason is blank.
  const handleAcceptAIProposal = useCallback(async () => {
    if (!activeFix?.ai_outcome_proposal) return
    const { outcome, reason } = activeFix.ai_outcome_proposal
    const fixOutcome: FixOutcome =
      outcome === 'success' ? 'applied_success'
      : outcome === 'failure' ? 'applied_failed'
      : 'applied_partial'
    const notes = fixOutcome === 'applied_partial'
      ? (reason?.trim() || 'Partially applied per AI detection')
      : fixOutcome === 'applied_failed'
      ? reason?.trim() || undefined
      : undefined
    await handleSetOutcome(fixOutcome, notes)
  }, [activeFix, handleSetOutcome])

  // Phase 8: reject the AI proposal — persist the rejection to the server so
  // the banner does not re-surface on the next refreshSessionDerived call.
  // Falls back to a local-state clear on error (non-fatal: banner may re-arm
  // on the next refetch, matching the previous behaviour).
  const handleRejectAIProposal = useCallback(async () => {
    if (!activeFix || !activeChatId) return
    try {
      const updated = await sessionSuggestedFixesApi.clearAIProposal(activeChatId, activeFix.id)
      setActiveFix(updated)
    } catch {
      // Non-fatal fallback: clear locally so the banner disappears immediately.
      setActiveFix({ ...activeFix, ai_outcome_proposal: null })
    }
  }, [activeFix, activeChatId])

  // Phase 8: silence the nudge banner without recording an outcome.
  const handleSilenceNudge = useCallback(() => {
    setNudgeSilenced(true)
    setPostApplyMsgCount(0)
  }, [])

  // Phase 8: Escalate intercept — capture fix outcome before proceeding.
  // Wraps the existing Escalate click (which opens ConcludeSessionModal).
  const handleEscalateClick = useCallback(() => {
    const inVerifyState =
      activeFix && (
        (!!activeFix.applied_at && activeFix.status === 'proposed') ||
        activeFix.status === 'applied_partial'
      )
    if (inVerifyState && activeFix) {
      setEscalateIntercept({ fixId: activeFix.id, fixTitle: activeFix.title })
      return
    }
    setShowConclude(true)
  }, [activeFix])

  const handleInterceptChoice = useCallback(async (choice: InterceptChoice, notes?: string) => {
    const stored = escalateIntercept
    if (!stored || !activeChatId) {
      setEscalateIntercept(null)
      return
    }
    const outcomeToSend: FixOutcome =
      choice === 'never_applied' ? 'dismissed' : choice
    try {
      const updated = await sessionSuggestedFixesApi.patchOutcome(
        activeChatId, stored.fixId, outcomeToSend, notes,
      )
      setActiveFix(updated)
      setEscalateIntercept(null)
      setShowConclude(true)
    } catch (err) {
      // applied_partial without notes (or any other 4xx) must surface — the
      // previous silent catch let engineers believe the partial outcome was
      // recorded while it was rejected server-side.
      const message = choice === 'applied_partial'
        ? 'Couldn’t record the partial outcome. Add a short note and try again.'
        : 'Couldn’t record the outcome before escalating. Try again.'
      toast.error(message)
      // Keep the intercept open so the engineer can retry (partial path can
      // re-enter the notes step from the dialog).
      if (import.meta.env.DEV) console.warn('[AssistantChat] intercept outcome failed:', err)
    }
  }, [activeChatId, escalateIntercept])

  // Phase 8: Resolve click — auto-mark applied_success if in verifying state
  // before opening the resolution note preview.
  const handleResolveClick = useCallback(async () => {
    if (activeFix && activeFix.applied_at && activeFix.status === 'proposed' && activeChatId) {
      try {
        const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, 'applied_success')
        setActiveFix(updated)
      } catch {
        // Non-fatal; user can still resolve.
      }
    }
    setShowConclude(true)
  }, [activeChatId, activeFix])

  const handleClosePreview = () => {
    setPreviewKind(null)
    setPreviewError(null)
  }

  const handleConfirmPost = async (markdown: string) => {
    if (!activeChatId || !previewKind) return
    setPreviewPosting(true)
    try {
      const out = previewKind === 'resolve'
        ? await sessionSuggestedFixesApi.postResolutionNote(activeChatId, markdown)
        : await sessionSuggestedFixesApi.postEscalationPackage(activeChatId, markdown)
      setActiveSessionStatus(out.session_status)

      if (out.outcome === 'resolved') {
        toast.success(
          out.verified_status_id
            ? `Posted to ${previewData?.target_ticket_ref ?? 'PSA'} · status ${out.verified_status_name}`
            : `Posted to ${previewData?.target_ticket_ref ?? 'PSA'}${out.status_transition_skipped_reason ? ' · status unchanged' : ''}`,
        )
      } else if (out.outcome === 'escalated') {
        toast.success(
          out.verified_status_id
            ? `Escalated · ${previewData?.target_ticket_ref ?? 'PSA'} status ${out.verified_status_name}`
            : `Escalated · handoff posted to ${previewData?.target_ticket_ref ?? 'PSA'}`,
        )
      } else if (out.outcome === 'resolved_local') {
        toast.success('Session resolved locally (no PSA ticket linked)')
      } else if (out.outcome === 'escalated_local') {
        toast.success('Session escalated locally (no PSA ticket linked)')
      }
      handleClosePreview()

      // Phase 6: on a successful Resolve (either external or local), check
      // for pending draft_templates rows created by the Phase 5 three-option
      // dialog. Show the TemplatizePrompt modal iff:
      //   - the account preference hasn't opted out
      //   - the session has at least one pending draft
      // Escalate doesn't trigger this flow — only resolution.
      if (out.outcome === 'resolved' || out.outcome === 'resolved_local') {
        try {
          const prefs = await accountPreferencesApi.get()
          if (prefs.preferences.templatize_prompt_enabled === false) return
          const drafts = await draftTemplatesApi.list(true)
          const forThisSession = drafts.filter(
            (d) => d.source_session_id === activeChatId,
          )
          if (forThisSession.length > 0) setTemplatizeQueue(forThisSession)
        } catch {
          // Soft-fail: the Resolve itself succeeded. A missing preference
          // or list fetch is not worth blocking the success toast.
        }
      }
    } catch (err: unknown) {
      console.error('[AssistantChat] confirm post failed:', err)
      const errResp = (err as { response?: { status?: number; data?: { detail?: string } } })?.response
      const status = errResp?.status
      const detail = errResp?.data?.detail
      if (status === 502) {
        toast.error(detail || 'PSA posted partially — see server logs.')
      } else if (status === 409) {
        toast.warning(detail || 'Session is already in that state.')
      } else {
        toast.error('Could not post. Please try again.')
      }
    } finally {
      setPreviewPosting(false)
    }
  }

  const selectChat = useCallback(async (chatId: string) => {
    currentChatRef.current = chatId
    setActiveChatId(chatId)
    // Clear TaskLane when switching chats — will restore from backend if available
    resetSessionDerivedState()
    setActiveSessionStatus(null)
    setActivePsaTicketId(null)
    // Fire facts + active-fix fetches in parallel with session detail.
    refreshSessionDerived(chatId)
    try {
      const detail = await aiSessionsApi.getSession(chatId)
      // Guard: if the user switched to a different chat while this API call was
      // in flight (e.g. clicked "New Chat"), discard stale results so we don't
      // clobber the new session's task lane state.
      if (currentChatRef.current !== chatId) return
      setActiveSessionStatus(detail.status)
      setActivePsaTicketId(detail.psa_ticket_id)
      setMessages(
        (detail.conversation_messages || []).map(m => ({
          role: m.role as 'user' | 'assistant',
          content: m.content,
        }))
      )
      // 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) {
          // Pre-load user's saved responses into sessionStorage BEFORE setting props
          // so TaskLane can restore them on mount/prop-change
          const responses = detail.pending_task_lane.responses
          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([])
    }
  }, [refreshSessionDerived])

  const handleNewChat = async () => {
    // Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
    // for the previous session sees a mismatch and bails — prevents stale task lane appearing
    // in the new empty session (same pattern as selectChat, which sets ref before its await).
    currentChatRef.current = null
    // Clear stale state immediately — don't wait for API to return.
    resetSessionDerivedState()
    setMessages([])
    setActiveSessionStatus('active')
    setActivePsaTicketId(null)
    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)
    } 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) {
        resetSessionDerivedState()
        setActiveChatId(null)
      }
    } catch {
      toast.error('Failed to delete chat')
    }
  }

  const handleTogglePin = async () => {
    // Pin/unpin not yet supported on unified sessions — no-op for now
    toast.info('Pin feature coming soon')
  }

  const handleSend = async () => {
    if (!input.trim() || !activeChatId || loading) return

    const userMessage = input.trim()
    const completedUploads = pendingUploads.filter((u) => u.status === 'done' && u.result?.id)
    const completedUploadIds = completedUploads.map((u) => u.result!.id)
    const imageUrls = completedUploads
      .filter((u) => u.preview)
      .map((u) => u.preview)
    setInput('')
    setPendingUploads([])
    setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }])
    setLoading(true)

    const sentForChatId = activeChatId
    try {
      const response = await aiSessionsApi.sendChatMessage(activeChatId, {
        message: userMessage,
        upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined,
      })
      // Guard: discard if user switched to a different chat while this was in flight
      if (currentChatRef.current !== sentForChatId) return
      analytics.aiFeatureUsed({ feature: 'assistant_chat' })
      setMessages(prev => [
        ...prev,
        { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
      ])
      setChats(prev =>
        prev.map(c =>
          c.id === sentForChatId
            ? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() }
            : c
        )
      )
      // Load branches if fork was created
      if (response.fork && sentForChatId) {
        branching.loadBranches(sentForChatId)
      }
      // Show task lane if AI sent questions or actions
      const hasQuestions = response.questions && response.questions.length > 0
      const hasActions = response.actions && response.actions.length > 0
      if (hasQuestions || hasActions) {
        clearTaskState(sentForChatId)
        setActiveQuestions(response.questions || [])
        setActiveActions(response.actions || [])
        setShowTaskLane(true)
      }
      // Phase 8: increment post-apply message counter for nudge logic.
      // Only increments when fix is still in 'proposed' (verifying) state —
      // partial/dismissed/terminal states don't render the nudge, and a
      // partial→verifying transition could inherit an already-saturated counter.
      if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') {
        setPostApplyMsgCount(c => c + 1)
      }
      // Refetch facts + active fix; preview refreshes if open.
      refreshSessionDerived(sentForChatId)
    } catch (err: unknown) {
      console.error('[AssistantChat] sendChatMessage failed:', err)
      const status = (err as { response?: { status?: number } })?.response?.status
      let errorMsg: string
      if (status === 429) {
        errorMsg = "You're sending messages too quickly. Wait a moment and try again."
      } else if (status === 502 || status === 503) {
        errorMsg = "The AI is temporarily unavailable. Please try again in a few seconds."
      } else {
        errorMsg = "Something went wrong sending your message. Please try again."
      }
      // Remove the optimistic user message and restore it to the input so they can retry
      setMessages(prev => prev.slice(0, -1))
      setInput(userMessage)
      toast.error(errorMsg)
    } finally {
      setLoading(false)
      requestAnimationFrame(() => inputRef.current?.focus())
    }
  }

  const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
    if (!activeChatId || loading) return

    // Format task responses into a structured message for the AI.
    // Pending tasks are included so the AI knows they weren't completed yet.
    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)_`)
      } else {
        parts.push(`**${name}:** _(not yet completed)_`)
      }
    }
    const userMessage = parts.join('\n\n')

    setMessages(prev => [...prev, { role: 'user', content: userMessage }])
    setLoading(true)

    const sentForChatId = activeChatId
    try {
      const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage })
      // Guard: discard if user switched to a different chat while this was in flight
      if (currentChatRef.current !== sentForChatId) return
      setMessages(prev => [
        ...prev,
        { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
      ])
      if (response.fork && sentForChatId) {
        branching.loadBranches(sentForChatId)
      }
      // Update task lane based on AI response
      const hasQuestions = response.questions && response.questions.length > 0
      const hasActions = response.actions && response.actions.length > 0
      clearTaskState(sentForChatId)
      if (hasQuestions || hasActions) {
        setActiveQuestions(response.questions || [])
        setActiveActions(response.actions || [])
        setShowTaskLane(true)
      } else {
        // AI sent no new tasks — clear the lane
        setShowTaskLane(false)
        setActiveQuestions([])
        setActiveActions([])
      }
      // Phase 8: increment post-apply message counter for nudge logic (mirrors handleSend).
      // Only increments in 'proposed' (verifying) state — same rationale as handleSend.
      if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') {
        setPostApplyMsgCount(c => c + 1)
      }
      // Refetch facts + active fix; answering tasks is the primary trigger.
      refreshSessionDerived(sentForChatId)
    } catch (err: unknown) {
      console.error('[AssistantChat] handleTaskSubmit failed:', err)
      const status = (err as { response?: { status?: number } })?.response?.status
      const errorMsg = status === 429
        ? "You're sending messages too quickly. Wait a moment and try again."
        : "Something went wrong submitting your responses. Please try again."
      setMessages(prev => prev.slice(0, -1))
      toast.error(errorMsg)
    } finally {
      setLoading(false)
    }
  }

  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',
      })
      setActiveSessionStatus('resolved')
      return activeChatId
    } else if (outcome === 'escalated') {
      await aiSessionsApi.escalateSession(activeChatId, {
        escalation_reason: _notes || 'Escalated from assistant chat',
      })
      setActiveSessionStatus('escalated')
      return activeChatId
    } else {
      await aiSessionsApi.pauseSession(activeChatId)
      setActiveSessionStatus('paused')
      return activeChatId
    }
  }

  const handleResumeNew = async (summary: string) => {
    // Invalidate currentChatRef BEFORE the await — same guard as handleNewChat
    currentChatRef.current = null
    // Clear stale state immediately — don't wait for API to return.
    resetSessionDerivedState()
    setActiveSessionStatus('active')
    setActivePsaTicketId(null)
    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 response = await aiSessionsApi.sendChatMessage(session.session_id, { message: resumePrompt })
      // Guard: discard if user switched to a different chat while this was in flight
      if (currentChatRef.current !== session.session_id) return
      setMessages(prev => [
        ...prev,
        { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
      ])
      setChats(prev =>
        prev.map(c =>
          c.id === session.session_id
            ? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() }
            : c
        )
      )
      // Show task lane if AI sent questions or actions
      if (response.fork && session.session_id) {
        branching.loadBranches(session.session_id)
      }
      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)
      }
      // Refetch facts + active fix — resume turn may emit markers.
      refreshSessionDerived(session.session_id)
    } catch {
      toast.error('Failed to create resume chat')
    } finally {
      setLoading(false)
    }
  }

  const handleKeyDown = (e: React.KeyboardEvent) => {
    // ⌘↵ / Ctrl+↵ is the FlowPilot-wide "send" shortcut (Phase 7 spec).
    // Plain Enter (without modifiers, without shift) also sends to preserve
    // existing composer ergonomics. Shift+Enter keeps the newline behavior.
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault()
      handleSend()
      return
    }
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault()
      handleSend()
    }
  }

  // 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])

  // Phase 7: global keyboard shortcuts. The hook auto-skips keypresses that
  // originate inside inputs/textareas, so `?` and ⌘G won't fight the composer.
  // ⌘K is handled by the TopBar (opens the global command palette).
  useKeyboardShortcuts([
    {
      key: '?',
      shift: true,
      handler: () => setShortcutsHelpOpen((v) => !v),
    },
    {
      key: 'g',
      ctrl: true,
      handler: () => {
        if (activeFix) setScriptPanelOpen((v) => !v)
      },
      enabled: activeFix !== null,
    },
  ])

  // ── File handling ──────────────────────────────
  const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx'

  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])

  // Cleanup blob URLs on unmount
  useEffect(() => { return () => { pendingUploads.forEach((u) => { if (u.preview) URL.revokeObjectURL(u.preview) }) } }, []) // eslint-disable-line react-hooks/exhaustive-deps

  return (
    <>
    <PageMeta title="AI Assistant" />
    <div className="flex h-[calc(100vh-3.5rem)]">
      {/* Sidebar — hidden on mobile, collapsed to top bar or full sidebar on desktop */}
      {!sidebarCollapsed && (
        <div className="hidden sm:block">
          <ChatSidebar
            chats={chats}
            activeChatId={activeChatId}
            onSelectChat={selectChat}
            onNewChat={handleNewChat}
            onDeleteChat={handleDeleteChat}
            onTogglePin={handleTogglePin}
            onToggleCollapse={toggleSidebarCollapse}
          />
        </div>
      )}
      <div className="sm:hidden">
        <ChatSidebar
          chats={chats}
          activeChatId={activeChatId}
          onSelectChat={selectChat}
          onNewChat={handleNewChat}
          onDeleteChat={handleDeleteChat}
          onTogglePin={handleTogglePin}
          mobileOpen={mobileSidebarOpen}
          onMobileClose={() => setMobileSidebarOpen(false)}
        />
      </div>

      {/* Main chat area + optional branch sidebar */}
      <div className="flex-1 flex flex-col min-w-0">

      {/* Collapsed sidebar top bar — desktop only */}
      {sidebarCollapsed && (
        <div className="hidden sm:block">
          <ChatSidebarCollapsedBar
            chats={chats}
            activeChatId={activeChatId}
            onNewChat={handleNewChat}
            onExpand={toggleSidebarCollapse}
          />
        </div>
      )}

      {/* Chat content row: chat column + TaskLane side by side */}
      <div className="flex-1 flex min-w-0 min-h-0">
      <div className="flex-1 flex flex-col min-w-0">
        {/* Mobile header with chat history toggle */}
        <div className="sm:hidden flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
          <button
            onClick={() => 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" />
          <button
            onClick={handleNewChat}
            className="rounded-lg px-3 py-2 text-sm font-medium text-primary hover:bg-primary/10 transition-colors"
          >
            + New
          </button>
        </div>

        {activeChatId ? (
          <>
            {/* Session header — title + lifecycle actions */}
            {(() => {
              const chatTitle = chats.find(c => c.id === activeChatId)?.title
              const isActive = activeSessionStatus === 'active' || activeSessionStatus === null
              const canAct = messages.length >= 2 && isActive && !loading
              const updateLabel = activePsaTicketId ? 'Update Ticket' : 'Share Update'
              return (
                <div className="flex items-center gap-3 border-b border-border px-3 sm:px-5 py-2.5 shrink-0">
                  <span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-dim shrink-0">
                    <Sparkles size={14} className="text-primary" />
                  </span>
                  <div className="flex-1 min-w-0">
                    <h1 className="font-heading text-sm font-semibold text-foreground truncate">
                      {chatTitle || 'AI Assistant'}
                    </h1>
                    {activeSessionStatus && activeSessionStatus !== 'active' && (
                      <span className={cn(
                        'text-[0.625rem] font-medium uppercase tracking-wide',
                        activeSessionStatus === 'resolved' ? 'text-success' :
                        activeSessionStatus === 'escalated' || activeSessionStatus === 'requesting_escalation' ? 'text-warning' :
                        activeSessionStatus === 'paused' ? 'text-muted-foreground' : 'text-muted-foreground'
                      )}>
                        {activeSessionStatus === 'requesting_escalation' ? 'Escalated' : activeSessionStatus.charAt(0).toUpperCase() + activeSessionStatus.slice(1)}
                      </span>
                    )}
                  </div>

                  {/* Desktop actions — shown when session is active and has messages */}
                  <div className="hidden sm:flex items-center gap-1.5">
                    {isActive && (
                      <>
                        <button
                          onClick={handleResolveClick}
                          disabled={!canAct}
                          data-conclude-outcome="resolved"
                          className="flex items-center gap-1.5 rounded-lg bg-success-dim border border-success/20 px-3 py-1.5 text-xs font-medium text-success hover:bg-success/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
                        >
                          <CheckCircle2 size={13} />
                          Resolve
                        </button>
                        <div className="relative">
                        <button
                          onClick={handleEscalateClick}
                          disabled={!canAct}
                          data-conclude-outcome="escalated"
                          className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
                        >
                          <ArrowUpRight size={13} />
                          Escalate
                        </button>
                        {escalateIntercept && (
                          <EscalateInterceptDialog
                            fixTitle={escalateIntercept.fixTitle}
                            onChoose={handleInterceptChoice}
                            onClose={() => setEscalateIntercept(null)}
                          />
                        )}
                        </div>
                      </>
                    )}
                    {messages.length >= 2 && (
                      <button
                        onClick={() => setShowStatusUpdate(true)}
                        disabled={loading}
                        className="flex items-center gap-1.5 rounded-lg bg-accent-dim border border-accent/20 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
                      >
                        <FileText size={13} />
                        {updateLabel}
                      </button>
                    )}
                    {/* Overflow: Pause / — */}
                    {isActive && messages.length >= 2 && (
                      <div className="relative">
                        <button
                          onClick={() => setShowOverflow(!showOverflow)}
                          className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
                        >
                          <MoreHorizontal size={16} />
                        </button>
                        {showOverflow && (
                          <>
                            <div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
                            <div className="absolute right-0 top-full mt-1 z-50 w-36 rounded-lg border border-border bg-card py-1 shadow-lg">
                              <button
                                onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
                                className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
                              >
                                <Pause size={13} />
                                Pause
                              </button>
                            </div>
                          </>
                        )}
                      </div>
                    )}
                  </div>

                  {/* Mobile: single overflow menu */}
                  {messages.length >= 2 && (
                    <div className="sm:hidden relative">
                      <button
                        onClick={() => setShowOverflow(!showOverflow)}
                        className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
                      >
                        <MoreHorizontal size={18} />
                      </button>
                      {showOverflow && (
                        <>
                          <div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
                          <div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
                            {isActive && (
                              <>
                                <button
                                  onClick={() => { setShowOverflow(false); handleResolveClick() }}
                                  disabled={!canAct}
                                  className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-success hover:bg-success-dim transition-colors disabled:opacity-40"
                                >
                                  <CheckCircle2 size={14} />
                                  Resolve
                                </button>
                                {/* Mobile Escalate: wrapped in relative so EscalateInterceptDialog anchors here */}
                                <div className="relative">
                                  <button
                                    onClick={() => { setShowOverflow(false); handleEscalateClick() }}
                                    disabled={!canAct}
                                    className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-warning hover:bg-warning-dim transition-colors disabled:opacity-40"
                                  >
                                    <ArrowUpRight size={14} />
                                    Escalate
                                  </button>
                                  {/* Mobile intercept dialog — mirrors desktop; only one is visible at a time */}
                                  {escalateIntercept && (
                                    <EscalateInterceptDialog
                                      fixTitle={escalateIntercept.fixTitle}
                                      onChoose={handleInterceptChoice}
                                      onClose={() => setEscalateIntercept(null)}
                                    />
                                  )}
                                </div>
                              </>
                            )}
                            <button
                              onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
                              disabled={loading}
                              className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-accent hover:bg-accent-dim transition-colors disabled:opacity-40"
                            >
                              <FileText size={14} />
                              {updateLabel}
                            </button>
                            {isActive && (
                              <button
                                onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
                                className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
                              >
                                <Pause size={14} />
                                Pause
                              </button>
                            )}
                          </div>
                        </>
                      )}
                    </div>
                  )}
                </div>
              )
            })()}

            {/* Phase 9: ChatTabStrip — shown when the fix needs a script drafted */}
            {showTabStrip && (
              <ChatTabStrip
                active={chatTab}
                onChange={setChatTab}
                scriptBuilderHasProgress={scriptBuilderHasProgress}
              />
            )}

            {/* Chat tab content — messages + banner + composer.
                Hidden (not unmounted) when Script Builder tab is active so
                scroll position and input state are preserved. */}
            <div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}>
            {/* Messages */}
            <div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4">
              {messages.length === 0 && !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">
                    AI Assistant
                  </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>
              )}
              {messages.map((msg, i) => (
                <ChatMessage
                  key={i}
                  role={msg.role}
                  content={msg.content}
                  suggestedFlows={msg.suggestedFlows}
                  imageUrls={msg.imageUrls}
                />
              ))}
              {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={messagesEndRef} />
            </div>

            {/* Phase 8: ProposalBanner — mounted above the composer */}
            {activeFix && bannerMode && (
              <ProposalBanner
                fix={activeFix}
                mode={bannerMode}
                collapsed={bannerCollapsed && bannerMode !== 'nudge' && bannerMode !== 'ai_confirming'}
                onToggleCollapsed={() => setBannerCollapsed(v => !v)}
                onApply={handleApplyFix}
                onDismiss={() => handleSetOutcome('dismissed')}
                onOutcome={handleSetOutcome}
                onAcceptAIProposal={handleAcceptAIProposal}
                onRejectAIProposal={handleRejectAIProposal}
                onSilenceNudge={handleSilenceNudge}
              />
            )}

            {/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case,
                rendered in the chat region above the composer so all three
                option cards fit side-by-side without the TaskLane's narrow width. */}
            {scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
              <InlineNoTemplateDialog
                fix={activeFix}
                onClose={() => setScriptPanelOpen(false)}
                onDecide={handleScriptDecision}
                busy={scriptDecisionBusy}
              />
            )}

            {/* Rich Input */}
            <div className="px-3 sm:px-6 py-3 shrink-0">
              <div
                className="max-w-3xl mx-auto"
                onDragOver={handleDragOver}
                onDragEnter={handleDragEnter}
                onDragLeave={handleDragLeave}
                onDrop={handleDrop}
              >
                <div className={cn(
                  'relative rounded-xl border transition-all',
                  loading ? 'border-border/50 opacity-50' :
                  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)' }}>
                  {/* Drag overlay */}
                  {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 */}
                  <textarea
                    ref={inputRef}
                    value={input}
                    onChange={e => setInput(e.target.value)}
                    onKeyDown={handleKeyDown}
                    onPaste={handlePaste}
                    placeholder={loading ? 'AI is thinking...' : 'Type a message, paste a screenshot, or drag a file...'}
                    disabled={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' }}
                  />

                  {/* Thumbnail strip */}
                  {pendingUploads.length > 0 && (
                    <div className="flex gap-2 flex-wrap px-4 pb-1">
                      {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={() => 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-danger/20 transition-colors">
                              <X size={8} className="text-muted-foreground" />
                            </button>
                          )}
                          {upload.status === 'error' && (
                            <div className="absolute inset-0 bg-danger/20 border-2 border-danger flex items-center justify-center cursor-pointer" onClick={() => retryUpload(upload.id)}>
                              <RotateCcw size={10} className="text-danger" />
                            </div>
                          )}
                        </div>
                      ))}
                    </div>
                  )}

                  {/* Logs textarea */}
                  {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={() => { setShowLogs(false); setLogContent('') }} className="text-muted-foreground hover:text-foreground"><X size={14} /></button>
                      </div>
                      <textarea
                        value={logContent}
                        onChange={(e) => 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>
                  )}

                  {/* Bottom toolbar */}
                  <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={fileInputRef} type="file" multiple accept={ACCEPTED_FILE_TYPES} onChange={handleFileSelect} className="hidden" />
                      <button type="button" onClick={() => fileInputRef.current?.click()} disabled={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>
                      {!showLogs && (
                        <button type="button" onClick={() => setShowLogs(true)} disabled={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>
                      )}
                      {!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
                        <button
                          type="button"
                          onClick={() => 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 ({activeQuestions.length + activeActions.length})
                        </button>
                      )}
                    </div>
                    <button type="button" onClick={handleSend} disabled={!input.trim() || loading} className={cn(
                      'flex h-8 w-8 items-center justify-center rounded-lg transition-all',
                      input.trim() && !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>{/* end chat-tab content wrapper */}

            {/* Phase 9: Script Builder tab — mounted alongside chat via display:none
                so both scroll positions and state are preserved across tab switches. */}
            {showTabStrip && activeFix && activeChatId && (
              <div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'script_builder' && 'hidden')}>
                {/* key={activeFix.id} forces a fresh mount when the active
                    fix changes within the same pilot session — otherwise
                    stale messages / editorBuffer / latestScript from the
                    prior fix could submit against the new fix.id. */}
                <ScriptBuilderTab
                  key={activeFix.id}
                  fix={activeFix}
                  pilotSessionId={activeChatId}
                  onProgressChange={setScriptBuilderHasProgress}
                  onScriptDrafted={(updated) => {
                    setActiveFix(updated)
                    setChatTab('chat')
                    setScriptBuilderHasProgress(false)
                  }}
                />
              </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">
              AI Assistant
            </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={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>

      {/* Phase 7: on narrow viewports (<1200px) the lane is a bottom drawer
          that's hidden by default; a floating "Tasks" button toggles it.
          Shows a count pill when new items are present while closed. */}
      {isNarrow
        && !showTaskLane
        && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
        <button
          onClick={() => setShowTaskLane(true)}
          className="fixed bottom-4 right-4 z-40 flex items-center gap-2 rounded-full bg-accent text-white px-4 py-2.5 shadow-lg hover:bg-accent-hover transition-colors"
          title="Open tasks drawer"
        >
          <ListChecks size={14} />
          <span className="text-[0.8125rem] font-semibold">Tasks</span>
          {(activeQuestions.length + activeActions.length) > 0 && (
            <span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[0.625rem] font-bold tabular-nums">
              {activeQuestions.length + activeActions.length}
            </span>
          )}
        </button>
      )}

      {/* Task lane — slides in when AI sends questions/actions OR when the
          session has any "What we know" facts OR an active suggested fix.
          Phase 2/3 make the lane the structural home of session diagnostic
          state, not a transient questions panel.
          Narrow viewport: the lane renders as a bottom drawer with backdrop. */}
      {showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
        isNarrow ? (
          <div className="fixed inset-0 z-50 flex flex-col" role="dialog" aria-modal="true">
            <div
              className="flex-1 bg-black/50"
              onClick={() => setShowTaskLane(false)}
            />
            <div className="h-[80vh] bg-bg-page">
              <TaskLane
                variant="drawer"
                questions={activeQuestions}
                actions={activeActions}
                sessionId={activeChatId}
                onSubmit={handleTaskSubmit}
                onClose={() => setShowTaskLane(false)}
                loading={loading}
                whatWeKnowSlot={
                  <WhatWeKnow
                    facts={facts}
                    onAddNote={handleAddNote}
                    onUpdateFact={handleUpdateFact}
                    onDeleteFact={handleDeleteFact}
                    loading={loading}
                  />
                }
                bottomSlot={
                  <>
                    {scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
                      <TemplateMatchPanel
                        fix={activeFix}
                        sessionId={activeChatId}
                        onClose={() => setScriptPanelOpen(false)}
                        onMarkRun={handleMarkRun}
                      />
                    )}
                    <div className="flex items-center gap-3 px-3 mt-1">
                      <button
                        onClick={() => handleOpenPreview('resolve')}
                        className={cn(
                          'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
                          previewKind === 'resolve'
                            ? 'text-success'
                            : 'text-accent-text hover:text-heading',
                        )}
                      >
                        <FileText size={12} />
                        {previewKind === 'resolve' ? 'Showing' : 'Preview'} Resolve note
                      </button>
                      <button
                        onClick={() => handleOpenPreview('escalate')}
                        className={cn(
                          'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
                          previewKind === 'escalate'
                            ? 'text-warning'
                            : 'text-muted-foreground hover:text-heading',
                        )}
                      >
                        <ArrowUpRight size={12} />
                        {previewKind === 'escalate' ? 'Showing' : 'Escalate instead'}
                      </button>
                    </div>
                    <ResolutionNotePreviewPopover
                      kind={previewKind ?? 'resolve'}
                      open={previewOpen}
                      loading={previewLoading}
                      preview={previewData}
                      error={previewError}
                      onClose={handleClosePreview}
                      onRefresh={() => { if (activeChatId) return refreshPreview(activeChatId) }}
                      onConfirm={handleConfirmPost}
                      posting={previewPosting}
                    />
                  </>
                }
              />
            </div>
          </div>
        ) : (
        <TaskLane
          questions={activeQuestions}
          actions={activeActions}
          sessionId={activeChatId}
          onSubmit={handleTaskSubmit}
          onClose={() => {
            setShowTaskLane(false)
          }}
          loading={loading}
          whatWeKnowSlot={
            <WhatWeKnow
              facts={facts}
              onAddNote={handleAddNote}
              onUpdateFact={handleUpdateFact}
              onDeleteFact={handleDeleteFact}
              loading={loading}
            />
          }
          bottomSlot={
            <>
              {scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
                <TemplateMatchPanel
                  fix={activeFix}
                  sessionId={activeChatId}
                  onClose={() => setScriptPanelOpen(false)}
                  onMarkRun={handleMarkRun}
                />
              )}
              <div className="flex items-center gap-3 px-3 mt-1">
                <button
                  onClick={() => handleOpenPreview('resolve')}
                  className={cn(
                    'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
                    previewKind === 'resolve'
                      ? 'text-success'
                      : 'text-accent-text hover:text-heading',
                  )}
                >
                  <FileText size={12} />
                  {previewKind === 'resolve' ? 'Showing' : 'Preview'} Resolve note
                </button>
                <button
                  onClick={() => handleOpenPreview('escalate')}
                  className={cn(
                    'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
                    previewKind === 'escalate'
                      ? 'text-warning'
                      : 'text-muted-foreground hover:text-heading',
                  )}
                >
                  <ArrowUpRight size={12} />
                  {previewKind === 'escalate' ? 'Showing' : 'Escalate instead'}
                </button>
              </div>
              <ResolutionNotePreviewPopover
                kind={previewKind ?? 'resolve'}
                open={previewOpen}
                loading={previewLoading}
                preview={previewData}
                error={previewError}
                onClose={handleClosePreview}
                onRefresh={() => { if (activeChatId) return refreshPreview(activeChatId) }}
                onConfirm={handleConfirmPost}
                posting={previewPosting}
              />
            </>
          }
        />
        )
      )}

      {/* Branch map hidden — branching is now silent/background only.
          Branches are tracked in the DB but not shown to the user.
          The AI manages branch context internally. */}
      </div>{/* close chat content row */}
      </div>{/* close outer flex-col */}

      {/* Conclude Session Modal */}
      <ConcludeSessionModal
        isOpen={showConclude}
        onClose={() => setShowConclude(false)}
        onConclude={handleConclude}
        onResumeNew={handleResumeNew}
        chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
        sessionId={activeChatId}
      />

      {/* Status Update Modal */}
      {activeChatId && (
        <StatusUpdateModal
          open={showStatusUpdate}
          onClose={() => setShowStatusUpdate(false)}
          onGenerate={(audience, length, context) =>
            aiSessionsApi.generateStatusUpdate(activeChatId, { audience, length, context })
          }
          context="status"
        />
      )}

      {/* Phase 6: post-resolve "save as team template?" modal. Shown one draft
          at a time; onResolved advances the queue. */}
      {templatizeQueue.length > 0 && (
        <TemplatizePrompt
          draft={templatizeQueue[0]}
          sourceTicketRef={activePsaTicketId ? `CW #${activePsaTicketId}` : null}
          onResolved={() => setTemplatizeQueue((q) => q.slice(1))}
        />
      )}

      {/* Phase 7: keyboard-shortcut help. `?` from anywhere inside /pilot. */}
      <ShortcutsHelpOverlay
        open={shortcutsHelpOpen}
        onClose={() => setShortcutsHelpOpen(false)}
      />
    </div>
    </>
  )
}
