import React, { useState, useRef, useEffect, useMemo } from "react";
import { invokeChatAgent } from "../../../../ai/agents/chat-agent/chat-agent-invoke";
import {
  invokeCouncil,
} from "../../../../ai/agents/council-agent/council-invoke";
import {
  CouncilConfig,
  DEFAULT_COUNCIL_CONFIG,
} from "../../../../ai/agents/council-agent/council-types";
import "../../style/councilDisplay.css";
import { Result, ChatMessage, ChatMode, VectorSearchUIPayload } from "../../types/types";
import { performAdaptiveExpansion } from "../../../../ai/agents/search-agent/helpers/contextExpansion";
import {
  extensionStorage,
  defaultModel,
  defaultStyle,
  chatRoles,
  getInstantAssistantRole,
} from "../../../..";
import {
  getBlockContentByUid,
  getPageUidByPageName,
  getPageNameByPageUid,
  insertBlockInCurrentView,
  createChildBlock,
  createSiblingBlock,
  hasBlockChildren,
  updateBlock,
  isExistingBlock,
  getDNPTitleFromDate,
  getRoamUserDisplayName,
} from "../../../../utils/roamAPI";
import { modelAccordingToProvider } from "../../../../ai/aiAPIsHub";
import { parseAndCreateBlocks } from "../../../../utils/format";
import { insertCompletion } from "../../../../ai/responseInsertion";
import { AppToaster } from "../../../Toaster";
import { extractConversationFromLiveAIChat } from "../../index";
import { getChatTitleFromUid } from "../../utils/chatStorage";
import {
  loadCurrentChat,
  loadAutoRestoreChat,
  getStoredChatInfo,
  saveCurrentChat,
  clearCurrentChat,
} from "../../utils/currentChatStorage";
import {
  calculateTotalTokens,
  convertMarkdownToRoamFormat,
  hasRealMessages,
  formatChatErrorContent,
} from "../../utils/chatMessageUtils";
import { ChatHeader } from "./ChatHeader";
import { ChatMessagesDisplay } from "./ChatMessagesDisplay";
import { ChatInputArea } from "./ChatInputArea";
import { BUILTIN_COMMANDS } from "../../../../ai/prebuildCommands";
import {
  getCustomPromptByUid,
  getCustomStyles,
} from "../../../../ai/dataExtraction";
import { loadResultsFromRoamContext } from "../../utils/roamContextLoader";
import { getModelThinkingDefault } from "../../../../utils/modelConfigHelpers";
import {
  createTemporaryEditBlock,
  extractContentFromBlock,
  cleanupTemporaryBlock,
  getFirstChildBlockUid,
} from "../../utils/chatEditUtils";
import { generateNLQuery } from "../../../../ai/agents/nl-query";
import { generateNLDatomicQuery } from "../../../../ai/agents/nl-datomic-query";
import { isFileExportRequest } from "../../../../ai/agents/chat-agent/multimodal-commands";
import {
  AdvancedOptionsState,
  getDefaultAdvancedOptions,
  getActiveAdvancedParams,
  getIncludePdfOverride,
} from "./AdvancedOptionsMenu";
import { alwaysExtractPdf } from "../../../..";

interface FullResultsChatProps {
  isOpen: boolean;
  selectedResults: Result[];
  allResults: Result[]; // All available results
  paginatedResults: Result[]; // Currently displayed results
  privateMode: boolean;
  targetUid?: string;
  onClose: () => void;
  // Chat state from parent
  chatMessages: ChatMessage[];
  setChatMessages: (
    messages: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[]),
  ) => void;
  chatAccessMode: "Balanced" | "Full Access";
  setChatAccessMode: (mode: "Balanced" | "Full Access") => void;
  noTruncation: boolean;
  setNoTruncation: (value: boolean) => void;
  chatAgentData: any;
  setChatAgentData: (data: any) => void;
  chatExpandedResults: Result[] | null;
  setChatExpandedResults: (results: Result[] | null) => void;
  // Token estimation state (passed from parent to persist across view switches)
  contextTokenEstimate: number;
  setContextTokenEstimate: (estimate: number) => void;
  willContextBeTruncated: boolean;
  setWillContextBeTruncated: (truncated: boolean) => void;
  hasCalculatedTokens: boolean;
  setHasCalculatedTokens: (calculated: boolean) => void;
  selectionChangedSinceCalculation: boolean;
  setSelectionChangedSinceCalculation: (changed: boolean) => void;
  // Pagination props for cross-page navigation
  currentPage: number;
  setCurrentPage: (page: number) => void;
  resultsPerPage: number;
  // View mode props for ((uid)) click handling
  chatOnlyMode: boolean;
  handleChatOnlyToggle: () => void;
  // References filtering
  handleIncludeReference: (reference: string) => void;
  // Initial chat state for continuing conversations
  initialChatMessages?: ChatMessage[];
  initialChatPrompt?: string;
  initialChatModel?: string;
  // Loaded chat UID (when opened via [[liveai/chat]])
  initialLoadedChatUid?: string;
  // Command context for enriching chat prompts
  initialStyle?: string;
  initialCommandId?: number;
  initialCommandPrompt?: string;
  initialEnabledTools?: Set<string>; // Force-enable specific tools/skills (merged with user prefs)
  // Page autocomplete props
  availablePages: string[];
  isLoadingPages: boolean;
  queryAvailablePages: (query: string) => void;
  // Agent callbacks
  onAddResults: (results: Result[]) => void;
  onSelectResultsByUids: (uids: string[]) => void;
}

// Utility functions moved to ./utils/chatMessageUtils.ts

