'use client';

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useAgentHookHealth } from '@/hooks/useAgentHookHealth';
import { useAgentMessages } from '@/hooks/useAgentMessages';
import { useAuthorization } from '@/hooks/useAuthorization';
import { useCatData } from '@/hooks/useCatData';
import { useChatHistory } from '@/hooks/useChatHistory';
import { useChatSocketCallbacks } from '@/hooks/useChatSocketCallbacks';
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
import { godAction, submitAction } from '@/hooks/useGameApi';
import { reconnectGame } from '@/hooks/useGameReconnect';
import { useGovernanceStatus } from '@/hooks/useGovernanceStatus';
import { useIndexState } from '@/hooks/useIndexState';
import { useIsDesktop } from '@/hooks/useIsDesktop';
import { usePersistedState } from '@/hooks/usePersistedState';
import { usePreviewAutoOpen } from '@/hooks/usePreviewAutoOpen';
import { useSendMessage } from '@/hooks/useSendMessage';
import { useSocket } from '@/hooks/useSocket';
import { useSplitPaneKeys } from '@/hooks/useSplitPaneKeys';
import { useThreadLiveness, useThreadMessages } from '@/hooks/useThreadScopedSelectors';
import { useVadInterrupt } from '@/hooks/useVadInterrupt';
import { useVoiceAutoPlay } from '@/hooks/useVoiceAutoPlay';
import { useVoiceStream } from '@/hooks/useVoiceStream';
import { useWorkspaceNavigate } from '@/hooks/useWorkspaceNavigate';
import { type ChatMessage as ChatMessageData, type Thread, useChatStore } from '@/stores/chatStore';
import { useGameStore } from '@/stores/gameStore';
import { useGuideStore } from '@/stores/guideStore';
import { useSidebarStore } from '@/stores/sidebarStore';
import { useTaskStore } from '@/stores/taskStore';
import { apiFetch } from '@/utils/api-client';
import { computeCliDiagnosticsDedup } from '@/utils/cli-diagnostics-dedup';
import { computeScrollRecomputeSignal } from '@/utils/scrollRecomputeSignal';
import { getUserId } from '@/utils/userId';
import { AgentHookHealthNotice, shouldRenderAgentHookHealthNotice } from './AgentHookHealthNotice';
import { AuthorizationCard } from './AuthorizationCard';
import { BootcampListModal } from './BootcampListModal';
import { BootstrapOrchestrator } from './BootstrapOrchestrator';
import { ChatContainerHeader } from './ChatContainerHeader';
import { ChatInput } from './ChatInput';
import { ChatMessage } from './ChatMessage';
import { ConnectionStatusBar } from './ConnectionStatusBar';
import { FirstRunQuestWizard } from './FirstRunQuestWizard';
import { BootcampGuideOverlay } from './first-run-quest/BootcampGuideOverlay';
import { QuestBanner } from './first-run-quest/QuestBanner';
import { syncLocalBootcampState } from './first-run-quest/syncLocalBootcampState';
import { useFirstProjectMistakeTipGate } from './first-run-quest/useFirstProjectMistakeTipGate';
import { useFirstProjectPreviewAutoOpen } from './first-run-quest/useFirstProjectPreviewAutoOpen';
import { GameOverlayConnector } from './game/GameOverlayConnector';
import { HubCatEditor } from './HubCatEditor';
import { BootcampIcon } from './icons/BootcampIcon';
import { PawIcon } from './icons/PawIcon';
import { MessageActions } from './MessageActions';
import { MessageNavigator } from './MessageNavigator';
import { MobileStatusSheet } from './MobileStatusSheet';
import { ParallelStatusBar } from './ParallelStatusBar';
import { ProjectSetupCard } from './ProjectSetupCard';
import { QueuePanel } from './QueuePanel';
import { RightStatusPanel } from './RightStatusPanel';
import { ScrollToBottomButton } from './ScrollToBottomButton';
import { SplitPaneView } from './SplitPaneView';
import { ThinkingIndicator } from './ThinkingIndicator';
import { ThreadExecutionBar } from './ThreadExecutionBar';
import { ThreadSidebar } from './ThreadSidebar';
import { assignDocumentRoute, pushThreadRouteWithHistory } from './ThreadSidebar/thread-navigation';
import { VoteActiveBar } from './VoteActiveBar';
import { type VoteConfig, VoteConfigModal } from './VoteConfigModal';
import { WorkspacePanel } from './WorkspacePanel';
import { FloatingTranscriptContainer } from './workspace/FloatingTranscriptContainer';
import { ResizeHandle } from './workspace/ResizeHandle';
import { TranscriptPanel } from './workspace/TranscriptPanel';

interface ChatContainerProps {
  threadId: string;
}