export const FullResultsChat: React.FC<FullResultsChatProps> = ({
  isOpen,
  selectedResults,
  allResults,
  paginatedResults,
  privateMode,
  targetUid,
  onClose,
  chatMessages,
  setChatMessages,
  chatAccessMode,
  setChatAccessMode,
  noTruncation,
  setNoTruncation,
  chatAgentData,
  setChatAgentData,
  chatExpandedResults,
  setChatExpandedResults,
  contextTokenEstimate,
  setContextTokenEstimate,
  willContextBeTruncated,
  setWillContextBeTruncated,
  hasCalculatedTokens,
  setHasCalculatedTokens,
  selectionChangedSinceCalculation,
  setSelectionChangedSinceCalculation,
  currentPage,
  setCurrentPage,
  resultsPerPage,
  chatOnlyMode,
  handleChatOnlyToggle,
  handleIncludeReference,
  initialChatMessages,
  initialChatPrompt,
  initialChatModel,
  initialLoadedChatUid,
  initialStyle,
  initialCommandId,
  initialCommandPrompt,
  initialEnabledTools,
  availablePages,
  isLoadingPages,
  queryAvailablePages,
  onAddResults,
  onSelectResultsByUids,
}) => {
  const [chatInput, setChatInput] = useState("");
  const [isTyping, setIsTyping] = useState(false);
  const chatAbortRef = useRef<AbortController | null>(null);
  const [statusMessage, setStatusMessage] = useState<string | undefined>(
    undefined,
  );
  const [streamingContent, _setStreamingContent] = useState("");
  const streamingContentRef = useRef("");
  // Wrapper that keeps the ref in sync with state
  const setStreamingContent = (
    value: string | ((prev: string) => string),
  ) => {
    if (typeof value === "function") {
      _setStreamingContent((prev) => {
        const next = value(prev);
        streamingContentRef.current = next;
        return next;
      });
    } else {
      streamingContentRef.current = value;
      _setStreamingContent(value);
    }
  };
  const [isStreaming, setIsStreaming] = useState(false);
  const [loadedChatTitle, setLoadedChatTitle] = useState<string | null>(() => {
    // Restore from persisted state if available (window first, then the durable
    // localStorage copy which survives a browser refresh).
    // Make sure to clean the title (remove #liveai/chat tag if present)
    const persistedTitle =
      (window as any).lastLoadedChatTitle ||
      loadAutoRestoreChat()?.loadedChatTitle;
    return persistedTitle
      ? persistedTitle.replace(/#liveai\/chat/g, "").trim()
      : null;
  });
  const [loadedChatUid, setLoadedChatUid] = useState<string | null>(() => {
    // Restore from persisted state if available
    return (
      (window as any).lastLoadedChatUid ||
      initialLoadedChatUid ||
      loadAutoRestoreChat()?.loadedChatUid ||
      null
    );
  });
  const [selectedStyle, setSelectedStyle] = useState<string>(() => {
    // Load style from window object (whether pinned or not), or use initialStyle/defaultStyle
    const sessionStyle = (window as any).__currentChatStyle;
    return sessionStyle || initialStyle || defaultStyle || "Normal";
  });
  const [isPinnedStyle, setIsPinnedStyle] = useState<boolean>(() => {
    // Check if style is pinned in window object
    return !!(window as any).__pinnedChatStyle;
  });
  const [toolUsageHistory, setToolUsageHistory] = useState<
    Array<{
      toolName: string;
      details: string;
      response?: string; // Tool's response/feedback
      timestamp: number;
      intermediateMessage?: string;
      vectorSearchData?: VectorSearchUIPayload;
    }>
  >([]);

  // Tool confirmation state for sensitive operations (block/page creation)
  const [pendingToolConfirmation, setPendingToolConfirmation] = useState<{
    toolName: string;
    toolCallId: string;
    args: Record<string, any>;
    timestamp: number;
  } | null>(null);
  const [declineReasonInput, setDeclineReasonInput] = useState("");
  const [alwaysApprovedTools, setAlwaysApprovedTools] = useState<Set<string>>(
    new Set(),
  );

  // Ref to store the resolve function for pending confirmation Promise
  const pendingConfirmationResolveRef = useRef<
    | ((result: {
        approved: boolean;
        alwaysApprove?: boolean;
        declineReason?: string;
      }) => void)
    | null
  >(null);

  // User choice state for inline choice forms (ask_user_choice tool + PDF export)
  const [pendingUserChoice, setPendingUserChoice] = useState<{
    commandId: string;
    title: string;
    hintsEnabled?: boolean;
    options: Array<{
      id: string;
      label: string;
      type: "radio" | "checkbox" | "text" | "slider";
      choices?: Array<{ value: string; label: string; hint?: string }>;
      defaultValue?: string;
      placeholder?: string;
      min?: number;
      max?: number;
      step?: number;
    }>;
    timestamp: number;
  } | null>(null);
  const pendingUserChoiceResolveRef = useRef<
    | ((result: {
        selectedOptions: Record<string, string>;
        cancelled: boolean;
      }) => void)
    | null
  >(null);

  const targetRef = useRef<string | undefined>(targetUid);

  // Generate a unique chat session ID for multi-turn image editing
  // Use loadedChatUid if available, otherwise generate a new ID
  const chatSessionIdRef = useRef<string>(
    loadedChatUid ||
      `chat_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
  );

  // Ref to track current context results for expansion callback
  // This allows the callback to access the latest results even during agent execution
  const currentContextResultsRef = useRef<Result[]>([]);

  // Ref to track current tool usage - avoids stale closure issues
  const toolUsageHistoryRef = useRef<
    Array<{
      toolName: string;
      details: string;
      response?: string;
      timestamp: number;
      intermediateMessage?: string;
    }>
  >([]);

  // Edit mode state for inline message editing
  const [editingMessageIndex, setEditingMessageIndex] = useState<number | null>(
    null,
  );
  const [editingOriginalContent, setEditingOriginalContent] =
    useState<string>("");
  const editTempBlockUidRef = useRef<string | null>(null);

  // Get custom style titles
  const customStyleTitles = useMemo(() => {
    return getCustomStyles().map((custom: any) => custom.title);
  }, []);

  // Handler for tool confirmation response
  const handleToolConfirmationResponse = (
    approved: boolean,
    alwaysApprove?: boolean,
    declineReason?: string,
  ) => {
    if (pendingConfirmationResolveRef.current) {
      // If "always approve" was selected, add the tool to the always-approved set
      if (approved && alwaysApprove && pendingToolConfirmation) {
        setAlwaysApprovedTools((prev) => {
          const newSet = new Set(prev);
          newSet.add(pendingToolConfirmation.toolName);
          return newSet;
        });
      }

      // Resolve the pending Promise
      pendingConfirmationResolveRef.current({
        approved,
        alwaysApprove,
        declineReason: declineReason || undefined,
      });

      // Clear the pending confirmation state
      setPendingToolConfirmation(null);
      pendingConfirmationResolveRef.current = null;
      setDeclineReasonInput("");
    }
  };

  // Handlers for user choice response
  const handleUserChoiceResponse = (
    selectedOptions: Record<string, string>,
  ) => {
    //console.log("🔍 [UserChoice] handleUserChoiceResponse called with:", selectedOptions);
    if (pendingUserChoiceResolveRef.current) {
      pendingUserChoiceResolveRef.current({
        selectedOptions,
        cancelled: false,
      });

      setPendingUserChoice(null);
      pendingUserChoiceResolveRef.current = null;
    }
  };
  const handleUserChoiceCancelled = () => {
    if (pendingUserChoiceResolveRef.current) {
      pendingUserChoiceResolveRef.current({
        selectedOptions: {},
        cancelled: true,
      });
      setPendingUserChoice(null);
      pendingUserChoiceResolveRef.current = null;
    }
  };

  // Track command context for auto-execution
  const [commandContext, setCommandContext] = useState<{
    commandPrompt?: string;
    commandName?: string;
  } | null>(null);

  // Track the number of initial messages loaded from Roam
  // This helps us exclude them when inserting the conversation back
  // Using state instead of ref so ChatHeader can display the updated count
  const [insertedMessagesCount, setInsertedMessagesCount] = useState<number>(
    () => {
      // Restore from persisted state if available
      return (
        (window as any).lastInsertedMessagesCount ??
        loadAutoRestoreChat()?.insertedMessagesCount ??
        0
      );
    },
  );
  // Token estimation state is now managed by parent component and passed as props
  const [isExpandingForEstimate, setIsExpandingForEstimate] = useState(false);
  const lastCalculatedSelectionRef = useRef<string>("");
  const hasAutoExecutedRef = useRef(false); // Prevent double execution

  // Initialize chat from provided initial state
  useEffect(() => {
    if (
      initialChatMessages &&
      Array.isArray(initialChatMessages) &&
      initialChatMessages.length > 0
    ) {
      // Create a fresh copy to avoid mutation issues
      const messagesCopy = [...initialChatMessages];

      setChatMessages(messagesCopy);

      // Store count of initial messages to exclude from Roam insertion later
      setInsertedMessagesCount(messagesCopy.length);
    }
    if (initialChatPrompt) {
      // Ensure initialChatPrompt is a string
      const promptString =
        typeof initialChatPrompt === "string"
          ? initialChatPrompt
          : String(initialChatPrompt || "");
      setChatInput(promptString);
    }
    // If initialLoadedChatUid is provided, set the loaded chat info
    if (initialLoadedChatUid) {
      setLoadedChatUid(initialLoadedChatUid);
      const title = getChatTitleFromUid(initialLoadedChatUid);
      setLoadedChatTitle(title);
    }

    // Initialize command context if provided
    let commandName: string | undefined = undefined;
    if (initialCommandPrompt || initialCommandId || initialStyle) {
      // Get command name from BUILTIN_COMMANDS if initialCommandId is provided
      if (initialCommandId) {
        const command = BUILTIN_COMMANDS.find(
          (cmd) => cmd.id === initialCommandId,
        );
        if (command) {
          commandName = command.name;
        }
      }

      // Set command context regardless of whether we have a commandId
      setCommandContext({
        commandPrompt: initialCommandPrompt,
        commandName,
      });
    }
    // }
  }, [
    initialChatMessages,
    initialChatPrompt,
    initialLoadedChatUid,
    initialCommandPrompt,
    initialCommandId,
    initialStyle,
  ]);

  // Detect selection changes (but don't auto-calculate tokens)
  useEffect(() => {
    const resultsToEstimate =
      selectedResults.length > 0 ? selectedResults : allResults;

    const currentSelectionKey = resultsToEstimate
      .map((r) => r.uid || r.pageUid)
      .filter(Boolean)
      .sort()
      .join(",");

    // Check if selection changed since last calculation
    if (
      hasCalculatedTokens &&
      currentSelectionKey !== lastCalculatedSelectionRef.current
    ) {
      setSelectionChangedSinceCalculation(true);
    }

    // If no results, reset everything
    if (resultsToEstimate.length === 0) {
      setContextTokenEstimate(0);
      setWillContextBeTruncated(false);
      setHasCalculatedTokens(false);
      setSelectionChangedSinceCalculation(false);
      lastCalculatedSelectionRef.current = "";
    }
  }, [selectedResults, allResults, hasCalculatedTokens]);

  // Manual token calculation function (called when user clicks)
  const calculateTokenEstimate = (expandedResultsToUse?: Result[]) => {
    // Determine which results to use for estimation
    const resultsToEstimate =
      selectedResults.length > 0 ? selectedResults : allResults;

    if (resultsToEstimate.length === 0) {
      setContextTokenEstimate(0);
      setWillContextBeTruncated(false);
      setHasCalculatedTokens(false);
      setSelectionChangedSinceCalculation(false);
      return;
    }

    // Use provided expanded results or fall back to state
    const expandedResults = expandedResultsToUse || chatExpandedResults;

    // IMPORTANT: Only calculate if we have expanded results
    // We should NOT calculate from raw results as they don't include page content
    if (!expandedResults || expandedResults.length === 0) {
      return;
    }

    // Store current selection key
    const currentSelectionKey = resultsToEstimate
      .map((r) => r.uid || r.pageUid)
      .filter(Boolean)
      .sort()
      .join(",");
    lastCalculatedSelectionRef.current = currentSelectionKey;

    // Match expanded results to current selection by UID
    const selectedUids = new Set(
      resultsToEstimate.map((r) => r.uid || r.pageUid).filter(Boolean),
    );

    // Filter expanded results to only include selected ones
    // If no selection (all results), use all expanded results
    const resultsForCalculation =
      selectedResults.length > 0
        ? expandedResults.filter(
            (r) => selectedUids.has(r.uid) || selectedUids.has(r.pageUid),
          )
        : expandedResults;

    // Calculate total character count from content
    // For expanded results, we want the FULL EXPANDED content count (including children)
    // Both pages and blocks have expandedLength in metadata which includes:
    // - For pages: page title + all children content
    // - For blocks: block content + parent context + children outline
    // This gives us the pre-truncation size (before intelligent truncation is applied)
    const totalChars = resultsForCalculation.reduce((sum, result) => {
      // Use expandedLength (full content including children) or fallback to content.length
      const contentLength =
        result.metadata?.expandedLength || result.content?.length || 0;
      return sum + contentLength;
    }, 0);

    // Estimate tokens: ~0.3 tokens per character
    const estimatedTokens = Math.round(totalChars * 0.3);
    setContextTokenEstimate(estimatedTokens);

    // Check if context will be truncated based on expansion budget
    // Budget calculation matches the one used in handleExpandForEstimate and handleSendMessage
    const expansionBudget =
      chatAccessMode === "Full Access"
        ? modelTokensLimit * 3
        : modelTokensLimit * 2;

    // Convert token limit to character limit for comparison
    const charLimit = expansionBudget;
    const willBeTruncated = totalChars > charLimit;
    setWillContextBeTruncated(willBeTruncated);

    // Mark that we've calculated tokens and selection hasn't changed since
    setHasCalculatedTokens(true);
    setSelectionChangedSinceCalculation(false);
  };

  // Handle manual expansion for token estimation
  const handleExpandForEstimate = async () => {
    if (isExpandingForEstimate) return; // Already expanding

    const contextResults =
      selectedResults.length > 0 ? selectedResults : allResults;

    if (contextResults.length === 0) {
      return;
    }

    // Check if we already have expanded results that match current selection
    if (chatExpandedResults && chatExpandedResults.length > 0) {
      // Check if the current selection is a subset of what we already expanded
      const contextUids = new Set(
        contextResults.map((r) => r.uid || r.pageUid).filter(Boolean),
      );
      const expandedUids = new Set(
        chatExpandedResults.map((r) => r.uid || r.pageUid).filter(Boolean),
      );

      // If all context UIDs are in expanded results, just recalculate
      const allInExpanded = Array.from(contextUids).every((uid) =>
        expandedUids.has(uid),
      );

      if (allInExpanded) {
        calculateTokenEstimate();
        return;
      }
    }

    // Need to perform fresh expansion
    setIsExpandingForEstimate(true);

    try {
      // Perform expansion using the same logic as sending a message
      const expansionBudget =
        chatAccessMode === "Full Access"
          ? modelTokensLimit * 3
          : modelTokensLimit * 2;

      const expandedResults = await performAdaptiveExpansion(
        contextResults.map((result) => ({ ...result })),
        expansionBudget,
        0,
        chatAccessMode,
      );

      setChatExpandedResults(expandedResults);

      // Calculate tokens immediately with the expanded results
      // Pass them directly to avoid state update delay
      calculateTokenEstimate(expandedResults);
    } catch (error) {
      console.error("Error expanding results for token estimate:", error);
    } finally {
      setIsExpandingForEstimate(false);
    }
  };

  // Auto-execute command when chat opens with command context
  useEffect(() => {
    const autoExecuteCommand = async () => {
      // Only execute once
      if (hasAutoExecutedRef.current) return;

      // Need command context and at least one user message
      if (!commandContext || !commandContext.commandPrompt) return;
      if (chatMessages.length === 0) return;

      // Check if last message is from user
      const lastMessage = chatMessages[chatMessages.length - 1];
      if (lastMessage.role !== "user") return;

      // If not in chat-only mode, wait for results to be loaded before auto-executing
      // This ensures context is available when the AI processes the message
      // However, if commandContext exists (chat opened from context menu with command),
      // we should proceed even without results, as the context might come from roamContext instead
      if (
        !chatOnlyMode &&
        allResults.length === 0 &&
        paginatedResults.length === 0 &&
        !commandContext?.commandPrompt // Allow execution when we have a command context
      ) {
        return;
      }

      // Mark as executed BEFORE starting to prevent duplicate executions
      hasAutoExecutedRef.current = true;

      // Update the last message to include command info if it doesn't have it yet
      if (!lastMessage.commandName && commandContext?.commandName) {
        setChatMessages((prev) => {
          const updated = [...prev];
          updated[updated.length - 1] = {
            ...lastMessage,
            commandName: commandContext?.commandName,
            commandPrompt: commandContext?.commandPrompt,
          };
          return updated;
        });
      }

      // Trigger the chat submission with the user's message
      setIsTyping(true);
      setIsStreaming(true);
      setStreamingContent("");
      setToolUsageHistory([]);
      toolUsageHistoryRef.current = []; // Sync ref

      // Create abort controller for this request
      const abortController = new AbortController();
      chatAbortRef.current = abortController;

      try {
        const contextResults = getSelectedResultsForChat();

        if (commandContext?.commandPrompt === "prompt") {
          await processChatMessage(
            lastMessage.content,
            contextResults,
            undefined,
            undefined,
            undefined,
            selectedStyle,
          );
        } else
          await processChatMessageWithCommand(
            lastMessage.content,
            contextResults,
            commandContext?.commandPrompt,
            commandContext?.commandName,
            undefined,
            selectedStyle,
          );
      } catch (error: any) {
        if (error.name === "AbortError") {
          const partialContent = streamingContentRef.current.trim();
          const abortMessage: ChatMessage = {
            role: "assistant",
            content: partialContent
              ? `${partialContent}\n\n*— Generation stopped by user.*`
              : "*Generation stopped by user.*",
            timestamp: new Date(),
          };
          setChatMessages((prev) => [...prev, abortMessage]);
        } else {
          console.error("Auto-execution error:", error);
          const errorMessage: ChatMessage = {
            role: "assistant",
            content: formatChatErrorContent(
              "Sorry, I encountered an error processing your request with the command. Please try again.",
              error,
            ),
            timestamp: new Date(),
          };
          setChatMessages((prev) => [...prev, errorMessage]);
        }
      }

      chatAbortRef.current = null;
      setIsTyping(false);
      setIsStreaming(false);
      setStreamingContent("");
      // Don't clear tool usage history - it should persist to show user what tools were used

      // Reset command context after processing
      setCommandContext(null);
    };

    if (isOpen && commandContext) {
      // Only auto-execute if we have messages and (no context required OR context is loaded)
      if (chatMessages.length > 0) {
        // If roamContext was provided, wait for allResults to be populated
        // Otherwise execute immediately
        autoExecuteCommand();
      }
    }
  }, [isOpen, commandContext, chatMessages, allResults]);

  // Track if chat was previously closed to detect when it opens
  const prevIsOpenRef = useRef(isOpen);

  // Auto-focus chat input when in chat-only mode or when chat first opens
  // This effect runs whenever chatOnlyMode or isOpen changes
  useEffect(() => {
    const chatJustOpened = isOpen && !prevIsOpenRef.current;
    prevIsOpenRef.current = isOpen;

    // Focus if: switching to chat-only mode OR chat just opened
    if ((chatOnlyMode || chatJustOpened) && chatInputRef.current) {
      // Small delay to ensure DOM is ready
      const timeoutId = setTimeout(() => {
        chatInputRef.current?.focus();
      }, 100);

      return () => clearTimeout(timeoutId);
    }
  }, [chatOnlyMode, isOpen]);

  // Track previous isTyping state to detect when assistant finishes responding
  const prevIsTypingRef = useRef(isTyping);

  // Auto-focus chat input when assistant finishes responding
  useEffect(() => {
    const assistantJustFinished = prevIsTypingRef.current && !isTyping;
    prevIsTypingRef.current = isTyping;

    // Focus input when assistant just finished responding
    if (assistantJustFinished && chatInputRef.current) {
      // Small delay to ensure streaming is fully complete
      const timeoutId = setTimeout(() => {
        chatInputRef.current?.focus();
      }, 100);

      return () => clearTimeout(timeoutId);
    }
  }, [isTyping]);

  // Additional effect to handle initial mount - runs once when component first appears
  // This is crucial for when the popup opens directly in chat-only mode via openChatPopup()
  const hasMounted = useRef(false);
  useEffect(() => {
    if (!hasMounted.current) {
      hasMounted.current = true;

      // Check if we should auto-focus on initial mount
      // Focus if: in chat-only mode OR if there are no results (which implies chat-only)
      const shouldFocus =
        chatOnlyMode ||
        (allResults.length === 0 && selectedResults.length === 0);

      if (shouldFocus && chatInputRef.current) {
        // Delay to ensure the component is fully rendered
        const timeoutId = setTimeout(() => {
          chatInputRef.current?.focus();
        }, 250);

        return () => clearTimeout(timeoutId);
      }
    }
  }, []); // Empty deps - only run once on mount

  // Ref for auto-scrolling to the latest message
  const messagesContainerRef = useRef<HTMLDivElement>(null);

  // Ref for the chat input field
  const chatInputRef = useRef<HTMLTextAreaElement>(null);

  // State to track pending highlight after page change
  const [pendingHighlight, setPendingHighlight] = useState<string | null>(null);

  // Track if user has manually scrolled during streaming
  const [userScrolledDuringStreaming, setUserScrolledDuringStreaming] =
    useState(false);
  const lastScrollHeightRef = useRef(0);
  const previousMessagesLengthRef = useRef(0);
  const wasStreamingRef = useRef(false);

  // Auto-scroll to the latest message when messages change
  useEffect(() => {
    if (!messagesContainerRef.current) return;

    const container = messagesContainerRef.current;
    const messagesLengthChanged =
      chatMessages.length !== previousMessagesLengthRef.current;
    const streamingJustStarted = isStreaming && !wasStreamingRef.current;

    // Update refs
    previousMessagesLengthRef.current = chatMessages.length;
    wasStreamingRef.current = isStreaming;

    // Case 1: Initial load or new message added (but not streaming completion)
    // Scroll to bottom when a new user message is added (start of new conversation turn)
    if (messagesLengthChanged && !isStreaming) {
      container.scrollTop = container.scrollHeight;
      return;
    }

    // Case 2: Streaming just started - scroll to show the new assistant message
    if (streamingJustStarted) {
      container.scrollTop = container.scrollHeight;
      return;
    }

    // Case 3: During streaming - only auto-scroll if user hasn't manually scrolled
    if (isStreaming && !userScrolledDuringStreaming && streamingContent) {
      container.scrollTop = container.scrollHeight;
    }

    // Case 4: Streaming ended - do nothing, let user stay where they are
  }, [
    chatMessages,
    streamingContent,
    isStreaming,
    userScrolledDuringStreaming,
  ]);

  // Reset user scroll flag when streaming stops
  useEffect(() => {
    if (!isStreaming) {
      setUserScrolledDuringStreaming(false);
    }
  }, [isStreaming]);

  // Detect manual scrolling during streaming
  useEffect(() => {
    const container = messagesContainerRef.current;
    if (!container) return;

    const handleScroll = () => {
      // Only track manual scrolling during streaming
      if (!isStreaming) return;

      // Check if scroll was triggered by content growth (auto-scroll) or user action
      const currentScrollHeight = container.scrollHeight;
      const scrolledUp =
        container.scrollTop + container.clientHeight < currentScrollHeight - 50; // 50px threshold

      // If user scrolled up during streaming, pause auto-scroll
      if (scrolledUp && currentScrollHeight > lastScrollHeightRef.current) {
        setUserScrolledDuringStreaming(true);
      }

      lastScrollHeightRef.current = currentScrollHeight;
    };

    container.addEventListener("scroll", handleScroll);
    return () => container.removeEventListener("scroll", handleScroll);
  }, [isStreaming]);

  // Watch for page changes and handle pending highlights
  useEffect(() => {
    if (pendingHighlight) {
      // Small delay to ensure DOM is updated
      const timeoutId = setTimeout(() => {
        highlightElementOnCurrentPage(pendingHighlight);
        setPendingHighlight(null);
      }, 50);

      return () => clearTimeout(timeoutId);
    }
  }, [currentPage, pendingHighlight]);

  // Watch for chat-only mode changes and handle pending highlights
  useEffect(() => {
    if (pendingHighlight && !chatOnlyMode) {
      // When switching from chat-only mode to results view, handle pending highlight
      const timeoutId = setTimeout(() => {
        highlightAndScrollToResult(pendingHighlight);
        setPendingHighlight(null);
      }, 100); // Slightly longer delay for mode switch

      return () => clearTimeout(timeoutId);
    }
  }, [chatOnlyMode, pendingHighlight]);

  // Create lookup maps for performance optimization
  const { uidToIndexMap, pageTitleToIndexMap } = useMemo(() => {
    const allAvailableResults =
      selectedResults.length > 0 ? selectedResults : allResults;

    const uidMap = new Map<string, number>();
    const pageMap = new Map<string, number>();

    allAvailableResults.forEach((result, index) => {
      if (result.uid) {
        uidMap.set(result.uid, index);
      }
      if (result.pageTitle) {
        // Store first occurrence of each page title
        if (!pageMap.has(result.pageTitle)) {
          pageMap.set(result.pageTitle, index);
        }
      }
    });

    return { uidToIndexMap: uidMap, pageTitleToIndexMap: pageMap };
  }, [selectedResults, allResults]);

  // Shared helper to navigate to a different page with forced re-render
  const navigateToPage = (targetPage: number, onComplete?: () => void) => {
    if (targetPage === currentPage) {
      // Already on the right page, just execute callback
      if (onComplete) {
        setTimeout(onComplete, 50);
      }
      return;
    }

    // Force re-render: briefly set to a different page, then to target page
    const tempPage = targetPage === 1 ? 2 : 1;
    setCurrentPage(tempPage);

    // Small delay then set to actual target page
    setTimeout(() => {
      setCurrentPage(targetPage);
      if (onComplete) {
        setTimeout(onComplete, 50);
      }
    }, 10);
  };

  // Handle block reference and page reference hover for navigation and highlighting
  useEffect(() => {
    const handleBlockRefHover = (event: MouseEvent) => {
      const target = event.target as HTMLElement;

      // Handle block references
      if (target.classList.contains("roam-block-ref-chat")) {
        const blockUid = target.getAttribute("data-block-uid");
        if (!blockUid) return;

        // Only highlight on hover if NOT in chat-only mode
        if (!chatOnlyMode) {
          // Hover: Highlight and scroll to result in popup
          highlightAndScrollToResult(blockUid);
        }
      }

      // Handle page references and tags
      if (
        target.classList.contains("rm-page-ref") ||
        target.classList.contains("roam-page-ref-chat") ||
        target.classList.contains("roam-tag-ref-chat")
      ) {
        const pageTitle = target.getAttribute("data-page-title");
        if (!pageTitle) return;

        // Only highlight on hover if NOT in chat-only mode
        if (!chatOnlyMode) {
          // Check if page exists in results
          const allAvailableResults =
            selectedResults.length > 0 ? selectedResults : allResults;
          const pageExistsInResults = allAvailableResults.some(
            (result) => result.pageTitle === pageTitle,
          );

          if (pageExistsInResults) {
            // Hover: Highlight and scroll to page result in popup (blue theme)
            highlightAndScrollToPageResult(pageTitle);
          }
        }
      }
    };

    const handleBlockRefClick = (event: MouseEvent) => {
      const target = event.target as HTMLElement;

      // Handle code block copy button
      const codeCopyButton = target.closest(
        ".chat-code-copy-button",
      ) as HTMLButtonElement | null;
      if (codeCopyButton) {
        event.preventDefault();
        event.stopPropagation();

        const codeBlockWrapper = codeCopyButton.closest(
          ".chat-code-block-wrapper",
        );
        const codeElement = codeBlockWrapper?.querySelector("code");
        const codeText = codeElement?.textContent ?? "";

        if (!codeText) {
          AppToaster.show({
            message: "No code found to copy.",
            intent: "warning",
            timeout: 1500,
          });
          return;
        }

        // Wrap in triple backticks if "Copy as codeblock" button
        const isCodeblock = codeCopyButton.classList.contains(
          "chat-code-copy-as-codeblock",
        );
        const langClass = codeElement?.className ?? "";
        const langMatch = langClass.match(/language-(\w+)/);
        const lang = langMatch ? langMatch[1] : "";
        const textToCopy = isCodeblock
          ? `\`\`\`${lang}\n${codeText}\n\`\`\``
          : codeText;

        // Visual feedback on button
        const originalText = codeCopyButton.textContent;
        codeCopyButton.textContent = "Copied!";
        setTimeout(() => {
          codeCopyButton.textContent = originalText;
        }, 1500);

        navigator.clipboard
          .writeText(textToCopy)
          .catch((error) => {
            console.error("Failed to copy code block:", error);
            codeCopyButton.textContent = "Failed";
            setTimeout(() => {
              codeCopyButton.textContent = originalText;
            }, 1500);
          });
        return;
      }

      // Only handle Roam-specific links, let external links work normally
      if (target.classList.contains("roam-block-ref-chat")) {
        event.preventDefault();
        const blockUid = target.getAttribute("data-block-uid");
        if (!blockUid) return;

        if (event.shiftKey) {
          // Shift+click: Open in sidebar
          window.roamAlphaAPI.ui.rightSidebar.addWindow({
            window: { type: "block", "block-uid": blockUid },
          });
        } else if (event.altKey || event.metaKey) {
          // Alt/Option+click: Copy block reference & show result
          const clipboardText = `((${blockUid}))`;
          navigator.clipboard
            .writeText(clipboardText)
            .then(() => {
              console.log(
                "[Chat Link Click] Copied to clipboard:",
                clipboardText,
              );
            })
            .catch((err) => {
              console.warn("[Chat Link Click] Failed to copy to clipboard:", err);
            });

          // Check if block exists in results
          const allAvailableResults =
            selectedResults.length > 0 ? selectedResults : allResults;
          const blockExistsInResults = allAvailableResults.some(
            (result) => result.uid === blockUid,
          );

          if (blockExistsInResults) {
            // Block exists in results - show it
            if (chatOnlyMode) {
              setPendingHighlight(blockUid);
              handleChatOnlyToggle();
            } else {
              highlightAndScrollToResult(blockUid);
            }
          }
        } else {
          // Regular click: Open in main window
          window.roamAlphaAPI.ui.mainWindow.openBlock({
            block: { uid: blockUid },
          });
        }
      }

      // Handle page references [[page]] and #tag clicks
      if (
        target.classList.contains("rm-page-ref") ||
        target.classList.contains("roam-page-ref-chat") ||
        target.classList.contains("roam-tag-ref-chat")
      ) {
        const pageTitle = target.getAttribute("data-page-title");
        if (!pageTitle) return;

        event.preventDefault(); // Prevent default link behavior

        if (event.shiftKey) {
          // Shift+click: Add to included references filter
          const pageUid = getPageUidByPageName(pageTitle);
          if (pageUid) {
            window.roamAlphaAPI.ui.rightSidebar.addWindow({
              window: {
                type: "outline",
                "block-uid": pageUid,
              },
            });
          } else {
            console.warn(
              "[Chat Link Click] Could not find page UID for:",
              pageTitle,
              pageUid,
            );
          }
        } else if (event.altKey || event.metaKey) {
          // Alt/Option+click: Open page in main window
          handleIncludeReference(pageTitle);

          // Switch to results view if in chat-only mode
          if (chatOnlyMode) {
            handleChatOnlyToggle();
          }
        } else {
          // Regular click: Open page in sidebar
          const pageUid = getPageUidByPageName(pageTitle);
          if (pageUid) {
            window.roamAlphaAPI.ui.mainWindow.openPage({
              page: { uid: pageUid },
            });
          } else {
            console.warn(
              "[Chat Link Click] Could not find page UID for:",
              pageTitle,
              pageUid,
            );
          }
        }
      }
    };

    // Add both hover and click listeners to the messages container
    if (messagesContainerRef.current) {
      messagesContainerRef.current.addEventListener(
        "mouseover",
        handleBlockRefHover,
      );
      messagesContainerRef.current.addEventListener(
        "click",
        handleBlockRefClick,
      );
    }

    // Cleanup
    return () => {
      if (messagesContainerRef.current) {
        messagesContainerRef.current.removeEventListener(
          "mouseover",
          handleBlockRefHover,
        );
        messagesContainerRef.current.removeEventListener(
          "click",
          handleBlockRefClick,
        );
      }
    };
  }, [chatOnlyMode, uidToIndexMap, pageTitleToIndexMap]); // Only re-attach when dependencies that affect behavior change

  // Function to highlight and scroll to a result by UID (with cross-page navigation)
  const highlightAndScrollToResult = (blockUid: string) => {
    // Use lookup map for O(1) performance instead of O(n) findIndex
    const targetResultIndex = uidToIndexMap.get(blockUid);

    if (targetResultIndex === undefined) {
      console.warn(`Block ${blockUid} not found in current results`);
      return;
    }

    // Calculate which page the target result is on
    const targetPage = Math.floor(targetResultIndex / resultsPerPage) + 1;

    // Always check if the block is actually visible in DOM first
    const blockAlreadyVisible = document.querySelector(
      `.full-results-result-item[data-uid="${blockUid}"]`,
    );

    if (blockAlreadyVisible) {
      highlightElementOnCurrentPage(blockUid);
    } else {
      // Navigate to the correct page and highlight after navigation
      setPendingHighlight(blockUid);
      navigateToPage(targetPage);
    }
  };

  // Helper function to highlight element on the current page
  const highlightElementOnCurrentPage = (blockUid: string) => {
    const targetElement = document.querySelector(
      `.full-results-result-item[data-uid="${blockUid}"]`,
    ) as HTMLElement;

    if (targetElement) {
      // Remove any existing highlights first
      document
        .querySelectorAll(".highlighted-result, .highlighted-page-result")
        .forEach((el) => {
          el.classList.remove("highlighted-result", "highlighted-page-result");
        });

      // Add temporary highlight
      targetElement.classList.add("highlighted-result");

      // Scroll to the element
      targetElement.scrollIntoView({
        behavior: "smooth",
        block: "center",
      });

      // Remove highlight after 3 seconds
      setTimeout(() => {
        targetElement?.classList.remove("highlighted-result");
      }, 3000);
    } else {
      console.warn(
        `❌ DOM element for block ${blockUid} not found on current page`,
      );
    }
  };

  // Function to highlight and scroll to a page result by title
  const highlightAndScrollToPageResult = (pageTitle: string) => {
    // Use lookup map for O(1) performance instead of O(n) findIndex
    const targetResultIndex = pageTitleToIndexMap.get(pageTitle);

    if (targetResultIndex === undefined) {
      console.warn(`Page "${pageTitle}" not found in current results`);
      return;
    }

    // Get the result to access its UID
    const allAvailableResults =
      selectedResults.length > 0 ? selectedResults : allResults;
    const targetResult = allAvailableResults[targetResultIndex];
    const resultUid = targetResult.uid;

    if (!resultUid) {
      console.warn(`Page "${pageTitle}" found but has no UID`);
      return;
    }

    // Calculate which page the target result is on
    const targetPage = Math.floor(targetResultIndex / resultsPerPage) + 1;

    // Check if the page is already visible in DOM
    const pageAlreadyVisible = document.querySelector(
      `.full-results-result-item[data-uid="${resultUid}"]`,
    );

    if (pageAlreadyVisible) {
      highlightPageElementOnCurrentPage(resultUid);
    } else {
      // Navigate to the correct page and highlight after navigation
      navigateToPage(targetPage, () => {
        highlightPageElementOnCurrentPage(resultUid);
      });
    }
  };

  // Helper function to highlight page element on the current page (blue theme)
  const highlightPageElementOnCurrentPage = (pageUid: string) => {
    const targetElement = document.querySelector(
      `.full-results-result-item[data-uid="${pageUid}"]`,
    ) as HTMLElement;

    if (targetElement) {
      // Remove any existing highlights first
      document
        .querySelectorAll(".highlighted-result, .highlighted-page-result")
        .forEach((el) => {
          el.classList.remove("highlighted-result", "highlighted-page-result");
        });

      // Add temporary highlight with blue theme
      targetElement.classList.add("highlighted-page-result");

      // Scroll to the element
      targetElement.scrollIntoView({
        behavior: "smooth",
        block: "center",
      });

      // Remove highlight after 3 seconds
      setTimeout(() => {
        targetElement?.classList.remove("highlighted-page-result");
      }, 3000);
    } else {
      console.warn(
        `❌ DOM element for page ${pageUid} not found on current page`,
      );
    }
  };
  const [chatMode, setChatMode] = useState<ChatMode>(() => {
    // Initialize agent mode if there are enabled tools (persisted or forced)
    const storedTools = extensionStorage.get("chatEnabledTools");
    const hasStoredTools = storedTools && storedTools.length > 0;
    const hasForcedInitialTools =
      initialEnabledTools && initialEnabledTools.size > 0;
    return hasStoredTools || hasForcedInitialTools ? "agent" : "simple";
  });
  // Council config state
  const [councilConfig, setCouncilConfig] = useState<CouncilConfig>(() => {
    const stored = extensionStorage.get("councilConfig");
    if (stored) {
      try {
        return { ...DEFAULT_COUNCIL_CONFIG, ...JSON.parse(stored) };
      } catch {
        // ignore parse errors
      }
    }
    return { ...DEFAULT_COUNCIL_CONFIG };
  });
  const councilAbortRef = useRef<AbortController | null>(null);

  // Debate mode state (persisted across Continue / human-in-loop turns)
  const [debateState, setDebateState] =
    useState<import("../../../../ai/agents/council-agent/council-types").DebateState | null>(
      null
    );
  const debateStateRef = useRef<
    | import("../../../../ai/agents/council-agent/council-types").DebateState
    | null
  >(null);
  useEffect(() => {
    debateStateRef.current = debateState;
  }, [debateState]);

  // Human-in-loop: participant index the human's next typed message targets.
  // null = no explicit addressee (auto-pick via pickNextSpeaker).
  const [humanDebateTarget, setHumanDebateTarget] = useState<number | null>(
    null,
  );
  const humanDebateTargetRef = useRef<number | null>(null);
  useEffect(() => {
    humanDebateTargetRef.current = humanDebateTarget;
  }, [humanDebateTarget]);

  const handleCouncilConfigChange = (newConfig: CouncilConfig) => {
    setCouncilConfig(newConfig);
    extensionStorage.set("councilConfig", JSON.stringify(newConfig));
    // Reset any in-flight debate state if config changed materially (mode switch, participants).
    if (newConfig.mode !== "debate") {
      setDebateState(null);
    }
  };

  const [isClearDialogOpen, setIsClearDialogOpen] = useState(false);

  // Previous-day ("stale") conversation available for on-demand restore.
  // Captured once at mount; the landing screen offers a Restore/Dismiss banner
  // instead of auto-loading it (so the welcome message still shows).
  const [staleChatInfo] = useState(() => getStoredChatInfo());
  const [staleRestoreDismissed, setStaleRestoreDismissed] = useState(false);

  const [hasExpandedResults, setHasExpandedResults] = useState(false); // Track if agent found additional results during conversation
  const [lastSelectedResultIds, setLastSelectedResultIds] = useState<string[]>(
    [],
  ); // Track result selection changes
  const [selectedModel, setSelectedModel] = useState<string>(() => {
    // Restore from persisted state if available, otherwise use initial or default
    return (
      (window as any).lastChatModel ||
      initialChatModel ||
      loadAutoRestoreChat()?.selectedModel ||
      defaultModel
    );
  });
  const [modelTokensLimit, setModelTokensLimit] = useState<number>(
    modelAccordingToProvider(initialChatModel || defaultModel).tokensLimit ||
      128000,
  );

  // Thinking mode state (session-based, resets on model change)
  const [thinkingEnabled, setThinkingEnabled] = useState<boolean>(() => {
    return getModelThinkingDefault(selectedModel);
  });

  // Advanced options (per-session, reset on new chat)
  const [advancedOptions, setAdvancedOptions] = useState<AdvancedOptionsState>(
    () => getDefaultAdvancedOptions(alwaysExtractPdf),
  );

  // Track enabled/disabled state for each tool
  const [enabledTools, setEnabledTools] = useState<Set<string>>(() => {
    // Load from storage (initialized in index.js on first install)
    const storedTools = extensionStorage.get("chatEnabledTools");

    // Storage is guaranteed to have a value (set in index.js onload)
    // Can be: [] (user disabled all) or [...] (enabled tools)
    const baseSet = new Set<string>(storedTools || []);

    // Merge forced tools if provided (e.g., from SmartBlock {chat:skill:X+tool:Y})
    if (initialEnabledTools && initialEnabledTools.size > 0) {
      initialEnabledTools.forEach((tool) => baseSet.add(tool));
    }

    return baseSet;
  });

  // Whether this chat session was opened with forced tools (don't persist to storage)
  const hasForcedTools = useRef(
    !!(initialEnabledTools && initialEnabledTools.size > 0),
  );

  // Override for conversation history used by processChatMessage (for retry/delete)
  const conversationHistoryOverrideRef = useRef<string[] | null>(null);

  // Save enabled tools to storage whenever they change
  // Skip if this session has forced tools to avoid overwriting user preferences
  useEffect(() => {
    if (!hasForcedTools.current) {
      extensionStorage.set("chatEnabledTools", Array.from(enabledTools));
    }
  }, [enabledTools]);

  // Handler to toggle individual tool
  const handleToggleTool = (toolName: string) => {
    setEnabledTools((prev) => {
      const newSet = new Set(prev);
      if (newSet.has(toolName)) {
        newSet.delete(toolName);
      } else {
        newSet.add(toolName);
      }
      return newSet;
    });
  };

  // Handler for pinned style change
  const handlePinnedStyleChange = (isPinned: boolean) => {
    setIsPinnedStyle(isPinned);
    if (isPinned) {
      // Save current style as pinned
      (window as any).__pinnedChatStyle = selectedStyle;
    } else {
      // Clear pinned style from window object
      delete (window as any).__pinnedChatStyle;
      // Keep the current style (don't reset to default when unpinning)
    }
  };

  // Always save current style to window object (for session persistence across view switches)
  useEffect(() => {
    (window as any).__currentChatStyle = selectedStyle;

    // Also update pinned style if pinned
    if (isPinnedStyle) {
      (window as any).__pinnedChatStyle = selectedStyle;
    }
  }, [selectedStyle, isPinnedStyle]);

  // No longer derive chatMode from enabledTools - they are independent.
  // chatMode controls whether tools are used at all (agent vs chat mode).
  // enabledTools controls which specific tools are available when in agent mode.

  // Calculate total tokens used in the conversation
  const { totalIn, totalOut } = React.useMemo(
    () => calculateTotalTokens(chatMessages),
    [chatMessages],
  );

  // Reset cache when chat is TRULY closed (not just hidden for view switch)
  // Only reset if isOpen goes to false AND there are no real messages
  // This preserves state when switching between results/chat views
  useEffect(() => {
    if (!isOpen && !hasRealMessages(chatMessages)) {
      // Only reset if chat is truly closed (no conversation in progress)
      setChatExpandedResults(null);
      setLastSelectedResultIds([]);
      // Also reset token estimation state
      setHasCalculatedTokens(false);
      setContextTokenEstimate(0);
      setWillContextBeTruncated(false);
      setSelectionChangedSinceCalculation(false);
      lastCalculatedSelectionRef.current = "";
    }
  }, [isOpen, chatMessages, setChatExpandedResults]);

  // Update tokensLimit when model is changed
  useEffect(() => {
    setModelTokensLimit(
      modelAccordingToProvider(selectedModel).tokensLimit || 32000,
    );
    // Reset thinking state based on new model's (possibly user-overridden) default
    setThinkingEnabled(getModelThinkingDefault(selectedModel));
  }, [selectedModel]);

  // Persist chat-specific state whenever it changes
  // This allows restoration after popup close/reopen
  useEffect(() => {
    if (hasRealMessages(chatMessages)) {
      (window as any).lastLoadedChatUid = loadedChatUid;
      (window as any).lastLoadedChatTitle = loadedChatTitle;
      (window as any).lastInsertedMessagesCount = insertedMessagesCount;
      (window as any).lastChatModel = selectedModel;
      // Mirror into the durable localStorage copy (survives browser refresh).
      // Merges with the messages slice written by useFullResultsState.
      saveCurrentChat({
        loadedChatUid,
        loadedChatTitle,
        insertedMessagesCount,
        selectedModel,
      });
    }
  }, [
    loadedChatUid,
    loadedChatTitle,
    insertedMessagesCount,
    selectedModel,
    chatMessages,
  ]);

  const getSelectedResultsForChat = () => {
    // Create copies to prevent mutation of shared objects from parent component
    const results = selectedResults.length > 0 ? selectedResults : allResults;
    return results.map((result) => ({ ...result }));
  };

  const resetChat = () => {
    setChatMessages([]);
    setChatAgentData(null);
    setChatExpandedResults(null);
    setLastSelectedResultIds([]);
    setHasExpandedResults(false);
    setLoadedChatTitle(null);
    setLoadedChatUid(null);
    setInsertedMessagesCount(0);
    targetRef.current = undefined;
    // Clear tool usage history
    setToolUsageHistory([]);
    toolUsageHistoryRef.current = [];
    // Clear typing/streaming state to re-enable textarea
    setIsTyping(false);
    setIsStreaming(false);
    // Clear streaming content
    setStreamingContent("");
    // Clear chat-scoped auto-approval settings
    setAlwaysApprovedTools(new Set());
    // Clear persisted chat-specific state from window object
    delete (window as any).lastLoadedChatUid;
    delete (window as any).lastLoadedChatTitle;
    delete (window as any).lastInsertedMessagesCount;
    delete (window as any).lastChatModel;
    // Clear the durable localStorage copy too
    clearCurrentChat();
  };

  // Whether to offer the "restore previous conversation" banner on the landing
  // screen: a stale (previous-day) snapshot exists, hasn't been dismissed, and
  // there's no real conversation in progress yet (auto-hides after restore /
  // first message).
  const showStaleRestore =
    !!staleChatInfo &&
    !staleChatInfo.isFromToday &&
    !staleRestoreDismissed &&
    !hasRealMessages(chatMessages);

  // Load the stale snapshot into the chat on demand (full restore).
  const handleRestorePreviousChat = () => {
    const stored = loadCurrentChat();
    if (!stored) {
      setStaleRestoreDismissed(true);
      return;
    }
    setChatMessages(stored.messages);
    setChatAgentData(stored.agentData ?? null);
    if (stored.accessMode) setChatAccessMode(stored.accessMode);
    if (typeof stored.noTruncation === "boolean")
      setNoTruncation(stored.noTruncation);
    setLoadedChatUid(stored.loadedChatUid || null);
    setLoadedChatTitle(
      stored.loadedChatTitle
        ? stored.loadedChatTitle.replace(/#liveai\/chat/g, "").trim()
        : null,
    );
    if (typeof stored.insertedMessagesCount === "number")
      setInsertedMessagesCount(stored.insertedMessagesCount);
    if (stored.selectedModel) setSelectedModel(stored.selectedModel);
  };

  // Discard the stale snapshot (user doesn't want to continue it).
  const handleDismissPreviousChat = () => {
    clearCurrentChat();
    setStaleRestoreDismissed(true);
  };

  const copyToClipboard = async (text: string) => {
    try {
      await navigator.clipboard.writeText(text);
    } catch (error) {
      console.error("Failed to copy to clipboard:", error);
    }
  };

  const insertConversationInRoam = async (forceDNP?: boolean) => {
    // Capture the current inserted count at the start to avoid race conditions
    const currentInsertedCount = insertedMessagesCount;

    if (forceDNP) {
      // Force insert into today's daily note page
      const dnpUid = window.roamAlphaAPI.util.dateToPageUid(new Date());
      targetRef.current = await createChildBlock(dnpUid, "");
    } else if (!loadedChatUid || !isExistingBlock(loadedChatUid)) {
      let focusedUid = window.roamAlphaAPI.ui.getFocusedBlock()?.["block-uid"];
      if (
        !focusedUid &&
        targetRef.current &&
        hasBlockChildren(targetRef.current)
      ) {
        targetRef.current = await createSiblingBlock(targetRef.current);
      }
      targetRef.current =
        focusedUid || targetRef.current || (await insertBlockInCurrentView(""));
    } else {
      targetRef.current = loadedChatUid;
    }

    try {
      // Get only NEW messages (exclude initial messages that came from Roam).
      // Filter out debate meta messages (status / continue button / speaker picker)
      // — they're UI-only and shouldn't be persisted to Roam. Keep track of the
      // original chatMessages index so we can write back roamBlockUid correctly.
      const newMessagesWithIndex: Array<{
        msg: ChatMessage;
        originalIndex: number;
      }> = [];
      for (let k = currentInsertedCount; k < chatMessages.length; k += 1) {
        const m = chatMessages[k];
        const t = m.councilStep?.type;
        if (
          t === "status" ||
          t === "debate-continue" ||
          t === "debate-speaker-picker"
        ) {
          continue;
        }
        newMessagesWithIndex.push({ msg: m, originalIndex: k });
      }
      const newMessages = newMessagesWithIndex.map((x) => x.msg);

      if (newMessages.length === 0) {
        const alreadySavedTitle = loadedChatTitle || loadedChatUid;
        AppToaster.show({
          message: alreadySavedTitle
            ? `All messages are already saved in the Roam chat "${alreadySavedTitle}".`
            : "All messages are already saved in Roam — nothing new to insert.",
          timeout: 6000,
        });
        return;
      }

      // If this is the first insertion (entire conversation), generate a title
      const isFirstInsertion = currentInsertedCount === 0;

      if (isFirstInsertion) {
        const targetContent = getBlockContentByUid(targetRef.current);

        if (targetContent.trim()) {
          let firstMsg = newMessages.shift();
          if (firstMsg?.content && !firstMsg?.content?.includes("liveai/chat"))
            firstMsg.content += " #liveai/chat";
          updateBlock({
            blockUid: targetRef.current,
            newContent: firstMsg?.content,
          });
          // Set the title from the first message content (without the tag)
          setLoadedChatUid(targetRef.current);
          setLoadedChatTitle(
            firstMsg?.content?.replace(/#liveai\/chat/g, "").trim() || "",
          );
        } else {
          // Create a summary of the conversation for the LLM (first 50 chars of each message)
          const conversationSummary = newMessages
            .map((msg, idx) => {
              const roleLabel = msg.role === "user" ? "User" : "Assistant";
              const content = msg.content || "";
              const snippet = content.substring(0, 50).replace(/\n/g, " ");
              return `${idx + 1}. ${roleLabel}: ${snippet}...`;
            })
            .join("\n");

          // Use model-specific assistant role for the title
          const assistantRoleForTitle = getInstantAssistantRole(selectedModel);
          const titlePrompt = `Based on this conversation summary, generate a very short (max 15 words) descriptive title starting with "Chat with ${assistantRoleForTitle}" and ending with "#liveai/chat". Be concise and specific and write in the same language as the one used in the conversation.\n\nConversation:\n${conversationSummary}`;

          // Generate title using insertCompletion
          // Type assertion needed because insertCompletion has many optional parameters
          try {
            await (insertCompletion as any)({
              instantModel: selectedModel,
              prompt: titlePrompt,
              targetUid: targetRef.current,
              target: "replace",
              isButtonToInsert: false,
            });
          } catch (titleError) {
            console.error("Failed to generate title:", titleError);
            // Fallback to a simple title if generation fails
            updateBlock({
              blockUid: targetRef.current,
              newContent: `Chat with ${assistantRoleForTitle} #liveai/chat`,
            });
          }
        }

        // After title generation, get the actual title and set both UID and title
        // Remove the #liveai/chat tag from the displayed title
        const generatedTitle = getBlockContentByUid(targetRef.current);
        setLoadedChatUid(targetRef.current);
        setLoadedChatTitle(generatedTitle.replace(/#liveai\/chat/g, "").trim());
      }

      // Insert each message separately to properly handle markdown structure
      // For each message: create role block, then parse content as children
      let currentTargetUid = targetRef.current;
      let isFirstMessage = true;

      // Track the UIDs created for each message (for later updating chatMessages)
      const insertedMessageUids: Array<{
        originalIndex: number;
        roleBlockUid: string;
      }> = [];

      for (let i = 0; i < newMessagesWithIndex.length; i++) {
        const { msg, originalIndex } = newMessagesWithIndex[i];

        // Resolve the per-message model. For debate turns this is the
        // participant's model, not the currently-selected chat model.
        const stepType = msg.councilStep?.type;
        const isDebateTurn =
          stepType === "debate-turn" || stepType === "debate-reaction";
        const messageModel = isDebateTurn
          ? msg.councilStep?.model || msg.model || selectedModel
          : msg.role === "assistant" && msg.model
            ? msg.model
            : selectedModel;
        const assistantRole = getInstantAssistantRole(messageModel);
        // Debate speaker header: "<assistant role> — [↪] R<n> · <Speaker Name>"
        // so each turn is clearly attributed when re-read in Roam.
        let rolePrefix: string;
        if (isDebateTurn) {
          const step = msg.councilStep!;
          if (step.isHuman) {
            rolePrefix = chatRoles.user;
          } else {
            const speakerName = step.speakerName || "Participant";
            const roundPrefix = step.round ? `R${step.round} · ` : "";
            const reactionPrefix =
              stepType === "debate-reaction" ? "↪ " : "";
            rolePrefix = `${assistantRole} — ${reactionPrefix}${roundPrefix}${speakerName}`;
          }
        } else {
          rolePrefix = msg.role === "user" ? chatRoles.user : assistantRole;
        }

        // Build the full content including command name if present
        const shouldShowCommandName =
          msg.role === "user" &&
          msg.commandName &&
          msg.commandPrompt !== "prompt";

        let fullContent = "";
        if (shouldShowCommandName) {
          // Check if commandPrompt is a UID (custom prompt) or a built-in command key
          // Custom prompts have UIDs (9 characters), built-in commands have keys like "summarize"
          const isCustomPrompt =
            msg.commandPrompt && msg.commandPrompt.length === 9;
          const commandDisplay = isCustomPrompt
            ? `**[((${msg.commandPrompt}))]**`
            : `**[${msg.commandName}]**`;

          fullContent = msg.content
            ? `${commandDisplay}\n\n${msg.content}`
            : commandDisplay;
        } else {
          fullContent = msg.content || "";
        }

        // Convert markdown formatting to Roam-native formatting
        // This converts: *italic* → __italic__, ==highlight== → ^^highlight^^, and markdown tables → Roam tables
        fullContent = convertMarkdownToRoamFormat(fullContent);

        // Create the role block
        let roleBlockUid: string;
        if (
          isFirstMessage &&
          (currentInsertedCount === 0 || !isFirstInsertion)
        ) {
          // First message - create as child of title block
          roleBlockUid = await createChildBlock(currentTargetUid, rolePrefix);
          isFirstMessage = false;
        } else {
          // Subsequent messages - create as siblings
          roleBlockUid = await createSiblingBlock(
            currentTargetUid,
            undefined,
            rolePrefix,
          );
        }

        // Track the UID for this message
        insertedMessageUids.push({ originalIndex, roleBlockUid });

        // Parse and insert the message content as children of the role block
        // parseAndCreateBlocks will handle markdown structure (lists, headers, etc.)
        if (fullContent.trim()) {
          await parseAndCreateBlocks(roleBlockUid, fullContent, false);
        }

        // Update target for next message
        currentTargetUid = roleBlockUid;
      }

      // Update chatMessages with their roamBlockUid so they can be edited in place
      setChatMessages((prev) => {
        const updated = [...prev];
        for (const { originalIndex, roleBlockUid } of insertedMessageUids) {
          if (updated[originalIndex]) {
            updated[originalIndex] = {
              ...updated[originalIndex],
              roamBlockUid: roleBlockUid,
              isTemporaryBlock: false,
            };
          }
        }
        return updated;
      });

      // Update the count to reflect that these messages are now in Roam
      // This prevents re-inserting the same messages if the button is clicked again
      setInsertedMessagesCount(chatMessages.length);

      // Show success message with count
      const insertedCount = newMessages.length;
      const msgWord = insertedCount > 1 ? "messages" : "message";
      const finalTitle = getBlockContentByUid(targetRef.current)
        .replace(/#liveai\/chat/g, "")
        .trim();
      let successMessage: string;
      if (isFirstInsertion) {
        const pageTitle = forceDNP ? getDNPTitleFromDate(new Date()) : null;
        const locationHint = pageTitle
          ? ` on [[${pageTitle}]]`
          : finalTitle
            ? ` as "${finalTitle}"`
            : "";
        successMessage = `Conversation saved${locationHint} (${insertedCount} ${msgWord})`;
      } else {
        const chatRef =
          loadedChatTitle || finalTitle || loadedChatUid || "the existing chat";
        successMessage = `${insertedCount} new ${msgWord} appended to "${chatRef}"`;
      }
      AppToaster.show({
        message: successMessage,
        intent: "success",
        timeout: 4000,
      });
    } catch (error) {
      console.error("Failed to insert conversation in Roam:", error);
      AppToaster.show({
        message: "Failed to insert conversation. Check console for details.",
        intent: "danger",
        timeout: 5000,
      });
    }
  };

  const copyAssistantMessage = async (messageContent: string) => {
    // Keep markdown formatting but remove HTML tags
    const cleanText = messageContent.replace(/<[^>]*>/g, "").trim();
    await copyToClipboard(cleanText);
  };

  const handleDeleteMessage = (messageIndex: number) => {
    // Delete both the user message and assistant response (chat turn)
    // Find the corresponding user message (should be the one before the assistant message)
    const userMessageIndex = messageIndex - 1;

    if (
      userMessageIndex >= 0 &&
      chatMessages[userMessageIndex]?.role === "user"
    ) {
      // Delete both user and assistant messages
      setChatMessages((prev) =>
        prev.filter(
          (_, idx) => idx !== userMessageIndex && idx !== messageIndex,
        ),
      );
    } else {
      // Just delete the assistant message if user message not found
      setChatMessages((prev) => prev.filter((_, idx) => idx !== messageIndex));
    }

    // Invalidate cached conversation history so it rebuilds from chatMessages on next send
    if (chatAgentData?.conversationHistory) {
      setChatAgentData({
        ...chatAgentData,
        conversationHistory: null,
      });
    }
  };

  const handleRetryMessage = async (messageIndex: number) => {
    // Find the user message that triggered this assistant response
    const userMessageIndex = messageIndex - 1;

    if (
      userMessageIndex < 0 ||
      chatMessages[userMessageIndex]?.role !== "user"
    ) {
      AppToaster.show({
        message: "Cannot retry: user message not found",
        intent: "warning",
        timeout: 3000,
      });
      return;
    }

    if (isTyping) {
      AppToaster.show({
        message: "Please wait for the current response to complete",
        intent: "warning",
        timeout: 3000,
      });
      return;
    }

    const userMessage = chatMessages[userMessageIndex];

    // Remove all messages after and including the user message
    setChatMessages((prev) => prev.slice(0, userMessageIndex));

    // Re-add the user message and regenerate
    setChatMessages((prev) => [...prev, userMessage]);

    // Build the correct truncated history from messages BEFORE the retried turn
    // and set it as override so processChatMessage uses it instead of the stale
    // chatAgentData.conversationHistory (which still contains the old response)
    const messagesBeforeRetry = chatMessages.slice(0, userMessageIndex);
    conversationHistoryOverrideRef.current = messagesBeforeRetry.map((msg) => {
      const role = msg.role === "user" ? "User" : "Assistant";
      return `${role}: ${msg.content}`;
    });

    // Also reset persisted agent state so next turn after this one starts clean
    if (chatAgentData) {
      setChatAgentData({
        ...chatAgentData,
        conversationHistory: null,
      });
    }

    setIsTyping(true);
    setIsStreaming(true);
    setStreamingContent("");
    setToolUsageHistory([]);
    toolUsageHistoryRef.current = [];

    // Create abort controller for this request
    const abortController = new AbortController();
    chatAbortRef.current = abortController;

    try {
      const contextResults = getSelectedResultsForChat();

      // Check if the message had a command
      if (userMessage.commandPrompt && userMessage.commandName) {
        await processChatMessageWithCommand(
          userMessage.content,
          contextResults,
          userMessage.commandPrompt,
          userMessage.commandName,
          undefined,
          selectedStyle,
        );
      } else {
        await processChatMessage(
          userMessage.content,
          contextResults,
          undefined,
          undefined,
          undefined,
          selectedStyle,
        );
      }
    } catch (error: any) {
      if (error.name === "AbortError") {
        const partialContent = streamingContentRef.current.trim();
        const abortMessage: ChatMessage = {
          role: "assistant",
          content: partialContent
            ? `${partialContent}\n\n*— Generation stopped by user.*`
            : "*Generation stopped by user.*",
          timestamp: new Date(),
        };
        setChatMessages((prev) => [...prev, abortMessage]);
      } else {
        console.error("Retry error:", error);
        const errorMessage: ChatMessage = {
          role: "assistant",
          content:
            "Sorry, I encountered an error while retrying. Please try again.",
          timestamp: new Date(),
        };
        setChatMessages((prev) => [...prev, errorMessage]);
      }
    }

    chatAbortRef.current = null;
    setIsTyping(false);
    setIsStreaming(false);
    setStreamingContent("");
  };

  /**
   * Start editing a message - creates a temporary block if needed
   */
  const handleEditMessage = async (messageIndex: number) => {
    if (isTyping || editingMessageIndex !== null) {
      return; // Don't allow editing while typing or already editing
    }

    const message = chatMessages[messageIndex];
    if (!message) return;

    // Store original content for cancel
    setEditingOriginalContent(message.content);
    setEditingMessageIndex(messageIndex);

    // Check if message already has a Roam block UID (saved to Roam)
    if (message.roamBlockUid && isExistingBlock(message.roamBlockUid)) {
      // Use existing block for editing - no temp block needed
      editTempBlockUidRef.current = null;
    } else {
      // Create temporary block for editing with role prefix
      try {
        // Get the appropriate role prefix based on message role
        const rolePrefix =
          message.role === "user"
            ? chatRoles.user
            : getInstantAssistantRole(message.model || selectedModel);

        const tempBlockUid = await createTemporaryEditBlock(
          message.content,
          rolePrefix,
        );
        editTempBlockUidRef.current = tempBlockUid;

        // Update the message to point to the temp block
        setChatMessages((prev) => {
          const updated = [...prev];
          updated[messageIndex] = {
            ...updated[messageIndex],
            roamBlockUid: tempBlockUid,
            isTemporaryBlock: true,
          };
          return updated;
        });
      } catch (error) {
        console.error("Failed to create temporary edit block:", error);
        setEditingMessageIndex(null);
        setEditingOriginalContent("");
        AppToaster.show({
          message: "Failed to start editing. Please try again.",
          intent: "danger",
          timeout: 3000,
        });
      }
    }
  };

  /**
   * Save the edited message - extracts content from block and updates state
   */
  const handleSaveEdit = async (messageIndex: number) => {
    if (editingMessageIndex !== messageIndex) return;

    const message = chatMessages[messageIndex];
    if (!message?.roamBlockUid) {
      handleCancelEdit(messageIndex);
      return;
    }

    try {
      // Extract content from the block
      const newContent = extractContentFromBlock(message.roamBlockUid);

      // Update the message with new content
      setChatMessages((prev) => {
        const updated = [...prev];
        updated[messageIndex] = {
          ...updated[messageIndex],
          content: newContent,
          // Keep roamBlockUid if it's a real block, clear if temp
          roamBlockUid: message.isTemporaryBlock
            ? undefined
            : message.roamBlockUid,
          isTemporaryBlock: false,
        };
        return updated;
      });

      // IMPORTANT: Invalidate cached conversation history so the edited content
      // will be used in subsequent AI interactions
      // Clear the conversationHistory from chatAgentData so it rebuilds from chatMessages
      if (chatAgentData?.conversationHistory) {
        setChatAgentData({
          ...chatAgentData,
          conversationHistory: null, // Force rebuild from chatMessages on next send
        });
      }

      // Cleanup temporary block if it exists
      if (message.isTemporaryBlock && editTempBlockUidRef.current) {
        await cleanupTemporaryBlock(editTempBlockUidRef.current);
      }

      AppToaster.show({
        message: "Message updated",
        intent: "success",
        timeout: 2000,
      });
    } catch (error) {
      console.error("Failed to save edit:", error);
      AppToaster.show({
        message: "Failed to save edit. Please try again.",
        intent: "danger",
        timeout: 3000,
      });
    } finally {
      setEditingMessageIndex(null);
      setEditingOriginalContent("");
      editTempBlockUidRef.current = null;
    }
  };

  /**
   * Cancel editing - restores original content and cleans up temp block
   */
  const handleCancelEdit = async (messageIndex: number) => {
    if (editingMessageIndex !== messageIndex) return;

    const message = chatMessages[messageIndex];

    // Restore original content if we have it
    if (editingOriginalContent) {
      setChatMessages((prev) => {
        const updated = [...prev];
        updated[messageIndex] = {
          ...updated[messageIndex],
          content: editingOriginalContent,
          // Clear temp block reference
          roamBlockUid: message?.isTemporaryBlock
            ? undefined
            : message?.roamBlockUid,
          isTemporaryBlock: false,
        };
        return updated;
      });
    }

    // Cleanup temporary block if it exists
    if (editTempBlockUidRef.current) {
      try {
        await cleanupTemporaryBlock(editTempBlockUidRef.current);
      } catch (error) {
        console.error("Failed to cleanup temp block:", error);
      }
    }

    setEditingMessageIndex(null);
    setEditingOriginalContent("");
    editTempBlockUidRef.current = null;
  };

  const copyFullConversation = async () => {
    const assistantRole = getInstantAssistantRole(selectedModel);
    const conversationText = chatMessages
      .map((msg) => {
        // Skip debate meta messages (status / continue / speaker picker) — not real content.
        const stepType = msg.councilStep?.type;
        if (
          stepType === "status" ||
          stepType === "debate-continue" ||
          stepType === "debate-speaker-picker"
        ) {
          return null;
        }

        // Build the full content including command name if present
        const shouldShowCommandName =
          msg.role === "user" &&
          msg.commandName &&
          msg.commandPrompt !== "prompt";

        let fullContent = "";
        if (shouldShowCommandName) {
          // Check if commandPrompt is a UID (custom prompt) or a built-in command key
          // Custom prompts have UIDs (9 characters), built-in commands have keys like "summarize"
          const isCustomPrompt =
            msg.commandPrompt && msg.commandPrompt.length === 9;
          const commandDisplay = isCustomPrompt
            ? `**[((${msg.commandPrompt}))]**`
            : `**[${msg.commandName}]**`;

          fullContent = msg.content
            ? `${commandDisplay}\n\n${msg.content}`
            : commandDisplay;
        } else {
          fullContent = msg.content;
        }

        // Keep markdown formatting but remove HTML tags
        const cleanContent = fullContent.replace(/<[^>]*>/g, "").trim();

        // Indent ALL lines with 2 spaces to maintain structure under the role
        const indentedContent = cleanContent
          .split("\n")
          .map((line) => `  ${line}`)
          .join("\n");

        // Debate turn / reaction: use speaker name (+ round) as the role, and
        // resolve the assistant role from the per-turn model rather than the
        // currently-selected chat model.
        let role: string;
        if (stepType === "debate-turn" || stepType === "debate-reaction") {
          const step = msg.councilStep!;
          const speakerName = step.speakerName || "Participant";
          const turnModel = step.model || msg.model;
          if (step.isHuman) {
            role = chatRoles.user;
          } else {
            const turnAssistantRole = turnModel
              ? getInstantAssistantRole(turnModel)
              : assistantRole;
            const roundPrefix = step.round ? `R${step.round} · ` : "";
            const reactionPrefix =
              stepType === "debate-reaction" ? "↪ " : "";
            role = `${turnAssistantRole} — ${reactionPrefix}${roundPrefix}${speakerName}`;
          }
        } else {
          role = msg.role === "user" ? chatRoles.user : assistantRole;
        }
        return `${role}\n${indentedContent}`;
      })
      .filter((v): v is string => v !== null)
      .join("\n\n");

    await copyToClipboard(conversationText);
  };

  // Model selection is now handled by ChatInputArea component

  // Handler for clicking on loaded chat title to open the block
  const handleLoadedChatClick = (event: React.MouseEvent) => {
    event.preventDefault();
    if (!loadedChatUid) return;

    // Copy ((uid)) to clipboard
    const clipboardText = `((${loadedChatUid}))`;
    navigator.clipboard
      .writeText(clipboardText)
      .then(() => {})
      .catch((err) => {
        console.warn("Failed to copy to clipboard:", err);
      });

    if (event.shiftKey) {
      // Shift+click: Open in sidebar
      window.roamAlphaAPI.ui.rightSidebar.addWindow({
        window: { type: "block", "block-uid": loadedChatUid },
      });
    } else if (event.altKey || event.metaKey) {
      // Alt/Option+click: Open in main window
      window.roamAlphaAPI.ui.mainWindow.openBlock({
        block: { uid: loadedChatUid },
      });
    } else {
      // Regular click: Just copy to clipboard (already done above)
      AppToaster.show({
        message: `Copied ((${loadedChatUid})) to clipboard`,
        intent: "success",
        timeout: 2000,
      });
    }
  };

  // Handler for loading a chat history from [[liveai/chat]] blocks
  const handleLoadChatHistory = (chatUid: string) => {
    try {
      const conversation = extractConversationFromLiveAIChat(chatUid);

      if (!conversation || conversation.length === 0) {
        AppToaster.show({
          message: "No conversation found in this chat block",
          intent: "warning",
          timeout: 3000,
        });
        return;
      }

      // Get the chat title using the helper function
      const chatTitle = getChatTitleFromUid(chatUid);

      // Store the loaded chat info
      setLoadedChatTitle(chatTitle);
      setLoadedChatUid(chatUid);

      // Check if last message is from user - if so, put it in the input field
      const lastMessage = conversation[conversation.length - 1];
      const isLastMessageFromUser = lastMessage?.role === "user";

      if (isLastMessageFromUser && conversation.length > 1) {
        // Put last user message in input field
        setChatInput(lastMessage.content);

        // Load all previous messages into chat history
        const previousMessages = conversation.slice(0, -1).map((msg) => ({
          role: msg.role,
          content: msg.content,
          timestamp: new Date(),
          roamBlockUid: msg.roamBlockUid, // Preserve block UID for in-place editing
        }));

        setChatMessages(previousMessages);
        setInsertedMessagesCount(previousMessages.length);
      } else {
        // Load all messages into chat history
        const allMessages = conversation.map((msg) => ({
          role: msg.role,
          content: msg.content,
          timestamp: new Date(),
          roamBlockUid: msg.roamBlockUid, // Preserve block UID for in-place editing
        }));

        setChatMessages(allMessages);
        setInsertedMessagesCount(allMessages.length);
        setChatInput("");
      }

      // Reset agent data when loading a new conversation
      setChatAgentData(null);
      setChatExpandedResults(null);
      setLastSelectedResultIds([]);
      setHasExpandedResults(false);

      AppToaster.show({
        message: `Loaded conversation with ${conversation.length} messages`,
        intent: "success",
        timeout: 3000,
      });
    } catch (error) {
      console.error("Error loading chat history:", error);
      AppToaster.show({
        message: "Failed to load chat history",
        intent: "danger",
        timeout: 3000,
      });
    }
  };

  /**
   * Handle command selection from ChatCommandSuggest
   * Instantly execute the command by creating a user message and setting command context
   */
  const handleCommandSelect = async (
    command: any,
    isFromSlashCommand: boolean = false,
    instantModel: string = "",
  ) => {
    if (isTyping) {
      console.warn("⚠️ Cannot execute command while agent is responding");
      return;
    }

    // Mark as auto-executed to prevent the auto-execution effect from running
    hasAutoExecutedRef.current = true;

    // Determine the content to process:
    // When from slash command, extract the content BEFORE the slash command
    // Example: "analyze this text /summarize" -> content = "analyze this text"
    let contentToProcess = chatInput.trim();

    if (isFromSlashCommand) {
      // Find the last "/" and extract everything before it
      const lastSlashIndex = contentToProcess.lastIndexOf("/");
      if (lastSlashIndex !== -1) {
        contentToProcess = contentToProcess.substring(0, lastSlashIndex).trim();
      } else {
        // No slash found (shouldn't happen, but handle gracefully)
        contentToProcess = "";
      }
    }

    // Create user message with command info attached
    const userMessage: ChatMessage = {
      role: "user",
      content: contentToProcess || "", // Empty content means analyzing search results
      timestamp: new Date(),
      commandName: command.name, // Display name (e.g., "Summarize")
      commandPrompt: command.prompt, // This is the key in completionCommands (e.g., "summarize") or custom prompt UID
    };

    // Set command context for processing (will be reset after)
    setCommandContext({
      commandPrompt: command.prompt,
      commandName: command.name,
    });

    // Add user message to chat
    setChatMessages((prev) => [...prev, userMessage]);

    // Clear input
    setChatInput("");

    // Set typing state
    setIsTyping(true);
    setIsStreaming(true);
    setStreamingContent("");
    setToolUsageHistory([]);
    toolUsageHistoryRef.current = []; // Sync ref

    if (command.name?.slice(0, 16) === "Image generation") {
      command.prompt = command.name;
    }

    // Execute the command immediately
    try {
      // Special handling for NL Query commands (id 80, 81)
      // These use dedicated NL interpreters instead of the chat agent
      if (command.id === 80 || command.id === 81) {
        await processNLQueryCommand(
          contentToProcess,
          command.id === 80 ? "roam" : "datomic",
        );
      } else {
        const contextResults = getSelectedResultsForChat();

        await processChatMessageWithCommand(
          contentToProcess, // The actual user input content (or empty if analyzing results)
          contextResults,
          command.prompt, // The command prompt key (e.g., "summarize")
          command.name,
          instantModel,
          selectedStyle, // Pass the selected style
        );
      }
    } catch (error) {
      console.error("Error executing command:", error);
      const errorMessage: ChatMessage = {
        role: "assistant",
        content: formatChatErrorContent(
          "I encountered an error processing your request. Please try again.",
          error,
        ),
        timestamp: new Date(),
      };
      setChatMessages((prev) => [...prev, errorMessage]);
    }

    // Clear typing state after completion
    setIsTyping(false);
    setIsStreaming(false);
    setStreamingContent("");
    // Don't clear tool usage history - it should persist to show user what tools were used

    // Reset command context after processing
    setCommandContext(null);
  };

  /**
   * Process a message in Council mode.
   * Invokes the council orchestrator which handles multi-LLM deliberation
   * and pushes intermediate steps via callback.
   */
  // Shared update/finalize helpers for debate per-turn streaming.
  const handleDebateTurnUpdate = (turnId: string, content: string) => {
    setChatMessages((prev) =>
      prev.map((m) =>
        m.councilStep?.turnId === turnId ? { ...m, content } : m
      )
    );
  };
  const handleDebateTurnFinalize = (
    turnId: string,
    content: string,
    tokensIn: number,
    tokensOut: number,
    concluded: boolean,
    extras?: { isPass?: boolean }
  ) => {
    setChatMessages((prev) =>
      prev.map((m) =>
        m.councilStep?.turnId === turnId
          ? {
              ...m,
              content,
              tokensIn,
              tokensOut,
              councilStep: {
                ...m.councilStep,
                isComplete: true,
                isConclusion: concluded,
                isPass: extras?.isPass,
              },
            }
          : m
      )
    );
  };

  const appendDebateControlMessage = (
    state: import("../../../../ai/agents/council-agent/council-types").DebateState
  ) => {
    if (state.status === "paused-round-limit") {
      setChatMessages((prev) => [
        ...prev,
        {
          role: "assistant",
          content: "",
          timestamp: new Date(),
          debateState: state,
          councilStep: {
            type: "debate-continue",
            councilMode: "debate",
            isIntermediate: false,
          },
        },
      ]);
    } else if (state.status === "paused-human") {
      setChatMessages((prev) => [
        ...prev,
        {
          role: "assistant",
          content: "",
          timestamp: new Date(),
          debateState: state,
          councilStep: {
            type: "debate-speaker-picker",
            councilMode: "debate",
            isIntermediate: false,
          },
        },
      ]);
    } else if (state.status === "concluded") {
      setChatMessages((prev) => [
        ...prev,
        {
          role: "assistant",
          content: "*Debate concluded by participant agreement.*",
          timestamp: new Date(),
          debateState: state,
        },
      ]);
    }
  };

  const runDebateInvocation = async (args: {
    message?: string;
    resume?: boolean;
    additionalRounds?: number;
    forcedNextParticipantIndex?: number;
    humanMessage?: string;
    singleTurnOnly?: boolean;
  }) => {
    const config = { ...councilConfig };
    if (!config.generatorModel) config.generatorModel = selectedModel;
    if (!config.synthesizerModel) config.synthesizerModel = selectedModel;
    // Default the human's display name from Roam when the user hasn't set one.
    // Snapshotted at debate init; `initDebateState` ultimately falls back to
    // "Human" if Roam can't resolve a name either.
    if (
      config.debateSubMode === "human-in-loop" &&
      !(config.debateHumanName && config.debateHumanName.trim())
    ) {
      config.debateHumanName = getRoamUserDisplayName() || "Human";
    }

    const contextResults = getSelectedResultsForChat();
    const conversationContext = chatMessages
      .filter(
        (msg) =>
          !msg.councilStep ||
          (!msg.councilStep.isIntermediate &&
            msg.councilStep.type !== "debate-turn" &&
            msg.councilStep.type !== "debate-continue" &&
            msg.councilStep.type !== "debate-speaker-picker")
      )
      .map(
        (msg) => `${msg.role === "user" ? "User" : "Assistant"}: ${msg.content}`
      )
      .join("\n\n");

    // Adaptive expansion: walk children of selected blocks up to a token-budget,
    // so debate participants see Roam context the same way the normal chat
    // agent does. Budget is sized against the SMALLEST participant context
    // window — every participant must be able to ingest the result. Skip the
    // whole step on resume (context was already expanded on the first turn and
    // is embedded in state.referenceBlocks).
    let expandedContextResults: Result[] | undefined = undefined;
    if (!args.resume && contextResults.length > 0) {
      const participantModels = config.debateParticipants
        .map((p) => p.modelId)
        .filter(Boolean);
      const modelsToCheck =
        participantModels.length > 0 ? participantModels : [selectedModel];
      let minTokens = Number.POSITIVE_INFINITY;
      for (const mid of modelsToCheck) {
        const info = modelAccordingToProvider(mid);
        const limit = info?.tokensLimit || 32000;
        if (limit < minTokens) minTokens = limit;
      }
      if (!Number.isFinite(minTokens)) minTokens = 32000;
      // Same character-budget scaling as normal chat (FullResultsChat.tsx:3298).
      // Debate allocates a smaller slice than normal chat because each turn
      // also carries the growing transcript + peer prompts — don't burn the
      // whole window on block context.
      const expansionBudget =
        chatAccessMode === "Full Access" ? minTokens * 2.5 : minTokens * 1.5;
      try {
        expandedContextResults = await performAdaptiveExpansion(
          contextResults.map((r) => ({ ...r })),
          expansionBudget,
          0,
          chatAccessMode,
        );
      } catch (err) {
        console.warn(
          "[Debate] adaptive expansion failed, falling back to raw results:",
          err,
        );
        expandedContextResults = contextResults;
      }
    }

    const abortController = new AbortController();
    councilAbortRef.current = abortController;

    try {
      const result = await invokeCouncil({
        config,
        userMessage:
          args.message ?? debateStateRef.current?.topic ?? "",
        conversationContext: conversationContext || undefined,
        resultsContext:
          expandedContextResults && expandedContextResults.length > 0
            ? expandedContextResults
            : contextResults.length > 0
              ? contextResults
              : undefined,
        style: selectedStyle !== "Normal" ? selectedStyle : undefined,
        intermediateCallback: (intermediateMessage: ChatMessage) => {
          setChatMessages((prev) => [...prev, intermediateMessage]);
        },
        updateTurnCallback: handleDebateTurnUpdate,
        finalizeTurnCallback: handleDebateTurnFinalize,
        abortSignal: abortController.signal,
        resumeState: args.resume ? debateStateRef.current || undefined : undefined,
        additionalRounds: args.additionalRounds,
        forcedNextParticipantIndex: args.forcedNextParticipantIndex,
        humanMessage: args.humanMessage,
        singleTurnOnly: args.singleTurnOnly,
      });

      setIsStreaming(false);
      setStreamingContent("");

      if ("state" in result && result.state) {
        setDebateState(result.state);
        appendDebateControlMessage(result.state);
      }
    } catch (error: any) {
      if (error.name === "AbortError") {
        setChatMessages((prev) => [
          ...prev,
          {
            role: "assistant",
            content: "*Debate was stopped.*",
            timestamp: new Date(),
          },
        ]);
      } else {
        throw error;
      }
    } finally {
      councilAbortRef.current = null;
    }
  };

  // Strip any pending debate control cards (speaker-picker, continue) from the
  // chat history. They're meaningful only while the debate is paused awaiting
  // the user — once the user has acted, the card is obsolete chrome and
  // clutters the transcript. A fresh card is re-appended by
  // appendDebateControlMessage when the next pause occurs.
  const stripDebateControlMessages = () => {
    setChatMessages((prev) =>
      prev.filter(
        (m) =>
          m.councilStep?.type !== "debate-speaker-picker" &&
          m.councilStep?.type !== "debate-continue",
      ),
    );
  };

  // Exposed to the chat UI: extend the debate by another round-limit's worth.
  const handleContinueDebate = async () => {
    if (!debateStateRef.current) return;
    stripDebateControlMessages();
    setIsTyping(true);
    setIsStreaming(true);
    try {
      await runDebateInvocation({
        resume: true,
        additionalRounds: councilConfig.debateMaxRounds,
      });
    } finally {
      setIsTyping(false);
      setIsStreaming(false);
    }
  };

  // Human-in-loop: user picks a specific speaker for one turn.
  const handlePickDebateSpeaker = async (participantIndex: number) => {
    if (!debateStateRef.current) return;
    stripDebateControlMessages();
    setIsTyping(true);
    setIsStreaming(true);
    try {
      await runDebateInvocation({
        resume: true,
        forcedNextParticipantIndex: participantIndex,
        singleTurnOnly: true,
      });
    } finally {
      setIsTyping(false);
      setIsStreaming(false);
    }
  };

  // Human-in-loop: user speaks as themselves, then auto-advances to next LLM speaker.
  const handleHumanDebateMessage = async (text: string) => {
    if (!debateStateRef.current) return;
    const target = humanDebateTargetRef.current;
    stripDebateControlMessages();
    setIsTyping(true);
    setIsStreaming(true);
    try {
      await runDebateInvocation({
        resume: true,
        humanMessage: text,
        singleTurnOnly: true,
        forcedNextParticipantIndex:
          target != null && target >= 0 ? target : undefined,
      });
    } finally {
      // Consume the target so the next typed message re-defaults to "Anyone"
      // unless the user re-picks.
      setHumanDebateTarget(null);
      setIsTyping(false);
      setIsStreaming(false);
    }
  };

  const processCouncilMessage = async (message: string) => {
    // Dispatch debate mode separately — it uses per-turn streaming + resumable state.
    if (councilConfig.mode === "debate") {
      setDebateState(null);
      debateStateRef.current = null;
      await runDebateInvocation({
        message,
        singleTurnOnly: councilConfig.debateSubMode === "human-in-loop",
      });
      return;
    }

    // Build conversation context from prior messages (only final answers, not intermediate steps)
    const conversationContext = chatMessages
      .filter((msg) => !msg.councilStep || !msg.councilStep.isIntermediate)
      .map((msg) => {
        const role = msg.role === "user" ? "User" : "Assistant";
        return `${role}: ${msg.content}`;
      })
      .join("\n\n");

    // Ensure config has defaults populated
    const config = { ...councilConfig };
    if (!config.generatorModel) config.generatorModel = selectedModel;
    if (!config.synthesizerModel) config.synthesizerModel = selectedModel;
    if (config.evaluatorModels.length === 0) {
      config.evaluatorModels = [selectedModel];
    }
    if (config.competitorModels.length === 0) {
      config.competitorModels = [selectedModel];
    }

    // Get context results if available
    const contextResults = getSelectedResultsForChat();

    // Create abort controller
    const abortController = new AbortController();
    councilAbortRef.current = abortController;

    try {
      const result = await invokeCouncil({
        config,
        userMessage: message,
        conversationContext: conversationContext || undefined,
        resultsContext: contextResults.length > 0 ? contextResults : undefined,
        style: selectedStyle !== "Normal" ? selectedStyle : undefined,
        streamingCallback: (content: string) => {
          setStreamingContent(content);
        },
        intermediateCallback: (intermediateMessage: ChatMessage) => {
          setChatMessages((prev) => [...prev, intermediateMessage]);
        },
        abortSignal: abortController.signal,
      });

      // Clear streaming
      setIsStreaming(false);
      setStreamingContent("");

      // If the final answer wasn't already posted via intermediateCallback
      // (for iterative mode, the last generation IS the final answer)
      // Add a final summary message with total tokens
      if (result.finalAnswer && result.mode === "iterative") {
        // The final generation was already posted; no need to duplicate
      }

      // Update conversation context for multi-turn
      // Only the final answer goes into conversation history
      if (result.finalAnswer) {
        const finalHistoryEntry = `Assistant: ${result.finalAnswer}`;
        const currentHistory = chatAgentData?.conversationHistory || [];
        setChatAgentData({
          ...chatAgentData,
          conversationHistory: [
            ...currentHistory,
            `User: ${message}`,
            finalHistoryEntry,
          ],
        });
      }
    } catch (error: any) {
      if (error.name === "AbortError") {
        const abortMessage: ChatMessage = {
          role: "assistant",
          content: "*Council deliberation was cancelled.*",
          timestamp: new Date(),
        };
        setChatMessages((prev) => [...prev, abortMessage]);
      } else {
        throw error;
      }
    } finally {
      councilAbortRef.current = null;
    }
  };

  const handleChatSubmit = async () => {
    if (!chatInput.trim() || isTyping) return;

    // Debate human-in-loop: when paused awaiting the user, route the input
    // as a human turn instead of starting a new chat thread.
    if (
      chatMode === "council" &&
      councilConfig.mode === "debate" &&
      councilConfig.debateSubMode === "human-in-loop" &&
      debateStateRef.current?.status === "paused-human"
    ) {
      const text = chatInput.trim();
      setChatInput("");
      await handleHumanDebateMessage(text);
      return;
    }

    const userMessage: ChatMessage = {
      role: "user",
      content: chatInput.trim(),
      timestamp: new Date(),
    };

    setChatMessages((prev) => [...prev, userMessage]);
    setChatInput("");
    setIsTyping(true);
    setIsStreaming(true);
    setStreamingContent("");
    setToolUsageHistory([]);
    toolUsageHistoryRef.current = []; // Sync ref

    // Create abort controller for this request
    const abortController = new AbortController();
    chatAbortRef.current = abortController;

    try {
      if (chatMode === "council") {
        await processCouncilMessage(userMessage.content);
      } else {
        const contextResults = getSelectedResultsForChat();
        await processChatMessage(
          userMessage.content,
          contextResults,
          undefined,
          undefined,
          undefined,
          selectedStyle,
        );
      }
    } catch (error: any) {
      if (error.name === "AbortError") {
        // Capture whatever was streamed so far as a partial response
        const partialContent = streamingContentRef.current.trim();
        const abortMessage: ChatMessage = {
          role: "assistant",
          content: partialContent
            ? `${partialContent}\n\n*— Generation stopped by user.*`
            : "*Generation stopped by user.*",
          timestamp: new Date(),
        };
        setChatMessages((prev) => [...prev, abortMessage]);
      } else {
        console.error("Chat error:", error);
        const errorMessage: ChatMessage = {
          role: "assistant",
          content: formatChatErrorContent(
            "Sorry, I encountered an error processing your request. Please try again.",
            error,
          ),
          timestamp: new Date(),
        };
        setChatMessages((prev) => [...prev, errorMessage]);
      }
    }

    chatAbortRef.current = null;
    setIsTyping(false);
    setIsStreaming(false);
    setStreamingContent("");
    // Don't clear tool usage history - it should persist to show user what tools were used
  };

  const handleStopGeneration = () => {
    if (chatAbortRef.current) {
      chatAbortRef.current.abort();
    }
    if (councilAbortRef.current) {
      councilAbortRef.current.abort();
    }
  };

  // Handler for help buttons
  const handleHelpButtonClick = async (
    type: "chat" | "liveai" | "tip" | "helpabout",
    promptOrContent: string,
  ) => {
    if (isTyping) return;

    // Special case: "helpabout" just inserts text in the input
    if (type === "helpabout") {
      setChatInput(promptOrContent);
      return;
    }

    // All help messages are inserted as assistant messages (no LLM call)
    const assistantMessage: ChatMessage = {
      role: "assistant",
      content: promptOrContent,
      timestamp: new Date(),
      isHelpMessage: true, // Mark as help message for special rendering
      helpType: type, // Store the type to know which buttons to show
    };

    setChatMessages((prev) => {
      // Help messages always replace the previous help message if it exists
      if (prev.length > 0) {
        const lastMessage = prev[prev.length - 1];
        if (lastMessage.role === "assistant" && lastMessage.isHelpMessage) {
          // Replace the last help message with the new one
          return [...prev.slice(0, -1), assistantMessage];
        }
      }
      // Otherwise, append the new message
      return [...prev, assistantMessage];
    });
  };

  /**
   * Process a Natural Language Query command in chat.
   * 1. Calls the NL interpreter to generate a query from the user's natural language
   * 2. Displays the generated query as an assistant message (rendered with renderString)
   * 3. Executes the query to get results
   * 4. Adds results to the chat context for follow-up questions
   */
  const processNLQueryCommand = async (
    userInput: string,
    queryType: "roam" | "datomic",
  ) => {
    if (!userInput.trim()) {
      const errorMessage: ChatMessage = {
        role: "assistant",
        content:
          "Please provide a natural language description of what you want to query.",
        timestamp: new Date(),
      };
      setChatMessages((prev) => [...prev, errorMessage]);
      return;
    }

    try {
      // Phase 1: Generate the query using the NL interpreter
      let queryString: string | null = null;

      if (queryType === "roam") {
        const result = await generateNLQuery({
          model: selectedModel,
          prompt: userInput,
        });
        if (result) {
          queryString = result.roamQuery;
        }
      } else {
        const result = await generateNLDatomicQuery({
          model: selectedModel,
          prompt: userInput,
        });
        if (result && result.datomicQuery) {
          // Extract just the Datomic query part (the LLM might include description)
          let query = result.datomicQuery.trim();

          // Remove :q prefix if present to clean the text
          if (query.startsWith(":q")) {
            query = query.substring(2).trim();
          }

          // Extract the actual query (starts with [:find)
          const queryMatch = query.match(/(\[:find[\s\S]*\])/);
          if (queryMatch) {
            query = queryMatch[1];
          }

          // Add :q prefix for display
          queryString = `:q ${query}`;
        }
      }

      if (!queryString) {
        const errorMessage: ChatMessage = {
          role: "assistant",
          content:
            "Failed to generate a query from your description. Please try rephrasing.",
          timestamp: new Date(),
        };
        setChatMessages((prev) => [...prev, errorMessage]);
        return;
      }

      // Phase 2: Execute the query and load results
      let queryResults: Result[] = [];

      try {
        if (queryType === "roam") {
          queryResults = await executeRoamNativeQuery(queryString);
        } else {
          queryResults = await executeChatDatomicQuery(queryString);
        }
      } catch (executionError: any) {
        console.error("Query execution error:", executionError);

        // Still show the query that was generated
        const assistantMessage: ChatMessage = {
          role: "assistant",
          content: queryString,
          timestamp: new Date(),
          queryContent: queryString,
          queryType: queryType,
          queryResultCount: 0,
        };
        setChatMessages((prev) => [...prev, assistantMessage]);

        // Show error message
        const errorMessage: ChatMessage = {
          role: "assistant",
          content: `❌ **Query execution failed:** ${executionError.message || "Unknown error"}\n\nPlease try rephrasing your query or check the generated query syntax.`,
          timestamp: new Date(),
        };
        setChatMessages((prev) => [...prev, errorMessage]);
        return;
      }

      // Phase 3: Create assistant message with query content
      const assistantMessage: ChatMessage = {
        role: "assistant",
        content: queryString,
        timestamp: new Date(),
        queryContent: queryString,
        queryType: queryType,
        queryResultCount: queryResults.length,
      };
      setChatMessages((prev) => [...prev, assistantMessage]);

      // Phase 4: Add results to context
      if (queryResults.length > 0) {
        console.log(
          `📊 [NLQuery] Adding ${queryResults.length} results to context:`,
          queryResults.slice(0, 3),
        );
        onAddResults(queryResults);

        const contextMessage: ChatMessage = {
          role: "assistant",
          content: `Found **${queryResults.length} result${queryResults.length !== 1 ? "s" : ""}**. The results have been loaded into the chat context. You can now ask follow-up questions about them.`,
          timestamp: new Date(),
        };
        setChatMessages((prev) => [...prev, contextMessage]);
      } else {
        const noResultsMessage: ChatMessage = {
          role: "assistant",
          content:
            "The query returned no results. Try modifying your query description.",
          timestamp: new Date(),
        };
        setChatMessages((prev) => [...prev, noResultsMessage]);
      }
    } catch (error) {
      console.error(`Error in NL ${queryType} query:`, error);
      throw error;
    }
  };

  /**
   * Execute a native Roam query using the roamQuery API with the query property directly.
   * Extracts the query content from {{[[query]]: ...}} wrapper and passes it to the API.
   */
  const executeRoamNativeQuery = async (
    queryString: string,
  ): Promise<Result[]> => {
    // Extract the query content from {{[[query]]: ...}} or {{query: ...}} wrapper
    let queryContent = queryString.trim();
    const match = queryContent.match(
      /\{\{\s*(?:\[\[query\]\]|query)\s*:\s*([\s\S]*)\}\}$/,
    );
    if (match) {
      queryContent = match[1].trim();
    }

    if (!queryContent) return [];

    console.log("📊 [NLQuery] Executing with query property:", queryContent);

    try {
      const queryResponse = await (window as any).roamAlphaAPI.data.roamQuery({
        query: queryContent,
        limit: null,
      });

      console.log("📊 [NLQuery] roamQuery response:", queryResponse);

      const queryResults = queryResponse?.results;
      if (
        !queryResults ||
        !Array.isArray(queryResults) ||
        queryResults.length === 0
      ) {
        console.warn("📊 [NLQuery] No results returned");
        return [];
      }

      console.log(`📊 [NLQuery] Processing ${queryResults.length} raw results`);

      const results: Result[] = [];
      for (const result of queryResults) {
        const resultUid = result?.[":block/uid"];
        if (!resultUid) continue;

        const blockData = (window as any).roamAlphaAPI.pull(
          "[:block/uid :block/string :block/page {:block/page [:block/uid :node/title]}]",
          [":block/uid", resultUid],
        );
        if (blockData) {
          const pageInfo = blockData[":block/page"];
          results.push({
            uid: resultUid,
            content: blockData[":block/string"] || "",
            text: blockData[":block/string"] || "",
            pageUid: pageInfo?.[":block/uid"] || "",
            pageTitle: pageInfo?.[":node/title"] || "",
          });
        }
      }
      console.log(`📊 [NLQuery] Returning ${results.length} processed results`);
      return results;
    } catch (error) {
      console.error("📊 [NLQuery] Query execution failed:", error);
      throw error;
    }
  };

  /**
   * Parse the :find clause of a Datomic query to extract projected variable names.
   * Returns an array of variable info objects with name and whether it's an aggregate.
   */
  const parseFindClause = (
    query: string,
  ): Array<{ name: string; isAggregate: boolean }> => {
    const findMatch = query.match(
      /:find\s+(.*?)(?:\s*:(?:where|in|with)|\s*\])/s,
    );
    if (!findMatch) return [];

    const findClause = findMatch[1].trim();
    const variables: Array<{ name: string; isAggregate: boolean }> = [];

    // Match aggregates like (count ?x), (sum ?x), (sample 5 ?x) and plain ?variables
    const tokenRegex =
      /\((?:count|sum|avg|min|max|sample\s+\d+|distinct)\s+(\?[\w-]+)\)(?:\s*\.)?|\?[\w-]+/g;
    let match;
    while ((match = tokenRegex.exec(findClause)) !== null) {
      if (match[1]) {
        // Aggregate — the inner variable
        variables.push({ name: match[1].substring(1), isAggregate: true });
      } else {
        // Plain variable
        variables.push({
          name: match[0].substring(1),
          isAggregate: false,
        });
      }
    }
    return variables;
  };

  /**
   * Resolve a cell value (string UID or numeric entity ID) to a block UID.
   * Returns the UID string, or null if unresolvable.
   */
  const resolveToUid = (cell: unknown): string | null => {
    if (typeof cell === "string" && /^[a-zA-Z0-9_-]{9,12}$/.test(cell)) {
      // Already looks like a UID (9-char standard, up to 12 for some edge cases)
      return cell;
    }
    if (typeof cell === "number" && Number.isInteger(cell) && cell > 0) {
      // Numeric entity ID — resolve via pull
      try {
        const pulled = (window as any).roamAlphaAPI.pull(
          "[:block/uid]",
          cell,
        );
        return pulled?.[":block/uid"] || null;
      } catch {
        return null;
      }
    }
    return null;
  };

  /**
   * Enrich a UID into a full Result object by pulling block/page data.
   */
  const enrichUidToResult = (uid: string): Result | null => {
    const data = (window as any).roamAlphaAPI.pull(
      "[:block/uid :block/string :node/title :block/page {:block/page [:block/uid :node/title]}]",
      [":block/uid", uid],
    );
    if (!data) return null;

    const pageInfo = data[":block/page"];
    const title = data[":node/title"];

    if (title) {
      // This is a page
      return {
        uid,
        content: title,
        text: title,
        pageUid: uid,
        pageTitle: title,
      };
    }
    // This is a block
    return {
      uid,
      content: data[":block/string"] || "",
      text: data[":block/string"] || "",
      pageUid: pageInfo?.[":block/uid"] || "",
      pageTitle: pageInfo?.[":node/title"] || "",
    };
  };

  /**
   * Execute a Datomic :q query directly using roamAlphaAPI.q()
   * Parses the :find clause to understand projected variables, resolves
   * both string UIDs and numeric entity IDs, and skips aggregates.
   */
  const executeChatDatomicQuery = async (
    queryString: string,
  ): Promise<Result[]> => {
    try {
      // Strip the :q prefix if present
      let cleanQuery = queryString?.trim() || "";
      if (!cleanQuery) {
        throw new Error("Empty query string");
      }

      if (cleanQuery.startsWith(":q")) {
        cleanQuery = cleanQuery.substring(2).trim();
      }

      // Remove optional description string (e.g. :q "description"\n[:find ...])
      cleanQuery = cleanQuery.replace(/^"[^"]*"\s*/, "");

      // Extract just the Datomic query part (starts with [:find)
      const queryMatch = cleanQuery.match(/(\[:find[\s\S]*\])/);
      if (queryMatch) {
        cleanQuery = queryMatch[1];
      } else {
        throw new Error(
          "Could not find valid Datomic query pattern [:find ...]",
        );
      }

      // Parse :find clause to understand what each column represents
      const findVars = parseFindClause(cleanQuery);
      const isScalarResult = cleanQuery.match(/:find\s+.*\.\s*(?::|\])/s);

      console.log(
        "Executing Datomic query:",
        cleanQuery,
        "| find vars:",
        findVars.map((v) => (v.isAggregate ? `(agg ${v.name})` : v.name)),
      );

      let rawResults;
      try {
        rawResults = (window as any).roamAlphaAPI.q(cleanQuery);
      } catch (queryError: any) {
        console.error("Datomic query execution error:", queryError);
        throw new Error(
          `Query execution failed: ${queryError.message || "Unknown error"}`,
        );
      }

      // Scalar result (e.g. (count ?x) .) — not extractable to context
      if (isScalarResult && !Array.isArray(rawResults?.[0])) {
        console.log("Query returned scalar result:", rawResults);
        return [];
      }

      if (!rawResults || !Array.isArray(rawResults)) {
        console.warn("Query returned no results or invalid format");
        return [];
      }

      console.log(`Query returned ${rawResults.length} rows`);
      if (rawResults.length === 0) return [];

      // Identify which columns contain resolvable entities (non-aggregate)
      const resolvableIndices = findVars
        .map((v, i) => (v.isAggregate ? -1 : i))
        .filter((i) => i >= 0);

      const results: Result[] = [];
      const seenUids = new Set<string>();

      for (const row of rawResults) {
        const cells = Array.isArray(row) ? row : [row];

        // Try resolvable columns first (based on :find clause parsing)
        const indicesToTry =
          resolvableIndices.length > 0
            ? resolvableIndices
            : cells.map((_, i) => i);

        for (const idx of indicesToTry) {
          if (idx >= cells.length) continue;
          const uid = resolveToUid(cells[idx]);
          if (uid && !seenUids.has(uid)) {
            seenUids.add(uid);
            const result = enrichUidToResult(uid);
            if (result) {
              results.push(result);
              break; // One result per row to avoid duplicates from multi-column projections
            }
          }
        }
      }
      return results;
    } catch (error) {
      console.warn("Failed to execute Datomic query:", error);
      return [];
    }
  };

  const processChatMessage = async (
    message: string,
    contextResults: Result[],
    commandPromptFromCall?: string,
    commandNameFromCall?: string,
    commandModelFromCall?: string,
    styleFromCall?: string,
  ) => {
    try {
      // Update the ref with current context results for expansion callback
      currentContextResultsRef.current = contextResults;

      // Check if result selection has changed
      const currentResultIds = contextResults
        .map((r) => r.uid || r.pageUid || r.pageTitle)
        .filter(Boolean);
      const selectionChanged =
        JSON.stringify(currentResultIds.sort()) !==
        JSON.stringify(lastSelectedResultIds.sort());

      let expandedResults: Result[];

      if (selectionChanged || !chatExpandedResults) {
        setLastSelectedResultIds(currentResultIds);

        // Perform expansion once and cache the expanded objects
        // Use proper expansion budgets based on access mode and model context window
        // expansionBudget is expressed in approx. maximum characters
        const expansionBudget =
          chatAccessMode === "Full Access"
            ? modelTokensLimit * 3.8
            : modelTokensLimit * 2; //  ~90% context window vs ~50% context window
        expandedResults = await performAdaptiveExpansion(
          contextResults.map((result) => ({ ...result })), // Pass deep copies to prevent any mutation
          expansionBudget,
          0,
          chatAccessMode, // Pass access mode to influence depth strategy
        );
        setChatExpandedResults(expandedResults);
      } else {
        // Reuse cached expanded objects
        expandedResults = chatExpandedResults;
      }

      console.log(
        `📝 [Chat] Using ${
          expandedResults.length
        } expanded results with avg ${Math.round(
          expandedResults.reduce(
            (sum, r) => sum + (r.content?.length || 0),
            0,
          ) / expandedResults.length,
        )} chars per result`,
      );

      // Build results description for context
      const resultsDescription = selectionChanged
        ? `🔄 IMPORTANT: The user has changed their result selection since the last message. The results below represent the NEW selection (${expandedResults.length} items).`
        : `You are analyzing search results extracted from a Roam Research graph database. The user can already see the raw content and metadata - your job is to provide INSIGHTS, ANALYSIS, and UNDERSTANDING.`;

      // Use conversation history from previous agent turn (if available)
      // This preserves commandPrompt instructions and other context added by the agent
      // Only rebuild from chatMessages if no agent data exists (first turn)
      // conversationHistoryOverrideRef is set by retry/delete to bypass stale agent history
      const currentConversationHistory =
        conversationHistoryOverrideRef.current ||
        chatAgentData?.conversationHistory ||
        chatMessages.map((msg) => {
          const role = msg.role === "user" ? "User" : "Assistant";
          return `${role}: ${msg.content}`;
        });
      conversationHistoryOverrideRef.current = null; // consume the override

      // Get command prompt if provided
      let commandPrompt: string | undefined = undefined;
      let commandName: string | undefined =
        commandNameFromCall || commandContext?.commandName;

      if (commandPromptFromCall) {
        // Handle <language> placeholder for translate commands
        if (commandPromptFromCall === "translate") {
          // Get the language from the commandLabel (label contains the language name)
          // }
          commandPromptFromCall += `:${
            commandName?.includes("Translate to")
              ? commandName?.split("(")[1]?.slice(0, -1)
              : commandName
          }`;
          // console.log("commandPrompt after :>> ", commandPrompt);
        }
      }

      // Set a status message for long-running operations
      if (isFileExportRequest(commandPromptFromCall)) {
        const fmt =
          commandPromptFromCall === "Export to DOCX"
            ? "DOCX"
            : commandPromptFromCall === "Export to PPTX"
              ? "PPTX"
              : "PDF";
        setStatusMessage(
          `⏳ Generating ${fmt} with Claude Skill — this may take 1–2 minutes or more depending on data provided...`,
        );
      }

      // Create abort promise that rejects when abort signal fires
      const abortPromise = chatAbortRef.current
        ? new Promise<never>((_, reject) => {
            const signal = chatAbortRef.current!.signal;
            if (signal.aborted) {
              reject(new DOMException("Aborted", "AbortError"));
              return;
            }
            signal.addEventListener(
              "abort",
              () => reject(new DOMException("Aborted", "AbortError")),
              { once: true },
            );
          })
        : null;

      // Detect "Use 'tool_name': " prefix to force-enable a tool for this turn
      const forcedToolMatch = message.match(/^Use '([^']+)':\s*/);
      let effectiveEnabledTools = enabledTools;
      let effectiveToolsEnabled = chatMode === "agent";
      if (forcedToolMatch) {
        const forcedToolName = forcedToolMatch[1];
        effectiveEnabledTools = new Set(enabledTools);
        effectiveEnabledTools.add(forcedToolName);
        // Also enable edit section master switch if forcing an edit tool
        const editTools = ["create_block", "create_page", "update_block", "delete_block"];
        if (editTools.includes(forcedToolName)) {
          effectiveEnabledTools.add("section:edit");
        }
        effectiveToolsEnabled = true; // Force agent mode for this turn
        console.log(`🔧 [Chat] Force-enabling tool '${forcedToolName}' for this turn. Effective tools:`, Array.from(effectiveEnabledTools));
      }

      // Build model with per-session advanced params
      const modelForInvocation = {
        ...modelAccordingToProvider(
          commandModelFromCall || selectedModel,
          thinkingEnabled,
        ),
        advancedParams: getActiveAdvancedParams(advancedOptions),
      };

      // Invoke the chat agent (race with abort if available)
      const agentInvocation = invokeChatAgent({
        model: modelForInvocation,
        userMessage: message,

        // Chat session ID for multi-turn image editing
        chatSessionId: chatSessionIdRef.current,

        // Results context - pass the expanded results directly
        resultsContext: expandedResults,
        resultsDescription,

        // Configuration
        style: styleFromCall,
        commandPrompt: commandPromptFromCall,
        toolsEnabled: effectiveToolsEnabled,
        enabledTools: effectiveEnabledTools,
        accessMode: chatAccessMode,
        isAgentMode: effectiveToolsEnabled,

        // Permissions
        permissions: {
          contentAccess: chatAccessMode === "Full Access",
          noTruncation,
        },

        // Per-session PDF extraction override
        includePdf: getIncludePdfOverride(advancedOptions),

        // Conversation state from previous turns
        conversationHistory: currentConversationHistory,
        // Only a real agent-produced summary — never the chat title, which is
        // not a conversation summary and would mislead the model.
        conversationSummary: chatAgentData?.conversationSummary,
        exchangesSinceLastSummary:
          chatAgentData?.exchangesSinceLastSummary || 0,

        // Active skill instructions from previous turns
        activeSkillInstructions: chatAgentData?.activeSkillInstructions,

        // Tool results cache from previous turns (for deduplication)
        toolResultsCache: chatAgentData?.toolResultsCache,

        // Image edition mode state from previous turns
        imageEditionMode: chatAgentData?.imageEditionMode,
        imageGenerationModelId: chatAgentData?.imageGenerationModelId,
        lastGeneratedImageUrl: chatAgentData?.lastGeneratedImageUrl,

        // Streaming
        streamingCallback: (content: string) => {
          setStreamingContent((prev) => prev + content);
        },

        // Tool usage callback
        toolUsageCallback: (toolInfo: {
          toolName: string;
          args?: Record<string, any>;
        }) => {
          // Capture any intermediate message that was streamed before the tool call
          const intermediateMessage = streamingContentRef.current.trim();

          // Build detailed description based on tool name and args
          let details = "";

          if (toolInfo.toolName === "live_ai_skills") {
            const skillName = toolInfo.args?.skill_name || "unknown skill";
            const resourceTitle = toolInfo.args?.resource_title;

            if (resourceTitle) {
              details = `Loading deeper resource: "${resourceTitle}" from skill "${skillName}"`;
            } else {
              details = `Loading skill: "${skillName}"`;
            }
          } else if (
            toolInfo.toolName === "create_block" ||
            toolInfo.toolName === "create_page"
          ) {
            // Special formatting for create_block/create_page tools
            const args = toolInfo.args || {};
            const hasContent = args.markdown_content?.trim();

            if (hasContent) {
              // Insertion mode - show rich context with parent, siblings, and content

              // Build parent block display with clickable reference
              let parentDisplay: string;
              if (args.is_page && args.page_name) {
                parentDisplay = `[[${args.page_name}]]`;
              } else if (args.parent_content) {
                parentDisplay = `"${args.parent_content}" <span class="roam-block-ref-chat" data-block-uid="${args.parent_uid}">((${args.parent_uid}))</span>`;
              } else if (args.parent_uid) {
                parentDisplay = `<span class="roam-block-ref-chat" data-block-uid="${args.parent_uid}">((${args.parent_uid}))</span>`;
              } else {
                parentDisplay =
                  args.target || args.page_title || args.date || "main view";
              }

              // Build insertion location context showing outline preview with existing content + new content
              let locationContext = "";
              if (args.outline_preview) {
                // New unified outline preview showing existing content + insertion point
                locationContext = `\n<div class="tool-insertion-context">\n<div class="tool-outline-preview">\n<div class="existing-content">${args.outline_preview}</div>\n<div class="tool-insertion-marker">➤ <em>New content here</em> (${args.insertion_position || "last"})</div>\n</div>\n</div>`;
              } else if (args.sibling_above || args.sibling_below) {
                // Fallback to old format if outline_preview not available
                locationContext = `\n<div class="tool-insertion-context">`;
                if (args.sibling_above) {
                  locationContext += `<div class="tool-sibling above">↑ "${args.sibling_above}"</div>`;
                }
                locationContext += `<div class="tool-insertion-marker">➤ <em>New content here</em> (${args.insertion_position || "last"})</div>`;
                if (args.sibling_below) {
                  locationContext += `<div class="tool-sibling below">↓ "${args.sibling_below}"</div>`;
                }
                locationContext += `</div>`;
              } else if (args.insertion_position) {
                locationContext = ` *(${args.insertion_position})*`;
              }

              // Normalize list markers and indentation for display
              let normalizedContent = args.markdown_content.replace(
                /^(\s*)•\s/gm,
                "$1- ",
              );

              // Find lines that start with list markers (-, *, or numbered)
              const lines = normalizedContent.split("\n");
              const listLineIndents = lines
                .filter(
                  (line: string) =>
                    /^\s*[-*]\s/.test(line) || /^\s*\d+\.\s/.test(line),
                )
                .map((line: string) => {
                  const match = line.match(/^(\s*)/);
                  return match ? match[1].length : 0;
                });

              // If we have list items and the minimum indent is > 0, normalize
              if (listLineIndents.length > 0) {
                const minIndent = Math.min(...listLineIndents);
                if (minIndent > 0) {
                  const indentRegex = new RegExp(`^\\s{${minIndent}}`, "gm");
                  normalizedContent = normalizedContent.replace(
                    indentRegex,
                    "",
                  );
                }
              }

              details = `**Parent:** ${parentDisplay}${locationContext}\n\n**Content to insert:**\n<div class="tool-markdown-content">\n${normalizedContent}\n</div>`;
            } else {
              // Analysis mode - just show brief "Analyzing" message (content will show only on insertion call)
              // Try to extract target from tool response if available (contains formatted DNP title)
              let target = "main view";
              const response = (toolInfo as any).response;
              if (response && typeof response === "string") {
                // Extract from response like "📄 Analyzed page [[February 5th, 2026]]" or "📄 Analyzed block ((uid))"
                const pageMatch = response.match(
                  /Analyzed page \[\[([^\]]+)\]\]/,
                );
                const blockMatch = response.match(
                  /Analyzed block \(\(([^)]+)\)\)/,
                );
                if (pageMatch) {
                  target = `[[${pageMatch[1]}]]`;
                } else if (blockMatch) {
                  target = `((${blockMatch[1]}))`;
                } else {
                  // Fallback to args
                  target = args.page_name
                    ? `[[${args.page_name}]]`
                    : args.page_title
                      ? `[[${args.page_title}]]`
                      : args.parent_uid
                        ? `((${args.parent_uid}))`
                        : "main view";
                }
              } else {
                // No response yet, use args
                target = args.page_name
                  ? `[[${args.page_name}]]`
                  : args.page_title
                    ? `[[${args.page_title}]]`
                    : args.parent_uid
                      ? `((${args.parent_uid}))`
                      : "main view";
              }
              details = `Analyzing: ${target}`;
            }
          } else if (
            toolInfo.toolName === "update_block" ||
            toolInfo.toolName === "delete_block"
          ) {
            // Special formatting for update_block/delete_block tools
            // Filter out empty/null/undefined values and empty arrays
            const args = toolInfo.args || {};
            const filteredArgs: Record<string, any> = {};
            for (const [key, value] of Object.entries(args)) {
              // Skip empty values
              if (value === null || value === undefined || value === "")
                continue;
              // Skip empty arrays
              if (Array.isArray(value) && value.length === 0) continue;
              // Skip default values that LLMs send
              if (key === "heading" && value === 0) continue;
              if (key === "open" && value === true) continue;
              if (key === "smart_move" && value === false) continue;
              filteredArgs[key] = value;
            }

            const mode =
              args.mode ||
              (args.batch_operations?.length > 0 ||
              args.batch_block_uids?.length > 0
                ? "batch"
                : "single");
            const target =
              args.page_title ||
              args.parent_uid ||
              args.block_uid ||
              "main view";

            if (mode === "browse") {
              details = `Browsing: ${target}`;
            } else if (args.batch_operations?.length > 0) {
              details = `Batch update: ${args.batch_operations.length} block(s)`;
            } else if (args.batch_block_uids?.length > 0) {
              details = `Batch delete: ${args.batch_block_uids.length} block(s)`;
            } else {
              // Single block mode - show filtered args
              const argsSummary = Object.entries(filteredArgs)
                .filter(([key]) => key !== "mode") // mode already shown in context
                .map(([key, value]) => {
                  const displayValue =
                    typeof value === "string" && value.length > 60
                      ? value.substring(0, 60) + "..."
                      : JSON.stringify(value);
                  return `${key}: ${displayValue}`;
                })
                .join(", ");
              details = argsSummary || "No changes";
            }
          } else if (toolInfo.toolName === "ask_user_choice") {
            // Friendly summary for ask_user_choice — hide raw parameters
            const args = toolInfo.args || {};
            const optionGroups = args.options || [];
            const groupSummaries = optionGroups.map((opt: any) => {
              const type = opt.type || "radio";
              const choiceCount = opt.choices?.length || 0;
              if (type === "text") return `"${opt.label}" (free text)`;
              if (type === "slider")
                return `"${opt.label}" (slider ${opt.min ?? 0}–${opt.max ?? 10})`;
              if (type === "checkbox")
                return `"${opt.label}" (${choiceCount} choices, multiple)`;
              return `"${opt.label}" (${choiceCount} choices)`;
            });
            details = `**${args.title || "Choice form"}** — ${groupSummaries.join(", ")}`;
          } else if (toolInfo.toolName === "random_pick") {
            // Friendly summary for random_pick — hide the raw items array
            const args = toolInfo.args || {};
            const count = args.count ?? 1;
            const total = (args.items || []).length;
            const selectInUi = args.select_in_ui ? ", select in UI" : "";
            details = `${count} of ${total} item${total !== 1 ? "s" : ""}${selectInUi}`;
          } else if (toolInfo.toolName === "add_to_context") {
            // Friendly summary for add_to_context
            const args = toolInfo.args || {};
            const parts: string[] = [];
            if (args.page_titles?.length > 0) {
              parts.push(args.page_titles.map((t: string) => `[[${t}]]`).join(", "));
            }
            if (args.block_uids?.length > 0) {
              parts.push(args.block_uids.map((u: string) => `((${u}))`).join(", "));
            }
            if (args.use_current_page) parts.push("current page");
            if (args.use_focused_block) parts.push("focused block");
            if (args.use_sidebar) parts.push("sidebar");
            if (args.linked_refs_page_titles?.length > 0) {
              parts.push(`linked refs of ${args.linked_refs_page_titles.map((t: string) => `[[${t}]]`).join(", ")}`);
            }
            if (args.use_current_page_refs) parts.push("current page linked refs");
            if (args.daily_notes_count > 0) parts.push(`${args.daily_notes_count} daily note(s)`);
            details = parts.length > 0 ? parts.join(", ") : "Loading context...";
          } else if (toolInfo.toolName === "run_smartblock") {
            // Friendly summary for run_smartblock
            const args = toolInfo.args || {};
            const src = args.src_name ? `'${args.src_name}'` : `((${args.src_uid}))`;
            const target = args.target_name
              ? `[[${args.target_name}]]`
              : args.target_uid
                ? `((${args.target_uid}))`
                : args.date || "";
            details = `Run ${src} SmartBlock` + (target ? ` on ${target}` : "");
          } else if (toolInfo.toolName === "select_results_by_criteria") {
            const args = toolInfo.args || {};
            const criteria = args.criteria_description || "custom criteria";
            details = `Selecting results by: ${criteria.length > 80 ? criteria.substring(0, 80) + "..." : criteria}`;
          } else if (toolInfo.toolName === "ask_your_graph") {
            const args = toolInfo.args || {};
            const query = args.query || args.natural_language_query || "";
            details = query ? `Query: ${query.length > 80 ? query.substring(0, 80) + "..." : query}` : "Querying graph...";
          } else if (toolInfo.toolName === "get_help") {
            const args = toolInfo.args || {};
            const topic = args.topic || args.query || "";
            details = topic ? `Topic: ${topic}` : "Loading help...";
          } else if (toolInfo.toolName === "vector_search") {
            const args = toolInfo.args || {};
            const query = args.query || "";
            details = query ? `Search: ${query.length > 80 ? query.substring(0, 80) + "..." : query}` : "Searching...";
          } else {
            // For other tools, show arguments only (tool name is already in the title)
            if (toolInfo.args) {
              const argsSummary = Object.entries(toolInfo.args)
                .map(([key, value]) => {
                  // Show full value without truncation - important for user transparency
                  return `${key}: ${JSON.stringify(value)}`;
                })
                .join(", ");
              details = argsSummary || "No arguments";
            } else {
              details = "No arguments";
            }
          }

          // Add new tool usage to history (stacking them)
          const newToolUsage = {
            toolName: toolInfo.toolName,
            details,
            timestamp: Date.now(),
            intermediateMessage: intermediateMessage || undefined,
          };

          // Update both state and ref
          setToolUsageHistory((prev) => {
            const updated = [...prev, newToolUsage];
            toolUsageHistoryRef.current = updated;
            return updated;
          });
        },

        // Tool response callback - updates the tool usage with the response
        toolResponseCallback: (toolInfo: {
          toolName: string;
          response: string;
        }) => {
          // Filter response for analysis/browse mode (hide outline from user)
          let displayResponse = toolInfo.response;
          let vectorSearchData: VectorSearchUIPayload | undefined;

          if (toolInfo.toolName === "vector_search") {
            // Extract structured UI payload from vector search response
            const uiMatch = toolInfo.response.match(
              /<!--VECTOR_SEARCH_UI:(.*?):END_VECTOR_SEARCH_UI-->/s
            );
            if (uiMatch) {
              try {
                vectorSearchData = JSON.parse(uiMatch[1]);
              } catch (e) {
                console.warn("[Chat] Failed to parse vector search UI data:", e);
              }
              // Show a clean summary as the text response
              const count = vectorSearchData?.results?.length || 0;
              const time = vectorSearchData?.executionTime || "";
              displayResponse = `Found ${count} result${count !== 1 ? "s" : ""} in ${time}s`;
            }
          } else if (
            (toolInfo.toolName === "create_block" ||
              toolInfo.toolName === "create_page") &&
            toolInfo.response.startsWith("📄 Analyzed")
          ) {
            // Extract just the first line (summary) for display
            const firstLine = toolInfo.response.split("\n")[0];
            displayResponse = firstLine + " *(outline sent to assistant)*";
          } else if (
            (toolInfo.toolName === "update_block" ||
              toolInfo.toolName === "delete_block") &&
            toolInfo.response.startsWith("📄 ")
          ) {
            // Browse mode response - hide outline from user, just show summary
            const firstLine = toolInfo.response.split("\n")[0];
            displayResponse = firstLine + " *(outline sent to assistant)*";
          }

          // Find the most recent tool usage for this tool and update it with the response
          setToolUsageHistory((prev) => {
            const updatedHistory = [...prev];
            // Find the last occurrence of this tool (most recent)
            for (let i = updatedHistory.length - 1; i >= 0; i--) {
              if (
                updatedHistory[i].toolName === toolInfo.toolName &&
                !updatedHistory[i].response
              ) {
                updatedHistory[i] = {
                  ...updatedHistory[i],
                  response: displayResponse,
                  ...(vectorSearchData ? { vectorSearchData } : {}),
                };
                break;
              }
            }
            // Update ref immediately to avoid closure issues
            toolUsageHistoryRef.current = updatedHistory;
            return updatedHistory;
          });
        },

        // Tool confirmation callback for sensitive operations
        toolConfirmationCallback: async (confirmationRequest: {
          toolName: string;
          toolCallId: string;
          args: Record<string, any>;
        }) => {
          // Create a Promise that will be resolved when user responds
          return new Promise((resolve) => {
            // Store the resolve function in the ref
            pendingConfirmationResolveRef.current = resolve;

            // Set the pending confirmation state to show UI
            setPendingToolConfirmation({
              toolName: confirmationRequest.toolName,
              toolCallId: confirmationRequest.toolCallId,
              args: confirmationRequest.args,
              timestamp: Date.now(),
            });
          });
        },

        // User choice callback for ask_user_choice tool and PDF export
        userChoiceCallback: async (choiceRequest: any) => {
          return new Promise((resolve) => {
            pendingUserChoiceResolveRef.current = resolve;
            setPendingUserChoice({ ...choiceRequest, timestamp: Date.now() });
          });
        },

        // Pass the set of always-approved tools
        alwaysApprovedTools: alwaysApprovedTools,

        // Agent callbacks
        // Track tool-added results during this agent invocation
        addResultsCallback: (results: Result[]) => {
          // Update parent state
          onAddResults(results);
          // Also track locally for expansion callback
          currentContextResultsRef.current = [
            ...currentContextResultsRef.current,
            ...results,
          ];
        },
        selectResultsCallback: (uids: string[]) => {
          onSelectResultsByUids(uids);
        },
        expandedResultsCallback: async (originalResults: Result[]) => {
          // Re-expand all results (including newly added ones) and return them
          // This happens synchronously during the agent's tool execution phase
          console.log(
            "🔄 [Chat] Re-expanding context with newly added results",
          );

          // The ref has been accumulating tool-added results via addResultsCallback
          // So currentContextResultsRef.current = original + tool-added results
          const allResults = currentContextResultsRef.current;

          console.log(
            `📊 [Chat] Expanding ${allResults.length} results (${
              originalResults.length
            } original + ${
              allResults.length - originalResults.length
            } tool-added)`,
          );

          // Perform expansion with the same budget as initial expansion
          const expansionBudget =
            chatAccessMode === "Full Access"
              ? modelTokensLimit * 3
              : modelTokensLimit * 2;

          const newExpandedResults = await performAdaptiveExpansion(
            allResults.map((result) => ({ ...result })),
            expansionBudget,
            0,
            chatAccessMode,
          );

          // Update the cached expanded results
          setChatExpandedResults(newExpandedResults);

          console.log(
            `📝 [Chat] Re-expanded ${newExpandedResults.length} results after tool additions`,
          );

          return newExpandedResults;
        },

        // Token usage from previous turns
        tokensUsage: chatAgentData?.tokensUsage,

        // Abort signal to stop streaming on user cancel
        abortSignal: chatAbortRef.current?.signal,
      });

      const agentResult = abortPromise
        ? await Promise.race([agentInvocation, abortPromise])
        : await agentInvocation;

      // Update agent data for next conversation turn
      const newAgentData = {
        conversationHistory: agentResult.conversationHistory,
        conversationSummary: agentResult.conversationSummary,
        exchangesSinceLastSummary: agentResult.exchangesSinceLastSummary,
        toolResultsCache: agentResult.toolResultsCache,
        activeSkillInstructions: agentResult.activeSkillInstructions,
        tokensUsage: agentResult.tokensUsage,
        imageEditionMode: agentResult.imageEditionMode,
        imageGenerationModelId: agentResult.imageGenerationModelId,
        lastGeneratedImageUrl: agentResult.lastGeneratedImageUrl,
      };

      setChatAgentData(newAgentData);

      // Debug conversation state
      // console.log(`💬 [Chat] Agent returned conversation state:`, {
      //   conversationHistoryLength: agentResult.conversationHistory?.length || 0,
      //   hasSummary: !!agentResult.conversationSummary,
      //   hasActiveSkillInstructions: !!agentResult.activeSkillInstructions,
      //   tokensUsage: agentResult.tokensUsage,
      // });

      const aiResponse =
        agentResult.finalAnswer ||
        "I couldn't analyze the results. Please try rephrasing your question.";

      // Finalize streaming content or use the final answer
      const finalContent = streamingContentRef.current || aiResponse;
      setIsStreaming(false);
      setStreamingContent("");

      const assistantMessage: ChatMessage = {
        role: "assistant",
        content: finalContent,
        timestamp: new Date(),
        // Per-message tokens = THIS turn's usage only (incl. summarization cost).
        // The running cumulative total is shown in the chat header.
        tokensIn: agentResult.turnTokensDelta?.input_tokens,
        tokensOut: agentResult.turnTokensDelta?.output_tokens,
        model: selectedModel, // Store the model used for this response
        // Use ref to get the current tool usage (avoids stale closure)
        toolUsage:
          toolUsageHistoryRef.current.length > 0
            ? [...toolUsageHistoryRef.current]
            : undefined,
      };

      // Update chat messages first - use functional update to avoid stale closure
      let updatedChatMessages: ChatMessage[] = [];
      setChatMessages((prevMessages) => {
        updatedChatMessages = [...prevMessages, assistantMessage];
        return updatedChatMessages;
      });

      // Clear tool usage history now that it's attached to the message
      setToolUsageHistory([]);
      toolUsageHistoryRef.current = [];

      // Clear any status message
      setStatusMessage(undefined);

      // The agent's built-in conversation management will handle history and summarization automatically
      // We just need to preserve the agent state for the next turn
    } catch (error: any) {
      setStatusMessage(undefined);
      setIsStreaming(false);
      setStreamingContent("");
      // Re-throw AbortError so callers can handle it
      if (error.name === "AbortError") {
        throw error;
      }
      console.error("Chat processing error:", error);
      const errorMessage: ChatMessage = {
        role: "assistant",
        content: formatChatErrorContent(
          "I encountered an error processing your request. Please try again.",
          error,
        ),
        timestamp: new Date(),
      };
      setChatMessages((prev) => [...prev, errorMessage]);
    }
  };

  // Process chat message with command context (for auto-execution)
  const processChatMessageWithCommand = async (
    message: string,
    contextResults: Result[],
    commandPromptKey?: string,
    commandName?: string,
    commandModel?: string,
    styleKey?: string,
  ) => {
    try {
      let finalContextResults = contextResults;

      // Load additional context if the custom prompt has inline context definition
      if (commandPromptKey) {
        const customPrompt = getCustomPromptByUid(commandPromptKey);

        // If the custom prompt has a roamContext definition, load the corresponding elements
        if (customPrompt?.context) {
          try {
            const { results: additionalResults } =
              await loadResultsFromRoamContext({
                roamContext: customPrompt.context,
                rootUid: undefined, // No root UID to exclude
              });

            // Merge the additional results with existing context results
            // Deduplicate by UID
            const existingUids = new Set(
              contextResults.map((r) => r.uid).filter(Boolean),
            );
            const uniqueAdditionalResults = additionalResults.filter(
              (r: Result) => r.uid && !existingUids.has(r.uid),
            );

            finalContextResults = [
              ...contextResults,
              ...uniqueAdditionalResults,
            ];

            // Add the loaded results to the parent component's state so they appear in the UI
            if (uniqueAdditionalResults.length > 0) {
              onAddResults(uniqueAdditionalResults);
            }
          } catch (error) {
            console.error("Error loading context from custom prompt:", error);
            // Continue with original context if loading fails
          }
        }
      }

      // Call processChatMessage with command and style parameters
      // The chat agent will handle command prompt loading and application
      await processChatMessage(
        message,
        finalContextResults,
        commandPromptKey,
        commandName,
        commandModel,
        styleKey,
      );
    } catch (error) {
      console.error("Error processing command:", error);
      throw error;
    }
  };

  // Tool usage messages are now permanent - they provide valuable feedback to users
  // about what actions the agent is taking. No auto-cleanup needed.

  if (!isOpen) return null;

  return (
    <div className="full-results-chat-panel">
      <ChatHeader
        selectedResults={selectedResults}
        allResults={allResults}
        totalIn={totalIn}
        totalOut={totalOut}
        hasExpandedResults={hasExpandedResults}
        chatMessages={chatMessages}
        loadedChatTitle={loadedChatTitle}
        loadedChatUid={loadedChatUid}
        privateMode={privateMode}
        isTyping={isTyping}
        insertedMessagesCount={insertedMessagesCount}
        contextTokenEstimate={contextTokenEstimate}
        willContextBeTruncated={willContextBeTruncated}
        modelTokensLimit={modelTokensLimit}
        chatAccessMode={chatAccessMode}
        isExpandingForEstimate={isExpandingForEstimate}
        isContextExpanded={
          !!(chatExpandedResults && chatExpandedResults.length > 0)
        }
        hasCalculatedTokens={hasCalculatedTokens}
        selectionChangedSinceCalculation={selectionChangedSinceCalculation}
        onExpandForEstimate={handleExpandForEstimate}
        onInsertConversation={insertConversationInRoam}
        onCopyFullConversation={copyFullConversation}
        onResetChat={resetChat}
        onLoadChatHistory={handleLoadChatHistory}
        onLoadedChatClick={handleLoadedChatClick}
        isClearDialogOpen={isClearDialogOpen}
        onSetClearDialogOpen={setIsClearDialogOpen}
      />

      <ChatMessagesDisplay
        chatMessages={chatMessages}
        isTyping={isTyping}
        isStreaming={isStreaming}
        streamingContent={streamingContent}
        toolUsageHistory={toolUsageHistory}
        modelTokensLimit={modelTokensLimit}
        chatAccessMode={chatAccessMode}
        chatMode={chatMode}
        hasSearchResults={allResults.length > 0}
        onCopyMessage={copyAssistantMessage}
        onDeleteMessage={handleDeleteMessage}
        onRetryMessage={handleRetryMessage}
        onSuggestionClick={setChatInput}
        onHelpButtonClick={handleHelpButtonClick}
        showStaleRestore={showStaleRestore}
        staleChatSavedAt={staleChatInfo?.savedAt}
        staleChatMessageCount={staleChatInfo?.messageCount}
        onRestorePreviousChat={handleRestorePreviousChat}
        onDismissPreviousChat={handleDismissPreviousChat}
        messagesContainerRef={messagesContainerRef}
        pendingToolConfirmation={pendingToolConfirmation}
        onToolConfirmationResponse={handleToolConfirmationResponse}
        declineReasonInput={declineReasonInput}
        setDeclineReasonInput={setDeclineReasonInput}
        pendingUserChoice={pendingUserChoice}
        onUserChoiceResponse={handleUserChoiceResponse}
        onUserChoiceCancelled={handleUserChoiceCancelled}
        editingMessageIndex={editingMessageIndex}
        onEditMessage={handleEditMessage}
        onSaveEdit={handleSaveEdit}
        onCancelEdit={handleCancelEdit}
        statusMessage={statusMessage}
        onAddResults={onAddResults}
        onContinueDebate={handleContinueDebate}
        onPickDebateSpeaker={handlePickDebateSpeaker}
        onHumanDebateMessage={handleHumanDebateMessage}
        humanDebateTarget={humanDebateTarget}
        onHumanDebateTargetChange={setHumanDebateTarget}
      />

      <ChatInputArea
        chatInput={chatInput}
        onChatInputChange={setChatInput}
        onSubmit={handleChatSubmit}
        onStop={handleStopGeneration}
        isTyping={isTyping}
        chatAccessMode={chatAccessMode}
        onAccessModeChange={setChatAccessMode}
        noTruncation={noTruncation}
        onNoTruncationChange={setNoTruncation}
        chatMode={chatMode}
        onChatModeChange={setChatMode}
        selectedModel={selectedModel}
        onModelSelect={setSelectedModel}
        chatInputRef={chatInputRef}
        onCommandSelect={handleCommandSelect}
        availablePages={availablePages}
        isLoadingPages={isLoadingPages}
        onQueryPages={queryAvailablePages}
        enabledTools={enabledTools}
        onToggleTool={handleToggleTool}
        isAgentMode={chatMode === "agent"}
        onToggleAgentMode={(enabled) =>
          setChatMode(enabled ? "agent" : "simple")
        }
        selectedStyle={selectedStyle}
        onStyleChange={setSelectedStyle}
        customStyleTitles={customStyleTitles}
        isPinnedStyle={isPinnedStyle}
        onPinnedStyleChange={handlePinnedStyleChange}
        thinkingEnabled={thinkingEnabled}
        onThinkingChange={setThinkingEnabled}
        imageEditionMode={chatAgentData?.imageEditionMode ?? false}
        hasGeneratedImage={!!chatAgentData?.lastGeneratedImageUrl}
        onExitImageEdition={() => {
          setChatAgentData({
            ...chatAgentData,
            imageEditionMode: false,
          });
          const exitMessage: ChatMessage = {
            role: "assistant",
            content:
              "*Exited image edition mode. Your messages will now be processed as regular conversation.*",
            timestamp: new Date(),
          };
          setChatMessages((prev) => [...prev, exitMessage]);
        }}
        onEnterImageEdition={() => {
          setChatAgentData({
            ...chatAgentData,
            imageEditionMode: true,
          });
          const enterMessage: ChatMessage = {
            role: "assistant",
            content:
              "*🖼️ Image edition mode activated. All your messages will now be used to edit the last generated image. Use `/exit-edit` or click the badge to return to conversation mode.*",
            timestamp: new Date(),
          };
          setChatMessages((prev) => [...prev, enterMessage]);
        }}
        onClearChat={() => {
          if (!hasRealMessages(chatMessages)) {
            resetChat();
          } else {
            setIsClearDialogOpen(true);
          }
        }}
        onCloseChat={onClose}
        onChatModeSetSimple={() => setChatMode("simple")}
        onChatModeSetAgent={() => setChatMode("agent")}
        onChatModeSetCouncil={() => setChatMode("council")}
        councilConfig={councilConfig}
        onCouncilConfigChange={handleCouncilConfigChange}
        onSaveChat={() => insertConversationInRoam()}
        onSaveChatDNP={() => insertConversationInRoam(true)}
        advancedOptions={advancedOptions}
        onAdvancedOptionsChange={setAdvancedOptions}
      />
    </div>
  );
};