export function ChatContainer({ threadId }: ChatContainerProps) {
  const bottomChromeRef = useRef<HTMLDivElement | null>(null);
  const bottomChromeObserverRef = useRef<ResizeObserver | null>(null);
  const bottomChromeObserverRafRef = useRef<number | null>(null);
  const {
    setCurrentThread,
    viewMode,
    setViewMode,
    isLoading: chatIsLoading,
    clearUnread,
    confirmUnreadAck,
    armUnreadSuppression,
    rightPanelMode,
  } = useChatStore();
  // F173 Phase C Task 3 — full read-side migration. All thread liveness +
  // messages now flow through thread-scoped selectors keyed off this
  // component's `threadId` prop, not the flat current-thread mirror. Closes
  // AC-C6 race window for the entire ChatContainer surface (Task 2 only
  // covered hasActiveInvocation; this finishes the job).
  const messages = useThreadMessages(threadId);
  const {
    hasActive: hasActiveInvocation,
    activeInvocations,
    catStatuses,
    catInvocations,
    intentMode,
    targetCats,
  } = useThreadLiveness(threadId);
  const navigateToThread = useCallback((tid: string) => {
    pushThreadRouteWithHistory(tid, typeof window !== 'undefined' ? window : undefined);
  }, []);
  const uiThinkingExpandedByDefault = useChatStore((s) => s.uiThinkingExpandedByDefault);
  const isOfflineSnapshot = useChatStore((s) => s.isOfflineSnapshot);

  // F101: Game state from Zustand store
  const gameView = useGameStore((s) => s.gameView);
  const isGameActive = useGameStore((s) => s.isGameActive);
  const isNight = useGameStore((s) => s.isNight);
  const selectedTarget = useGameStore((s) => s.selectedTarget);
  const godScopeFilter = useGameStore((s) => s.godScopeFilter);
  const myRole = useGameStore((s) => s.myRole);
  const myRoleIcon = useGameStore((s) => s.myRoleIcon);
  const myActionLabel = useGameStore((s) => s.myActionLabel);
  const myActionHint = useGameStore((s) => s.myActionHint);
  const isGodView = useGameStore((s) => s.isGodView);
  const isDetective = useGameStore((s) => s.isDetective);
  const detectiveBoundName = useGameStore((s) => s.detectiveBoundName);
  const godSeats = useGameStore((s) => s.godSeats);
  const godNightSteps = useGameStore((s) => s.godNightSteps);
  const hasTargetedAction = useGameStore((s) => s.hasTargetedAction);
  const altActionName = useGameStore((s) => s.altActionName);
  const overlayMinimized = useGameStore((s) => s.overlayMinimized);

  // Export mode: ?export=true triggers print-friendly layout (no scroll containers)
  const searchParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
  const isExport = searchParams?.get('export') === 'true';
  // AC-6: research=multi hint from Signal study "多猫研究" button
  const isResearchMode = searchParams?.get('research') === 'multi';
  const { clearTasks } = useTaskStore();
  const { cats, getCatById, refresh: refreshCats, isLoading, hasFetched } = useCatData();
  const workspaceWorktreeId = useChatStore((s) => s.workspaceWorktreeId);
  usePreviewAutoOpen(workspaceWorktreeId, threadId);
  useWorkspaceNavigate(workspaceWorktreeId, threadId);
  const { isOpen: sidebarOpen, open: openSidebar, close: closeSidebar, toggle: toggleSidebar } = useSidebarStore();
  const [statusPanelOpen, setStatusPanelOpen] = useState(true);
  const [mobileStatusOpen, setMobileStatusOpen] = useState(false);
  const [showBootcampList, setShowBootcampList] = useState(false);
  const [editingCatId, setEditingCatId] = useState<string | null>(null);
  const editingCat = editingCatId ? (getCatById(editingCatId) ?? null) : null;
  const [showFirstRunQuestPrompt, setShowFirstRunQuestPrompt] = useState(false);
  const [showQuestWizard, setShowQuestWizard] = useState(false);
  // F106: fetch bootcamp count independently of sidebar lifecycle
  // refreshKey increments only on modal close → avoids duplicate fetch on open
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const [_bootcampRefreshKey, setBootcampRefreshKey] = useState(0);
  const handleBootcampModalClose = useCallback(() => {
    setShowBootcampList(false);
    setBootcampRefreshKey((k) => k + 1);
  }, []);
  const [bootcampCount, setBootcampCount] = useState(0);
  useEffect(() => {
    let cancelled = false;
    apiFetch('/api/bootcamp/threads')
      .then(async (res) => {
        if (cancelled || !res.ok) return;
        const data = await res.json();
        if (!cancelled) setBootcampCount(data.threads?.length ?? 0);
      })
      .catch(() => {
        if (!cancelled) setBootcampCount(0);
      });
    return () => {
      cancelled = true;
    };
  }, []);
  // F063: resizable split pane — chatBasis as percentage (20-80), persisted
  const [chatBasis, setChatBasis, resetChatBasis] = usePersistedState('cat-cafe:chatBasis', 50);
  // clowder-ai#28: right status panel width in px, persisted
  const STATUS_PANEL_DEFAULT = 288; // w-72
  const [statusPanelWidth, setStatusPanelWidth, resetStatusPanelWidth] = usePersistedState(
    'cat-cafe:statusPanelWidth',
    STATUS_PANEL_DEFAULT,
  );
  const containerRef = useRef<HTMLDivElement>(null);
  const handleHorizontalResize = useCallback(
    (delta: number) => {
      if (!containerRef.current) return;
      const totalWidth = containerRef.current.offsetWidth;
      if (totalWidth === 0) return;
      const pct = (delta / totalWidth) * 100;
      setChatBasis((prev) => Math.min(80, Math.max(20, prev + pct)));
    },
    [setChatBasis],
  );
  // clowder-ai#28: drag-to-resize for right status panel (negative delta = panel wider)
  const handleStatusPanelResize = useCallback(
    (delta: number) => {
      setStatusPanelWidth((prev) => Math.min(480, Math.max(200, prev - delta)));
    },
    [setStatusPanelWidth],
  );

  // F063/F195: auto-open panel when workspace or transcript mode is set
  useEffect(() => {
    if ((rightPanelMode === 'workspace' || rightPanelMode === 'transcript') && !statusPanelOpen) {
      setStatusPanelOpen(true);
    }
  }, [rightPanelMode, statusPanelOpen]);

  const isDesktop = useIsDesktop();

  // Desktop: open sidebar before first paint (useLayoutEffect avoids false→true flicker).
  useLayoutEffect(() => {
    if (isDesktop) {
      openSidebar();
    }
  }, [isDesktop, openSidebar]);

  const { handleAgentMessage, handleStop: stopHandler, resetRefs, resetTimeout, clearDoneTimeout } = useAgentMessages();
  const { handleScroll, scrollContainerRef, messagesEndRef, isLoadingHistory, hasMore } = useChatHistory(threadId);
  const { handleSend, uploadStatus, uploadError } = useSendMessage(threadId);
  const {
    pending: authPending,
    respond: authRespond,
    handleAuthRequest,
    handleAuthResponse,
  } = useAuthorization(threadId);

  // F096: Listen for interactive block send events
  useEffect(() => {
    const handler = (e: Event) => {
      const text = (e as CustomEvent<{ text: string }>).detail.text;
      if (text) handleSend(text);
    };
    window.addEventListener('cat-cafe:interactive-send', handler);
    return () => window.removeEventListener('cat-cafe:interactive-send', handler);
  }, [handleSend]);

  // F079: Vote modal
  const showVoteModal = useChatStore((s) => s.showVoteModal);
  const setShowVoteModal = useChatStore((s) => s.setShowVoteModal);
  const { addMessage } = useChatStore();
  const handleVoteSubmit = useCallback(
    async (config: VoteConfig) => {
      setShowVoteModal(false);
      try {
        const res = await apiFetch(`/api/threads/${encodeURIComponent(threadId)}/vote/start`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(config),
        });
        if (res.status === 409) {
          addMessage({
            id: `vote-${Date.now()}`,
            type: 'system',
            variant: 'error',
            content: '已有活跃投票，请先 /vote end',
            timestamp: Date.now(),
          });
          return;
        }
        if (!res.ok) {
          const data = await res.json().catch(() => ({}));
          throw new Error(data.error ?? `Server error: ${res.status}`);
        }
        const data = await res.json();
        // Build @mention notification message and send as user message to trigger cats
        const mentions = config.voters.map((v) => `@${v}`).join(' ');
        const optionList = config.options.map((o) => `• ${o}`).join('\n');
        const notifyMsg = `${mentions}\n投票请求：${data.question}\n\n选项：\n${optionList}\n\n请在回复中包含 [VOTE:你的选项]，例如 [VOTE:${config.options[0]}]`;
        handleSend(notifyMsg);
      } catch (err) {
        addMessage({
          id: `vote-${Date.now()}`,
          type: 'system',
          variant: 'error',
          content: `发起投票失败: ${err instanceof Error ? err.message : 'Unknown'}`,
          timestamp: Date.now(),
        });
      }
    },
    [threadId, handleSend, setShowVoteModal, addMessage],
  );

  const messageSummary = useMemo(() => {
    const c = { total: messages.length, assistant: 0, system: 0, evidence: 0, followup: 0 };
    for (const msg of messages) {
      const isAssistant = msg.type === 'assistant' || (msg.type === 'user' && !!msg.catId);
      if (isAssistant) c.assistant++;
      if (msg.type === 'system') {
        c.system++;
        if (msg.variant === 'evidence') c.evidence++;
        if (msg.variant === 'a2a_followup') c.followup++;
      }
    }
    return c;
  }, [messages]);

  // Sync URL-driven threadId to store (store is follower, URL is source of truth)
  // setCurrentThread saves old thread state to map, restores new thread state.
  const setCurrentProject = useChatStore((s) => s.setCurrentProject);
  const storeThreads = useChatStore((s) => s.threads);
  const setThreads = useChatStore((s) => s.setThreads);
  const handleSkipFirstRunQuest = useCallback(() => {
    // Session-only skip — next refresh will re-check backend state
    setShowFirstRunQuestPrompt(false);
  }, []);
  const handleStartFirstRunQuest = useCallback(() => {
    setShowFirstRunQuestPrompt(false);
    setShowQuestWizard(true);
  }, []);
  const currentBootcampState = storeThreads.find((thread) => thread.id === threadId)?.bootcampState;
  const currentBootcampPhase = currentBootcampState?.phase;
  const showFirstProjectMistakeTip = useFirstProjectMistakeTipGate({
    threadId,
    phase: currentBootcampPhase,
    messageCount: messages.length,
    hasActiveInvocation,
  });
  useFirstProjectPreviewAutoOpen({
    threadId,
    phase: currentBootcampPhase,
    messageCount: messages.length,
    hasActiveInvocation,
    worktreeId: workspaceWorktreeId,
  });
  const mistakeTipAdvanceKeyRef = useRef<string | null>(null);
  const handleMistakeTipVisible = useCallback(() => {
    // Read threads fresh from store to keep callback ref stable (avoids resetting
    // DelayedMistakeTip's 1500ms onVisible timer on every storeThreads change).
    const currentThread = useChatStore.getState().threads.find((thread) => thread.id === threadId);
    const raw = currentThread?.bootcampState;
    if (!raw || raw.phase !== 'phase-7-dev') return;

    const key = `${threadId}:${String(raw.startedAt ?? 'unknown')}:phase-4`;
    if (mistakeTipAdvanceKeyRef.current === key) return;
    const nextBootcampState: NonNullable<Thread['bootcampState']> = {
      ...raw,
      phase: 'phase-7.5-add-teammate',
    };

    void apiFetch(`/api/threads/${threadId}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        bootcampState: nextBootcampState,
      }),
    }).then((res) => {
      if (res.ok) {
        mistakeTipAdvanceKeyRef.current = key;
        syncLocalBootcampState(threadId, nextBootcampState);
      }
      return res;
    });
  }, [threadId]);
  // When gate fires (invocation ended with new Phase 4 output), advance immediately
  useEffect(() => {
    if (showFirstProjectMistakeTip) {
      handleMistakeTipVisible();
    }
  }, [showFirstProjectMistakeTip, handleMistakeTipVisible]);
  useEffect(() => {
    if (currentBootcampPhase !== 'phase-7-dev') {
      mistakeTipAdvanceKeyRef.current = null;
    }
  }, [currentBootcampPhase, threadId]);
  useEffect(() => {
    // Pure backend-driven: show prompt only when no cats AND no bootcamp thread
    const isCurrentBootcamp = Boolean(storeThreads.find((thread) => thread.id === threadId)?.bootcampState);
    const hasAnyBootcamp = storeThreads.some((t) => t.bootcampState);
    if (isCurrentBootcamp || hasAnyBootcamp || cats.length > 0 || isLoading) {
      setShowFirstRunQuestPrompt(false);
      return;
    }
    // Wait for thread store to populate before deciding — prevents flash on page refresh
    if (storeThreads.length === 0) return;
    // Only show first-run prompt after a successful cat fetch — prevents false
    // positives when /api/cats fails transiently (returns [] on network error).
    if (!hasFetched) return;
    setShowFirstRunQuestPrompt(true);
  }, [cats.length, isLoading, hasFetched, storeThreads, threadId]);

  // ── Data sync: re-fetch thread state ──
  // MCP callbacks update Redis directly; the companion WebSocket `thread_updated`
  // may not reach this frontend (e.g. worktree port isolation). Re-fetching the
  // thread ensures the store stays in sync.
  const syncThreadState = useCallback(() => {
    apiFetch(`/api/threads/${threadId}`)
      .then((res) =>
        res.ok
          ? (res.json() as Promise<{
              bootcampState?: Thread['bootcampState'];
              firstRunQuestState?: { phase: string; firstCatName?: string };
            }>)
          : null,
      )
      .then((thread) => {
        if (!thread) return;
        const local = useChatStore.getState().threads.find((t) => t.id === threadId);
        if (thread.bootcampState || local?.bootcampState) {
          syncLocalBootcampState(threadId, thread.bootcampState);
        }
        const localQuest = (local as Record<string, unknown> | undefined)?.firstRunQuestState;
        if (thread.firstRunQuestState || localQuest) {
          useChatStore.setState((state) => ({
            threads: state.threads.map((t) =>
              t.id === threadId ? { ...t, firstRunQuestState: thread.firstRunQuestState } : t,
            ),
          }));
        }
      })
      .catch(() => {});
  }, [threadId]);

  // Sync on invocation end (active → inactive transition)
  const prevInvocationRef = useRef(hasActiveInvocation);
  useEffect(() => {
    const wasActive = prevInvocationRef.current;
    prevInvocationRef.current = hasActiveInvocation;
    if (!wasActive || hasActiveInvocation) return;
    syncThreadState();
  }, [hasActiveInvocation, syncThreadState]);

  // Sync on mount / thread switch — sidebar may not have loaded yet
  useEffect(() => {
    syncThreadState();
  }, [syncThreadState]);

  // ── Bootcamp add-teammate: trigger guide engine when user interacts with input ──
  // Subscribe reactively so the effect re-runs when guide exits (session cleared).
  const activeGuideFlowId = useGuideStore((s) => s.session?.flow.id ?? null);
  useEffect(() => {
    if (currentBootcampPhase !== 'phase-7.5-add-teammate') return;
    // Guide already running — don't re-register
    if (activeGuideFlowId === 'bootcamp-add-teammate') return;
    // Prevent re-triggering a guide that already completed for this thread
    if (useGuideStore.getState().completedGuides.has(`${threadId}::bootcamp-add-teammate`)) return;

    const startGuide = () => {
      const { session: s, completedGuides: cg } = useGuideStore.getState();
      if (s?.flow.id === 'bootcamp-add-teammate') return;
      if (cg.has(`${threadId}::bootcamp-add-teammate`)) return;
      useGuideStore.getState().reduceServerEvent({
        action: 'start',
        guideId: 'bootcamp-add-teammate',
        threadId,
      });
    };

    // Wait for user to type in chat input before starting guide
    const handler = (e: Event) => {
      if ((e.target as HTMLElement)?.closest('[data-guide-id="chat.input"]')) {
        startGuide();
        document.removeEventListener('input', handler, true);
      }
    };
    document.addEventListener('input', handler, true);
    return () => {
      document.removeEventListener('input', handler, true);
    };
  }, [currentBootcampPhase, threadId, activeGuideFlowId]);

  // ── Bootcamp farewell: auto-trigger guide after agent finishes at phase-10-retro ──
  // Guard with both hasActiveInvocation AND chatIsLoading:
  // - hasActiveInvocation tracks per-slot presence (can briefly go false during A2A handoff)
  // - chatIsLoading stays true for the entire serial chain (cleared only on isFinal=true)
  const farewellTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(() => {
    if (farewellTimerRef.current) {
      clearTimeout(farewellTimerRef.current);
      farewellTimerRef.current = null;
    }
    if (currentBootcampPhase !== 'phase-10-retro') return;
    if (hasActiveInvocation || chatIsLoading) return;
    if (activeGuideFlowId === 'bootcamp-farewell') return;
    if (useGuideStore.getState().completedGuides.has(`${threadId}::bootcamp-farewell`)) return;

    farewellTimerRef.current = setTimeout(() => {
      farewellTimerRef.current = null;
      const s = useChatStore.getState();
      if (s.hasActiveInvocation || s.isLoading) return;
      useGuideStore.getState().reduceServerEvent({
        action: 'start',
        guideId: 'bootcamp-farewell',
        threadId,
      });
    }, 800);
    return () => {
      if (farewellTimerRef.current) {
        clearTimeout(farewellTimerRef.current);
        farewellTimerRef.current = null;
      }
    };
  }, [currentBootcampPhase, threadId, activeGuideFlowId, hasActiveInvocation, chatIsLoading]);

  const prevThreadRef = useRef(threadId);
  useEffect(() => {
    if (prevThreadRef.current !== threadId) {
      // Thread switch: store saves/restores per-thread state automatically
      setCurrentThread(threadId);
      // F173 A.12 — resetRefs no longer touches suppression markers (invocation-driven cleanup).
      // It still clears activeRefs / finalizedStreamRef / sawStreamData per the original purpose.
      resetRefs();
      clearTasks();
      prevThreadRef.current = threadId;
    }
    // First mount — sync threadId to store without save/restore
    setCurrentThread(threadId);
    // F101: Recover game state for the new thread (or clear stale game from previous thread)
    reconnectGame(threadId).catch(() => {});
  }, [
    threadId,
    clearTasks, // Clean up non-thread-scoped refs
    resetRefs, // First mount — sync threadId to store without save/restore
    setCurrentThread,
  ]); // eslint-disable-line react-hooks/exhaustive-deps

  // B1.1: Restore projectPath when thread or storeThreads change.
  // storeThreads is populated by ThreadSidebar.loadThreads shortly after mount,
  // so this covers both page refresh (threads arrive async) and thread switch.
  useEffect(() => {
    const cached = storeThreads?.find((t) => t.id === threadId);
    if (cached) {
      setCurrentProject(cached.projectPath || 'default');
    }
  }, [threadId, storeThreads, setCurrentProject]);

  // F113-E: Fetch governance status for the current project (drives ProjectSetupCard)
  const currentProjectPath = useChatStore((s) => s.currentProjectPath);
  const { status: govStatus, refetch: govRefetch } = useGovernanceStatus(currentProjectPath);
  const isProjectThread = !!currentProjectPath && currentProjectPath !== 'default' && currentProjectPath !== 'lobby';
  const agentHookHealth = useAgentHookHealth({ enabled: isProjectThread });
  const [setupDone, setSetupDone] = useState(false);
  // Show card when: needs setup (idle) OR just completed setup (done) — only in empty threads
  const showSetupCard = !!(
    (govStatus?.needsBootstrap || govStatus?.needsConfirmation || setupDone) &&
    messages.length === 0
  );
  // Reset setupDone on thread switch. Governance status already auto-refetches
  // when projectPath changes inside useGovernanceStatus; same-project thread switches
  // should not trigger an extra network round-trip.
  const prevThreadSetup = useRef(threadId);
  useEffect(() => {
    if (prevThreadSetup.current !== threadId) {
      prevThreadSetup.current = threadId;
      setSetupDone(false);
    }
  }, [threadId]);
  const showAgentHookNotice =
    isProjectThread &&
    !showSetupCard &&
    shouldRenderAgentHookHealthNotice({
      health: agentHookHealth.health,
      error: agentHookHealth.error,
      syncing: agentHookHealth.syncing,
      synced: agentHookHealth.synced,
    });

  // F152 Phase B: memory bootstrap state
  const {
    state: indexState,
    progress: bootstrapProgress,
    summary: bootstrapSummary,
    durationMs: bootstrapDurationMs,
    isSnoozed,
    startBootstrap,
    snooze: snoozeBootstrap,
    handleSocketEvent: handleIndexSocketEvent,
  } = useIndexState(currentProjectPath);

  const socketCallbacks = useChatSocketCallbacks({
    threadId,
    userId: getUserId(),
    handleAgentMessage,
    resetTimeout,
    clearDoneTimeout,
    handleAuthRequest,
    handleAuthResponse,
    onNavigateToThread: navigateToThread,
    onIndexEvent: handleIndexSocketEvent,
  });

  const handleEditCat = useCallback((catId: string) => setEditingCatId(catId), []);
  // F212 follow-up — UI-layer dedup for adjacent identical CliDiagnostics panels.
  // Compute once per messages change; map is keyed by messageId.
  const cliDedupMap = useMemo(() => computeCliDiagnosticsDedup(messages), [messages]);
  const renderSingleMessage = useCallback(
    (msg: ChatMessageData) => {
      const dedupInfo = cliDedupMap.get(msg.id);
      return (
        <MessageActions key={msg.id} message={msg} threadId={threadId}>
          <ChatMessage
            message={msg}
            getCatById={getCatById}
            onEditCat={handleEditCat}
            hideDiagnosticsPanel={dedupInfo?.hideDiagnosticsPanel}
            dedupCount={dedupInfo?.dedupCount}
          />
        </MessageActions>
      );
    },
    [threadId, getCatById, handleEditCat, cliDedupMap],
  );

  const { cancelInvocation, syncRooms, socketConnected } = useSocket(socketCallbacks, threadId);
  const connectionStatus = useConnectionStatus(socketConnected);

  // Single-slot execution can be recovered from queue truth even when the
  // active-thread flat intentMode has not been restored yet (for example after
  // queue hydration or a missed intent_mode event). In that case we still need
  // the top cancel affordance — otherwise the thread looks active in the
  // execution bar but offers no single-cat cancel control.
  const activeInvocationCount = Object.keys(activeInvocations).length;
  const singleSpawningTarget =
    targetCats.length === 1 && targetCats[0] !== undefined && catStatuses[targetCats[0]] === 'spawning';
  const showThinkingIndicator =
    intentMode === 'execute' ||
    (intentMode == null && hasActiveInvocation && (activeInvocationCount === 1 || singleSpawningTarget));

  useVoiceAutoPlay();
  useVoiceStream();
  useVadInterrupt();

  useSplitPaneKeys();
  const splitPaneThreadIds = useChatStore((s) => s.splitPaneThreadIds);
  const setSplitPaneThreadIds = useChatStore((s) => s.setSplitPaneThreadIds);
  const setSplitPaneTarget = useChatStore((s) => s.setSplitPaneTarget);

  useEffect(() => {
    if (viewMode === 'split' && splitPaneThreadIds.length === 0 && threadId !== 'default') {
      setSplitPaneThreadIds([threadId]);
      setSplitPaneTarget(threadId);
    }
  }, [viewMode, splitPaneThreadIds.length, threadId, setSplitPaneThreadIds, setSplitPaneTarget]);

  useEffect(() => {
    if (viewMode === 'split' && splitPaneThreadIds.length > 0) {
      // Join rooms for all threads in panes + the current active thread
      const allIds = new Set([...splitPaneThreadIds, threadId]);
      syncRooms([...allIds]);
    }
  }, [viewMode, splitPaneThreadIds, threadId, syncRooms]);

  useEffect(() => {
    clearUnread(threadId);
  }, [threadId, clearUnread]);

  const disconnectBottomChromeObserver = useCallback(() => {
    bottomChromeObserverRef.current?.disconnect();
    bottomChromeObserverRef.current = null;
    if (bottomChromeObserverRafRef.current !== null) {
      cancelAnimationFrame(bottomChromeObserverRafRef.current);
      bottomChromeObserverRafRef.current = null;
    }
  }, []);

  const attachBottomChromeRef = useCallback(
    (node: HTMLDivElement | null) => {
      bottomChromeRef.current = node;
      disconnectBottomChromeObserver();

      if (typeof window === 'undefined' || typeof window.ResizeObserver !== 'function' || !node) return;

      let lastHeight = node.getBoundingClientRect().height;
      const observer = new window.ResizeObserver(([entry]) => {
        const nextHeight = entry?.contentRect.height ?? node.getBoundingClientRect().height;
        if (Math.abs(nextHeight - lastHeight) <= 1) return;
        lastHeight = nextHeight;

        if (bottomChromeObserverRafRef.current !== null) {
          cancelAnimationFrame(bottomChromeObserverRafRef.current);
        }
        bottomChromeObserverRafRef.current = requestAnimationFrame(() => {
          bottomChromeObserverRafRef.current = null;
          window.dispatchEvent(new Event('catcafe:chat-layout-changed'));
        });
      });

      observer.observe(node);
      bottomChromeObserverRef.current = observer;
    },
    [disconnectBottomChromeObserver],
  );

  useEffect(() => {
    return disconnectBottomChromeObserver;
  }, [disconnectBottomChromeObserver]);

  // F069-R5: Ack read cursor server-side. The backend finds the latest real message
  // and acks it atomically — no frontend ID guessing, no timing races with fetchHistory.
  // Fires on thread entry AND when new messages arrive (messages.length changes),
  // so switching away after receiving new messages still acks to the latest.
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const _messageCount = messages.length;
  useEffect(() => {
    // Re-arm suppression before each ack. /read/latest is idempotent — any
    // successful POST means server cursor is at latest, so any successful ack
    // can safely clear suppression (no generation tracking needed).
    armUnreadSuppression(threadId);
    apiFetch(`/api/threads/${encodeURIComponent(threadId)}/read/latest`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: '{}',
    })
      .then((res) => {
        if (res.ok) {
          confirmUnreadAck(threadId);
        }
      })
      .catch((err) => {
        console.debug('[F069] read ack failed:', err);
      });
  }, [threadId, _messageCount, confirmUnreadAck, armUnreadSuppression]);

  const handleStop = useCallback(
    (overrideThreadId?: unknown) => {
      const targetThreadId = typeof overrideThreadId === 'string' ? overrideThreadId : threadId;
      stopHandler(cancelInvocation, targetThreadId);
    },
    [stopHandler, cancelInvocation, threadId],
  );

  const handleZoomToThread = useCallback(
    (tid: string) => {
      setViewMode('single');
      navigateToThread(tid);
    },
    [setViewMode, navigateToThread],
  );

  const handleQuestCreated = useCallback(
    async (questThreadId: string) => {
      setShowQuestWizard(false);
      try {
        const res = await apiFetch('/api/threads');
        if (res.ok) {
          const data = (await res.json()) as { threads: Thread[] };
          setThreads(data.threads);
        }
      } catch {
        // Ignore refresh errors — navigation is the priority
      }
      navigateToThread(questThreadId);
    },
    [navigateToThread, setThreads],
  );

  const handleSearchKnowledge = useCallback(() => {
    const fromParam = threadId ? `?from=${encodeURIComponent(threadId)}` : '';
    assignDocumentRoute(`/memory/search${fromParam}`, typeof window !== 'undefined' ? window : undefined);
  }, [threadId]);

  const handleGoToMemoryHub = useCallback(() => {
    const fromParam = threadId ? `?from=${encodeURIComponent(threadId)}` : '';
    assignDocumentRoute(`/memory${fromParam}`, typeof window !== 'undefined' ? window : undefined);
  }, [threadId]);

  if (viewMode === 'split') {
    return (
      <>
        <SplitPaneView
          onSend={handleSend}
          onStop={handleStop}
          uploadStatus={uploadStatus}
          uploadError={uploadError}
          onZoomToThread={handleZoomToThread}
        />
      </>
    );
  }

  // Export mode: print-friendly layout — no sidebars, no scroll containers.
  // data-export-ready signals to Puppeteer that messages + cat data are fully loaded and rendered.
  if (isExport) {
    const exportReady = !isLoadingHistory && messages.length > 0 && !isLoading;
    return (
      <div
        className="min-h-screen bg-[var(--console-shell-bg)]"
        {...(exportReady ? { 'data-export-ready': 'true' } : {})}
      >
        <div className="max-w-4xl mx-auto p-4">{messages.map(renderSingleMessage)}</div>
      </div>
    );
  }

  return (
    <div ref={containerRef} className="flex h-screen h-dvh">
      {/* Mobile-only sidebar overlay — desktop sidebar is in AppShell */}
      {sidebarOpen && !isDesktop && (
        <>
          <div
            className="fixed inset-0 bg-[var(--console-overlay-backdrop)] z-20"
            onClick={closeSidebar}
            aria-hidden="true"
          />
          <div className="fixed inset-y-0 left-0 z-30 w-[240px]">
            <ThreadSidebar onClose={closeSidebar} className="w-full" />
          </div>
        </>
      )}

      <div
        className="flex flex-col min-w-0"
        style={
          statusPanelOpen && (rightPanelMode === 'workspace' || rightPanelMode === 'transcript')
            ? { flexBasis: `${chatBasis}%`, flexGrow: 0, flexShrink: 0 }
            : { flex: '1 1 0%' }
        }
      >
        <ChatContainerHeader
          sidebarOpen={sidebarOpen}
          onToggleSidebar={toggleSidebar}
          threadId={threadId}
          authPendingCount={authPending.length}
          viewMode={viewMode}
          onToggleViewMode={() => setViewMode(viewMode === 'single' ? 'split' : 'single')}
          onOpenMobileStatus={() => setMobileStatusOpen(true)}
          statusPanelOpen={statusPanelOpen}
          onToggleStatusPanel={() => setStatusPanelOpen((v) => !v)}
          defaultCatId={targetCats[0] || 'opus'}
        />

        {intentMode === 'ideate' && <ParallelStatusBar onStop={handleStop} threadId={threadId} />}
        {showThinkingIndicator && <ThinkingIndicator onCancel={cancelInvocation} threadId={threadId} />}

        <div className="flex-1 relative overflow-hidden">
          <main
            ref={scrollContainerRef}
            onScroll={handleScroll}
            className="h-full overflow-y-auto p-4"
            data-guide-id="bootcamp.preview-result"
            data-bootcamp-host="chat-messages"
            data-chat-container
          >
            {isLoadingHistory && <div className="text-center py-3 text-sm text-cafe-muted">加载历史消息...</div>}
            <ConnectionStatusBar
              api={connectionStatus.api}
              socket={connectionStatus.socket}
              upstream={connectionStatus.upstream}
              isReadonly={connectionStatus.isReadonly}
              checkedAt={connectionStatus.checkedAt}
              isOfflineSnapshot={isOfflineSnapshot}
            />
            {showAgentHookNotice && (
              <div className="mb-3 flex justify-center text-left">
                <div className="max-w-[85%] w-full">
                  <AgentHookHealthNotice
                    health={agentHookHealth.health}
                    error={agentHookHealth.error}
                    syncing={agentHookHealth.syncing}
                    synced={agentHookHealth.synced}
                    onSync={agentHookHealth.sync}
                  />
                </div>
              </div>
            )}
            {!hasMore && messages.length > 0 && (
              <div className="text-center py-3 text-xs text-cafe-muted">没有更多消息了</div>
            )}
            {messages.length === 0 && !isLoadingHistory ? (
              <div className="text-center mt-20">
                <PawIcon className="w-12 h-12 text-cafe-muted mx-auto mb-4" />
                <p className="text-lg text-cafe-secondary mb-1">欢迎来到 Clowder AI!</p>
                <p className="text-sm text-cafe-muted" suppressHydrationWarning>
                  {cats.length > 0 ? '输入 @布偶 召唤布偶猫开始聊天' : '还没有可用成员，先开始新手教程创建第一只猫猫'}
                </p>
                {showSetupCard && govStatus && (
                  <div className="mt-6 text-left">
                    <ProjectSetupCard
                      key={threadId}
                      projectPath={currentProjectPath}
                      isEmptyDir={govStatus.isEmptyDir}
                      isGitRepo={govStatus.isGitRepo}
                      gitAvailable={govStatus.gitAvailable}
                      agentHookHealth={agentHookHealth.health}
                      agentHookHealthError={agentHookHealth.error}
                      agentHookSyncing={agentHookHealth.syncing}
                      agentHookSynced={agentHookHealth.synced}
                      onSyncAgentHooks={agentHookHealth.sync}
                      onComplete={() => {
                        setSetupDone(true);
                        govRefetch();
                      }}
                    />
                  </div>
                )}
                {/* F152 Phase B: memory bootstrap orchestrator */}
                {!showSetupCard &&
                  currentProjectPath &&
                  currentProjectPath !== 'default' &&
                  currentProjectPath !== 'lobby' && (
                    <div className="mt-4 text-left">
                      <BootstrapOrchestrator
                        projectPath={currentProjectPath}
                        indexState={indexState}
                        isSnoozed={isSnoozed}
                        progress={bootstrapProgress}
                        summary={bootstrapSummary}
                        durationMs={bootstrapDurationMs}
                        isNewProject={setupDone}
                        governanceDone={
                          setupDone || !!(govStatus && !govStatus.needsBootstrap && !govStatus.needsConfirmation)
                        }
                        onStartBootstrap={startBootstrap}
                        onSnooze={snoozeBootstrap}
                        onSearchKnowledge={handleSearchKnowledge}
                        onGoToMemoryHub={handleGoToMemoryHub}
                      />
                    </div>
                  )}
                {(() => {
                  const isCurrentBootcamp = storeThreads.find((t) => t.id === threadId)?.bootcampState;
                  if (isCurrentBootcamp) return null; // already in bootcamp thread
                  if (bootcampCount > 0) {
                    return (
                      <button
                        type="button"
                        onClick={() => setShowBootcampList(true)}
                        className="mt-6 inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-cafe-accent/20 bg-accent-50 text-cafe-accent hover:bg-accent-100 transition-colors text-sm font-medium"
                        data-testid="empty-state-bootcamp-list"
                      >
                        <BootcampIcon className="w-4 h-4" />
                        我的训练营（{bootcampCount}）
                      </button>
                    );
                  }
                  return (
                    <button
                      type="button"
                      onClick={() => setShowBootcampList(true)}
                      className="mt-6 inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-cafe-accent/20 bg-accent-50 text-cafe-accent hover:bg-accent-100 transition-colors text-sm font-medium"
                      data-testid="empty-state-bootcamp"
                    >
                      <BootcampIcon className="w-4 h-4" />
                      第一次来？开始猫猫训练营
                    </button>
                  );
                })()}
              </div>
            ) : (
              messages.map(renderSingleMessage)
            )}
            <div ref={messagesEndRef} />
          </main>
          <ScrollToBottomButton
            scrollContainerRef={scrollContainerRef}
            messagesEndRef={messagesEndRef}
            recomputeSignal={computeScrollRecomputeSignal(threadId, messages, uiThinkingExpandedByDefault ? 1 : 0)}
            observerKey={threadId}
          />
          {messages.length > 5 && <MessageNavigator messages={messages} scrollContainerRef={scrollContainerRef} />}
        </div>

        <div ref={attachBottomChromeRef}>
          {authPending.length > 0 && (
            <div className="border-t border-conn-amber-ring bg-conn-amber-bg/40 py-2">
              {authPending.map((req) => (
                <AuthorizationCard key={req.requestId} request={req} onRespond={authRespond} />
              ))}
            </div>
          )}

          <ThreadExecutionBar threadId={threadId} />
          <QueuePanel threadId={threadId} />
          <VoteActiveBar threadId={threadId} onEnd={() => {}} />

          {!showFirstRunQuestPrompt &&
            !showQuestWizard &&
            (() => {
              const currentThread = storeThreads.find((t) => t.id === threadId);
              const questState = (currentThread as Record<string, unknown> | undefined)?.firstRunQuestState as
                | { phase: string; firstCatName?: string }
                | undefined;
              if (!questState) return null;
              return (
                <QuestBanner
                  phase={questState.phase}
                  firstCatName={questState.firstCatName}
                  onAddSecondCat={() => setShowQuestWizard(true)}
                  onStartBootcamp={() => setShowBootcampList(true)}
                  onComplete={() => assignDocumentRoute('/hub', typeof window !== 'undefined' ? window : undefined)}
                />
              );
            })()}

          {isResearchMode && (
            <div className="mx-4 mb-2 rounded-lg border border-[var(--semantic-success)] bg-[var(--semantic-success-surface)] px-3 py-2 text-xs text-conn-emerald-text">
              多猫研究模式 — 文章上下文已注入。请输入研究问题，猫猫会自动调用 multi_mention 邀请其他猫参与分析。
            </div>
          )}
          <div
            className={(() => {
              if (showFirstRunQuestPrompt || showQuestWizard) return '';
              const ct = storeThreads.find((t) => t.id === threadId);
              // Bootcamp phase-1 with no messages: highlight + punch through overlay
              const bs = ct?.bootcampState as { phase: string } | undefined;
              if (bs?.phase === 'phase-1-intro' && messages.length === 0) {
                return 'relative z-[70] quest-input-highlight rounded-xl mx-1';
              }
              // Legacy quest support
              const qs = (ct as Record<string, unknown> | undefined)?.firstRunQuestState as
                | { phase: string }
                | undefined;
              return qs?.phase === 'quest-2-cat-intro' ? 'quest-input-highlight rounded-xl mx-1' : '';
            })()}
          >
            <ChatInput
              key={threadId}
              threadId={threadId}
              onSend={(content, images, whisper, deliveryMode) =>
                handleSend(content, images, undefined, whisper, deliveryMode)
              }
              onStop={handleStop}
              disabled={connectionStatus.isReadonly}
              hasActiveInvocation={hasActiveInvocation}
              uploadStatus={uploadStatus}
              uploadError={uploadError}
            />
          </div>

          {/* F101: "Return to game" banner when overlay is minimized */}
          {isGameActive && overlayMinimized && gameView?.threadId === threadId && (
            <button
              onClick={() => useGameStore.getState().restoreOverlay()}
              className="mx-4 mb-2 flex items-center justify-center gap-2 rounded-lg border border-[var(--color-cafe-accent)] bg-[var(--accent-50)] px-3 py-2 text-sm text-[var(--color-cafe-accent)] hover:bg-[var(--color-cocreator-surface)] transition-colors"
            >
              🎮 返回游戏
            </button>
          )}
        </div>

        {/* F101: Game overlay — renders when a game is active */}
        <GameOverlayConnector
          gameView={gameView}
          isGameActive={isGameActive}
          overlayMinimized={overlayMinimized}
          currentThreadId={threadId}
          isNight={isNight}
          selectedTarget={selectedTarget}
          godScopeFilter={godScopeFilter}
          isGodView={isGodView}
          isDetective={isDetective}
          detectiveBoundName={detectiveBoundName ?? undefined}
          godSeats={godSeats}
          godNightSteps={godNightSteps}
          hasTargetedAction={hasTargetedAction}
          myRole={myRole ?? undefined}
          myRoleIcon={myRoleIcon ?? undefined}
          myActionLabel={myActionLabel ?? undefined}
          myActionHint={myActionHint ?? undefined}
          altActionName={altActionName ?? undefined}
          onClose={() => {
            useGameStore.getState().minimizeOverlay();
          }}
          onSelectTarget={(seatId) => useGameStore.getState().setSelectedTarget(seatId)}
          onGodScopeChange={(scope) => useGameStore.getState().setGodScopeFilter(scope)}
          onGodAction={(action) => godAction(threadId, action)}
          onVote={() => {
            const state = useGameStore.getState();
            if (state.selectedTarget && state.mySeatId) {
              submitAction(threadId, state.mySeatId, 'vote', state.selectedTarget);
              state.setSelectedTarget(null);
            }
          }}
          onSpeak={(content) => {
            const state = useGameStore.getState();
            if (state.mySeatId) {
              submitAction(threadId, state.mySeatId, 'speak', undefined, { content });
            }
          }}
          onConfirmAction={() => {
            const state = useGameStore.getState();
            if (state.selectedTarget && state.mySeatId && state.currentActionName) {
              submitAction(threadId, state.mySeatId, state.currentActionName, state.selectedTarget);
              state.setSelectedTarget(null);
            }
          }}
          onConfirmAltAction={() => {
            const state = useGameStore.getState();
            if (state.selectedTarget && state.mySeatId && state.altActionName) {
              submitAction(threadId, state.mySeatId, state.altActionName, state.selectedTarget);
              state.setSelectedTarget(null);
            }
          }}
        />
      </div>

      {statusPanelOpen && rightPanelMode === 'status' && (
        <>
          <div className="hidden lg:flex">
            <ResizeHandle
              direction="horizontal"
              label="右侧状态栏"
              onResize={handleStatusPanelResize}
              onCollapse={() => setStatusPanelOpen(false)}
              onDoubleClick={resetStatusPanelWidth}
            />
          </div>
          <RightStatusPanel
            intentMode={intentMode}
            targetCats={targetCats}
            catStatuses={catStatuses}
            catInvocations={catInvocations}
            activeInvocations={activeInvocations}
            hasActiveInvocation={hasActiveInvocation}
            threadId={threadId}
            messageSummary={messageSummary}
            width={statusPanelWidth}
          />
        </>
      )}
      {statusPanelOpen && rightPanelMode === 'workspace' && (
        <>
          <ResizeHandle
            direction="horizontal"
            label="右侧工作区"
            onResize={handleHorizontalResize}
            onCollapse={() => setStatusPanelOpen(false)}
            onDoubleClick={resetChatBasis}
          />
          <WorkspacePanel />
        </>
      )}
      {statusPanelOpen && rightPanelMode === 'transcript' && (
        <>
          <ResizeHandle
            direction="horizontal"
            label="右侧转录栏"
            onResize={handleHorizontalResize}
            onCollapse={() => setStatusPanelOpen(false)}
            onDoubleClick={resetChatBasis}
          />
          <TranscriptPanel />
        </>
      )}
      <FloatingTranscriptContainer />
      <MobileStatusSheet
        open={mobileStatusOpen}
        onClose={() => setMobileStatusOpen(false)}
        intentMode={intentMode}
        targetCats={targetCats}
        catStatuses={catStatuses}
        catInvocations={catInvocations}
        activeInvocations={activeInvocations}
        hasActiveInvocation={hasActiveInvocation}
        threadId={threadId}
        messageSummary={messageSummary}
      />
      {showFirstRunQuestPrompt &&
        createPortal(
          <div className="fixed inset-0 z-[70] flex items-center justify-center bg-[var(--console-overlay-medium)] px-4">
            <div
              className="w-full max-w-md rounded-2xl border border-conn-amber-ring bg-[var(--console-card-bg)] p-6 shadow-2xl"
              onClick={(event) => event.stopPropagation()}
            >
              <h3 className="text-lg font-semibold text-cafe">开始猫猫新手教程？</h3>
              <p className="mt-2 text-sm text-cafe-secondary">
                当前还没有可用成员。我们可以先带你创建第一只猫猫，再开始首个协作任务。
              </p>
              <div className="mt-5 flex justify-end gap-3">
                <button
                  type="button"
                  onClick={handleSkipFirstRunQuest}
                  className="rounded-lg border border-[var(--console-border-soft)] px-3 py-2 text-sm text-cafe-secondary hover:bg-[var(--console-hover-bg)]"
                >
                  跳过
                </button>
                <button
                  type="button"
                  onClick={handleStartFirstRunQuest}
                  className="rounded-lg bg-cafe-accent px-3 py-2 text-sm font-medium text-[var(--cafe-surface)] hover:opacity-90"
                >
                  开始教程
                </button>
              </div>
            </div>
          </div>,
          document.body,
        )}
      <FirstRunQuestWizard
        open={showQuestWizard}
        onClose={() => setShowQuestWizard(false)}
        onCreated={handleQuestCreated}
      />
      <BootcampListModal open={showBootcampList} onClose={handleBootcampModalClose} currentThreadId={threadId} />
      {showVoteModal && <VoteConfigModal onSubmit={handleVoteSubmit} onCancel={() => setShowVoteModal(false)} />}
      {editingCat && (
        <HubCatEditor
          open
          cat={editingCat}
          draft={null}
          onClose={() => setEditingCatId(null)}
          onSaved={async () => {
            await refreshCats();
            setEditingCatId(null);
          }}
        />
      )}
      {/* Bootcamp guide overlay: intro phase tips + lifecycle tips (phase-7.5 uses guide engine) */}
      {(() => {
        if (showFirstRunQuestPrompt || showQuestWizard) return null;
        const bt = storeThreads.find((t) => t.id === threadId);
        const raw = bt?.bootcampState;
        if (!raw) return null;
        const phase = raw.phase;
        // Guide engine handles phase-7.5 and phase-10 — no custom overlay needed
        if (phase === 'phase-7.5-add-teammate' || phase === 'phase-10-retro') return null;
        const isLifecyclePhase = /^phase-(5|6|7|8|9|10|11)-/.test(phase);
        if (!isLifecyclePhase && messages.length > 0) return null;
        const leadCat = cats.find((c) => c.id === raw.leadCat) ?? cats[0];
        const catName = leadCat?.displayName ?? leadCat?.nickname ?? leadCat?.name;
        if (!catName) return null;
        return <BootcampGuideOverlay phase={phase} catName={catName} hasMessages={messages.length > 0} />;
      })()}
    </div>
  );
}
