// ABOUTME: Chat content panel without file tree for resizable layout.
// ABOUTME: Shows chat messages, input, and model selector.

import { confirm } from "@tauri-apps/plugin-dialog";
/* eslint-disable solid/no-innerhtml */
import type { Component } from "solid-js";
import {
  createEffect,
  createMemo,
  createSignal,
  For,
  onCleanup,
  onMount,
  Show,
  untrack,
} from "solid-js";
import { createStore } from "solid-js/store";
import { SignIn } from "@/components/auth/SignIn";
import { VoiceInputButton } from "@/components/chat/VoiceInputButton";
import { ResizableTextarea } from "@/components/common/ResizableTextarea";
import { SlidePanel } from "@/components/layout/SlidePanel";
import { DepositModal } from "@/components/wallet/DepositModal";
import { isAuthError, isContextOverflowError } from "@/lib/auth-errors";
import {
  formatChatHistoryMarkdown,
  hasExportableMessages,
} from "@/lib/chat-history-export";
import {
  getCompletions,
  isInvokableSkill,
  parseCommand,
  resolveSkillCommand,
} from "@/lib/commands/parser";
import type { CommandContext } from "@/lib/commands/types";
import { createDragDrop } from "@/lib/drag-drop";
import { openExternalLink } from "@/lib/external-link";
import { openFileInTab } from "@/lib/files/service";
import { formatDurationWithVerb } from "@/lib/format-duration";
import {
  groupConsecutiveToolCalls,
  toToolCallEvent,
} from "@/lib/group-tool-calls";
import { pickAndReadAttachments } from "@/lib/images/attachments";
import { isPaymentError } from "@/lib/payment-errors";
import {
  computeProviderBoundaries,
  providerDisplayName,
} from "@/lib/provider-boundaries";
import type { Attachment, ProviderId } from "@/lib/providers/types";
import { escapeHtmlWithSkillsAndLinks } from "@/lib/render-markdown";
import { saveToSerenNotes } from "@/lib/save-to-notes";
import {
  canAcceptSkillDrop,
  draftSkillInvocationFromDrag,
  setCurrentSkillDragPayload,
  skillDragPayload,
} from "@/lib/skill-drag";
import { skillCommandAliases, skillMatchesCommandAlias } from "@/lib/skills";
import {
  buildSkillInvocationDirective,
  buildSkillInvocationDisplay,
  RUN_SKILL_EVENT,
  type RunSkillEventDetail,
} from "@/lib/skills/invoke";
import { appendInputHistory, getInputHistory } from "@/lib/tauri-bridge";
import { catalog, type Publisher } from "@/services/catalog";
import {
  CHAT_MAX_RETRIES,
  type ChatContext,
  sendMessageWithRetry,
} from "@/services/chat";
import {
  correctAnswerMemory,
  forgetMemory,
  suppressMemoryForAnswer,
} from "@/services/memory";
import {
  cancelOrchestration,
  orchestrate,
  retryOrchestration,
} from "@/services/orchestrator";
import {
  allowsSerenPrivateAgent,
  allowsSerenPublicModels,
} from "@/services/organization-policy";
import { skills } from "@/services/skills";
import { authStore, checkAuth } from "@/stores/auth.store";
import { chatStore } from "@/stores/chat.store";
import { conversationStore } from "@/stores/conversation.store";
import { editorStore } from "@/stores/editor.store";
import { fileTreeState } from "@/stores/fileTree";
import { providerStore } from "@/stores/provider.store";
import { settingsStore } from "@/stores/settings.store";
import { skillsStore } from "@/stores/skills.store";
import { workspaceStore } from "@/stores/workspace.store";
import {
  isRetryableWorker,
  type MessageMemoryMetadata,
  type UnifiedMessage,
} from "@/types/conversation";
import RenderMarkdownWorker from "@/workers/render-markdown.worker?worker";
import { CompactedMessage } from "./CompactedMessage";
import {
  COMPOSER_TOOLBAR_LEFT_GROUP_CLASSES,
  COMPOSER_TOOLBAR_RIGHT_GROUP_CLASSES,
  COMPOSER_TOOLBAR_ROOT_CLASSES,
} from "./composerToolbarClasses";
import { ImageAttachmentBar } from "./ImageAttachmentBar";
import { MessageImages } from "./MessageImages";
import { ModelSelector } from "./ModelSelector";
import { PublisherSuggestions } from "./PublisherSuggestions";
import { ReasoningEffortSelector } from "./ReasoningEffortSelector";
import { RerouteAnnouncement } from "./RerouteAnnouncement";
import { RLMStepsBlock } from "./RLMStepsBlock";
import { SatisfactionSignal } from "./SatisfactionSignal";
import { SkillsButton } from "./SkillsButton";
import { SlashCommandPopup } from "./SlashCommandPopup";
import { ThinkingStatus } from "./ThinkingStatus";
import { ToolCallCard } from "./ToolCallCard";
import { ToolCallGroup } from "./ToolCallGroup";
import { ToolsetSelector } from "./ToolsetSelector";
import { TransitionAnnouncement } from "./TransitionAnnouncement";
import "highlight.js/styles/github-dark.css";

// Keywords that trigger publisher suggestions
const SUGGESTION_KEYWORDS = [
  "scrape",
  "crawl",
  "fetch",
  "search",
  "query",
  "database",
  "api",
  "web",
  "data",
  "analyze",
  "extract",
  "research",
];

interface ChatContentProps {
  onSignInClick?: () => void;
  threadId?: string;
  active?: boolean;
}

// Per-thread input drafts so switching threads doesn't leak text between them.
const chatDrafts = new Map<string, string>();

export const ChatContent: Component<ChatContentProps> = (props) => {
  const [input, setInput] = createSignal("");
  const [suggestions, setSuggestions] = createSignal<Publisher[]>([]);
  const [suggestionsLoading, setSuggestionsLoading] = createSignal(false);
  const [suggestionsDismissed, setSuggestionsDismissed] = createSignal(false);
  const [commandStatus, setCommandStatus] = createSignal<string | null>(null);
  const [commandPopupIndex, setCommandPopupIndex] = createSignal(0);
  // Input history navigation (terminal-style up/down arrow)
  const [historyIndex, setHistoryIndex] = createSignal(-1);
  const [savedInput, setSavedInput] = createSignal("");
  // Message queue for sending messages while streaming
  const [messageQueue, setMessageQueue] = createSignal<string[]>([]);
  const [showSignInPrompt, setShowSignInPrompt] = createSignal(false);
  const [showDepositFromError, setShowDepositFromError] = createSignal(false);
  const [memoryPanelMessageId, setMemoryPanelMessageId] = createSignal<
    string | null
  >(null);
  const [memoryActionStatus, setMemoryActionStatus] = createSignal<
    string | null
  >(null);
  const [memoryActionBusy, setMemoryActionBusy] = createSignal(false);
  const [correctionMessageId, setCorrectionMessageId] = createSignal<
    string | null
  >(null);
  const [correctionDraft, setCorrectionDraft] = createSignal("");
  const [attachedImages, setAttachedImages] = createSignal<Attachment[]>([]);
  const [isAttaching, setIsAttaching] = createSignal(false);
  const isPaneActive = () => props.active ?? true;
  const { isDragging } = createDragDrop((files) => {
    if (!isPaneActive()) return;
    setAttachedImages((prev) => [...prev, ...files]);
  });
  let inputRef: HTMLTextAreaElement | undefined;
  let messagesRef: HTMLDivElement | undefined;
  let suggestionDebounceTimer: ReturnType<typeof setTimeout> | undefined;
  let prevConversationId: string | null = null;

  // Off-thread markdown rendering: finalized assistant messages are processed
  // in a Web Worker so renderMarkdown (marked + hljs) never blocks JSC's main
  // thread. The worker returns HTML via onmessage → setHtmlCache → reactive
  // DOM update. Mirrors the same pattern used in AgentChat.tsx.
  const markdownWorker = new RenderMarkdownWorker();
  const [htmlCache, setHtmlCache] = createStore<Record<string, string>>({});
  // Slugs of currently-installed skills. Used by the transcript renderer to
  // decide which leading `/token` user messages should chip; random `/words`
  // in prose stay as plain text.
  const installedSkillSlugs = createMemo(
    () =>
      new Set(
        skillsStore.installed
          .filter(isInvokableSkill)
          .flatMap((skill) => skillCommandAliases(skill)),
      ),
  );
  const conversationId = () =>
    props.threadId ?? conversationStore.activeConversationId;
  const activeThreadProvider = (): ProviderId => {
    const id = conversationId();
    const selectedProvider = id
      ? (conversationStore.conversations.find((c) => c.id === id)
          ?.selectedProvider ??
        chatStore.conversations.find((c) => c.id === id)?.selectedProvider)
      : null;
    if (
      selectedProvider === "seren" ||
      selectedProvider === "seren-private" ||
      selectedProvider === "anthropic" ||
      selectedProvider === "openai"
    ) {
      return selectedProvider;
    }
    return providerStore.activeProvider;
  };
  const privateChatLocked = () =>
    activeThreadProvider() === "seren-private" &&
    !!authStore.privateChatPolicy?.hide_model_picker;
  const conversationMessages = createMemo(() => {
    const id = conversationId();
    return id ? conversationStore.getMessagesFor(id) : [];
  });
  const memoryPanelMessage = createMemo(() => {
    const id = memoryPanelMessageId();
    return id
      ? conversationMessages().find((message) => message.id === id)
      : undefined;
  });
  // Resolve the skill the composer's recent-skill button should label —
  // strictly "last actually invoked in this thread," derived from the
  // transcript. We deliberately do NOT track the composer's current draft
  // here: the button's body click recalls the labeled skill into the
  // composer, and if the label tracked the draft, an accidental click would
  // wipe any args the user had typed. Keeping the label transcript-anchored
  // means the label and the click action describe the same operation
  // ("recall this past invocation"). The composer is the canonical surface
  // for "what's about to run if I hit Enter."
  // Producer-boundary map for the transcript: assistant→assistant
  // transitions where (provider, model) differ get a small marker so
  // the user can see when a turn was produced by a different binding
  // than the prior turn. Keyed by the id of the message that follows
  // the boundary.
  const providerBoundaries = createMemo(() =>
    computeProviderBoundaries(conversationMessages()),
  );

  const persistMemoryUpdate = (
    message: UnifiedMessage,
    memory: MessageMemoryMetadata | undefined,
  ) => {
    const id = conversationId();
    if (!id) return;
    const updated: UnifiedMessage = { ...message, memory };
    conversationStore.updateMessage(message.id, updated, id);
    void conversationStore.persistMessage(updated, id);
  };

  const updateMemoryNotice = (
    message: UnifiedMessage,
    notice: string,
    updater: (memory: MessageMemoryMetadata) => MessageMemoryMetadata,
  ) => {
    const current = message.memory ?? { used: [] };
    persistMemoryUpdate(message, { ...updater(current), notice });
    setMemoryActionStatus(notice);
  };

  const handleForgetMemoryDetail = async (
    message: UnifiedMessage,
    memoryId: string | undefined,
  ) => {
    if (!memoryId || memoryActionBusy()) return;
    setMemoryActionBusy(true);
    setMemoryActionStatus(null);
    try {
      await forgetMemory(memoryId);
      updateMemoryNotice(
        message,
        "Forgot that remembered detail.",
        (memory) => ({
          ...memory,
          used: memory.used.filter((detail) => detail.id !== memoryId),
        }),
      );
    } catch (error) {
      setMemoryActionStatus(
        error instanceof Error ? error.message : String(error),
      );
    } finally {
      setMemoryActionBusy(false);
    }
  };

  const handleSuppressMemoryDetail = async (
    message: UnifiedMessage,
    memoryId: string | undefined,
  ) => {
    if (!memoryId || memoryActionBusy()) return;
    setMemoryActionBusy(true);
    setMemoryActionStatus(null);
    try {
      await suppressMemoryForAnswer(memoryId, message.id);
      updateMemoryNotice(
        message,
        "This answer will not use that detail.",
        (memory) => ({
          ...memory,
          used: memory.used.filter((detail) => detail.id !== memoryId),
        }),
      );
    } catch (error) {
      setMemoryActionStatus(
        error instanceof Error ? error.message : String(error),
      );
    } finally {
      setMemoryActionBusy(false);
    }
  };

  const handleUndoRemember = async (message: UnifiedMessage) => {
    const memoryId = message.memory?.captured?.find((detail) => detail.id)?.id;
    if (!memoryId || memoryActionBusy()) return;
    setMemoryActionBusy(true);
    setMemoryActionStatus(null);
    try {
      await forgetMemory(memoryId);
      updateMemoryNotice(
        message,
        "Removed the memory captured from this turn.",
        (memory) => ({
          ...memory,
          captureStatus: "undone",
        }),
      );
    } catch (error) {
      setMemoryActionStatus(
        error instanceof Error ? error.message : String(error),
      );
    } finally {
      setMemoryActionBusy(false);
    }
  };

  const beginMemoryCorrection = (message: UnifiedMessage) => {
    setMemoryPanelMessageId(message.id);
    setCorrectionMessageId(message.id);
    setCorrectionDraft("");
    setMemoryActionStatus(null);
  };

  const submitMemoryCorrection = async (message: UnifiedMessage) => {
    const correction = correctionDraft().trim();
    if (!correction || memoryActionBusy()) return;
    setMemoryActionBusy(true);
    setMemoryActionStatus(null);
    try {
      const notice = await correctAnswerMemory({
        messageId: message.id,
        correction,
        memories: [
          ...(message.memory?.used ?? []),
          ...(message.memory?.captured ?? []),
        ],
        errorContent: message.error ?? undefined,
        fixContent: message.content,
      });
      updateMemoryNotice(message, notice, (memory) => memory);
      setCorrectionMessageId(null);
      setCorrectionDraft("");
    } catch (error) {
      setMemoryActionStatus(
        error instanceof Error ? error.message : String(error),
      );
    } finally {
      setMemoryActionBusy(false);
    }
  };

  const recentSkill = createMemo(() => {
    const messages = conversationMessages();
    const slugs = installedSkillSlugs();
    if (slugs.size === 0) return null;
    for (let i = messages.length - 1; i >= 0; i -= 1) {
      const message = messages[i];
      if (!message || message.role !== "user") continue;
      const match = message.content.match(/^\/([a-zA-Z][a-zA-Z0-9:_-]*)/);
      if (!match) continue;
      const slug = match[1];
      if (!slug || !slugs.has(slug)) continue;
      const installed = skillsStore.installed.find(
        (s) => isInvokableSkill(s) && skillMatchesCommandAlias(s, slug),
      );
      if (installed) return installed;
    }
    return null;
  });
  const conversationIsLoading = () => {
    const id = conversationId();
    return id ? conversationStore.getLoadingFor(id) : false;
  };
  const conversationIsRLMProcessing = () => {
    const id = conversationId();
    return id ? conversationStore.getRLMProcessingFor(id) : false;
  };
  const streamingContent = () => {
    const id = conversationId();
    return id ? conversationStore.getStreamingContentFor(id) : "";
  };
  const streamingThinking = () => {
    const id = conversationId();
    return id ? conversationStore.getStreamingThinkingFor(id) : "";
  };
  const streamingStalled = () => {
    const id = conversationId();
    return id ? conversationStore.getStreamingStalledFor(id) : false;
  };
  markdownWorker.onmessage = (
    e: MessageEvent<{ id: string; html: string; error?: boolean }>,
  ) => {
    if (e.data.error) {
      console.warn(
        `[ChatContent] Worker markdown render failed for message ${e.data.id}, using fallback`,
      );
    }
    setHtmlCache(e.data.id, e.data.html);
  };
  markdownWorker.onerror = (err) => {
    console.error("[ChatContent] Markdown worker error:", err.message);
  };
  onCleanup(() => markdownWorker.terminate());

  // Enqueue finalized assistant messages to the render worker.
  createEffect(() => {
    for (const msg of conversationMessages()) {
      if (
        msg.role === "assistant" &&
        msg.status !== "streaming" &&
        untrack(() => htmlCache[msg.id]) === undefined
      ) {
        markdownWorker.postMessage({ id: msg.id, markdown: msg.content });
      }
    }
  });

  // Save/restore per-thread input drafts when switching conversations.
  // Two paths cover all the ways a thread can change:
  //   1) In-instance transition: conversationId() flips while ChatContent
  //      stays mounted (e.g. an internal route change). Handled by the
  //      createEffect below.
  //   2) Cross-mount transition (#1996): the pane wrapper in
  //      ThreadContent.tsx keys on `thread:${threadId}`, so binding a new
  //      thread to the pane unmounts this component before conversationId
  //      can transition. The onCleanup captures the current draft on the
  //      way out so the next mount can restore it from the module-scoped
  //      `chatDrafts` Map.
  createEffect(() => {
    const currentId = conversationId();
    if (currentId !== prevConversationId) {
      // Save draft for the thread we're leaving
      if (prevConversationId) {
        const currentInput = input();
        if (currentInput) {
          chatDrafts.set(prevConversationId, currentInput);
        } else {
          chatDrafts.delete(prevConversationId);
        }
      }
      // Restore draft for the thread we're entering (or clear)
      setInput(currentId ? (chatDrafts.get(currentId) ?? "") : "");
      prevConversationId = currentId;
    }
  });

  onCleanup(() => {
    const id = prevConversationId;
    if (!id) return;
    const currentInput = input();
    if (currentInput) {
      chatDrafts.set(id, currentInput);
    } else {
      chatDrafts.delete(id);
    }
  });

  // Suppress the native browser context menu on file links so "Open Link"
  // doesn't navigate the WebView. The JS click handler opens the editor instead.
  const handleContextMenu = (event: MouseEvent) => {
    if (!isPaneActive()) return;
    if ((event.target as HTMLElement).closest(".external-link")) {
      event.preventDefault();
    }
  };

  // Click handler for copy buttons and external links (event delegation)
  const handleCopyClick = (event: MouseEvent) => {
    if (!isPaneActive()) return;
    const target = event.target as HTMLElement;

    // Handle external link clicks
    const externalLink = target.closest(".external-link") as HTMLAnchorElement;
    if (externalLink) {
      event.preventDefault();
      const url = externalLink.dataset.externalUrl;
      if (url) {
        // Detect local file paths and open them in the editor
        const isFilePath =
          url.startsWith("/") ||
          url.startsWith("./") ||
          url.startsWith("../") ||
          url.startsWith("~");
        if (isFilePath && !url.startsWith("//")) {
          const root = fileTreeState.rootPath;
          const resolved =
            url.startsWith("/") || url.startsWith("~")
              ? url
              : root
                ? `${root.replace(/\/$/, "")}/${url}`
                : url;
          openFileInTab(resolved, { cwd: root ?? undefined })
            .then(() => {
              workspaceStore.bindEditorToWorkspace(resolved);
            })
            .catch((err) =>
              console.warn("[ChatContent] Failed to open file:", resolved, err),
            );
        } else {
          openExternalLink(url);
        }
      }
      return;
    }

    const copyBtn = target.closest(".code-copy-btn") as HTMLButtonElement;

    if (copyBtn) {
      const code = copyBtn.dataset.code;
      if (code) {
        // Decode HTML entities
        const textarea = document.createElement("textarea");
        textarea.innerHTML = code;
        const decodedCode = textarea.value;

        // Copy to clipboard
        navigator.clipboard
          .writeText(decodedCode)
          .then(() => {
            // Visual feedback
            const originalText = copyBtn.innerHTML;
            copyBtn.classList.add("copied");
            copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
              <path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path>
            </svg>Copied!`;

            setTimeout(() => {
              copyBtn.classList.remove("copied");
              copyBtn.innerHTML = originalText;
            }, 2000);
          })
          .catch((err) => {
            console.error("Failed to copy code:", err);
          });
      }
    }
  };

  const scrollToBottom = () => {
    if (messagesRef) {
      messagesRef.scrollTop = messagesRef.scrollHeight;
    }
  };

  onMount(async () => {
    console.log(
      "[ChatContent] Mounting, conversationStore.isLoading:",
      conversationIsLoading(),
    );

    // Register copy button handler on document for better reliability
    // Using document-level delegation ensures copy buttons work even if messagesRef timing is off
    document.addEventListener("click", handleCopyClick);
    document.addEventListener("contextmenu", handleContextMenu);

    // Listen for slash command events
    window.addEventListener("seren:pick-images", handlePickImages);
    window.addEventListener("seren:set-chat-input", handleSetChatInput);
    window.addEventListener(RUN_SKILL_EVENT, handleRunSkillEvent);

    try {
      await conversationStore.loadHistory();
    } catch (error) {
      conversationStore.setError((error as Error).message);
    }
  });

  // Debug: log when loading state changes
  createEffect(() => {
    console.log(
      "[ChatContent] conversationStore.isLoading changed to:",
      conversationIsLoading(),
    );
  });

  // Watch for pending input from store (e.g., from catalog "Let's Chat" button)
  createEffect(() => {
    if (!isPaneActive()) return;
    const pending = chatStore.pendingInput;
    if (pending) {
      setInput(pending);
      chatStore.setPendingInput(null);
      // Focus the input after a short delay to ensure panel is visible
      setTimeout(() => {
        if (isPaneActive()) inputRef?.focus();
      }, 100);
    }
  });

  // Auto-scroll to bottom when messages change, streaming starts, or switching channels.
  // Also track htmlCache size: the worker renders HTML asynchronously, so the DOM
  // grows after messages first load. Without tracking htmlCache, the scroll fires
  // once on empty content and never again as worker responses fill in the HTML.
  createEffect(() => {
    void conversationMessages();
    void conversationIsLoading();
    void streamingContent();
    void streamingThinking();
    void Object.keys(htmlCache).length;
    requestAnimationFrame(scrollToBottom);
  });

  onCleanup(() => {
    document.removeEventListener("click", handleCopyClick);
    document.removeEventListener("contextmenu", handleContextMenu);
    window.removeEventListener(
      "seren:pick-images",
      handlePickImages as EventListener,
    );
    window.removeEventListener(
      "seren:set-chat-input",
      handleSetChatInput as EventListener,
    );
    window.removeEventListener(RUN_SKILL_EVENT, handleRunSkillEvent);

    if (suggestionDebounceTimer) {
      clearTimeout(suggestionDebounceTimer);
    }
  });

  // Check if input contains suggestion-triggering keywords
  const shouldShowSuggestions = (text: string): boolean => {
    const lowerText = text.toLowerCase();
    return SUGGESTION_KEYWORDS.some((keyword) => lowerText.includes(keyword));
  };

  // Debounced fetch for publisher suggestions
  const fetchSuggestions = async (query: string) => {
    if (!authStore.isAuthenticated || suggestionsDismissed()) return;

    if (!shouldShowSuggestions(query)) {
      setSuggestions([]);
      return;
    }

    setSuggestionsLoading(true);
    try {
      const results = await catalog.suggest(query);
      setSuggestions(results.slice(0, 3));
    } catch {
      setSuggestions([]);
    } finally {
      setSuggestionsLoading(false);
    }
  };

  // Watch input changes for suggestions
  createEffect(() => {
    const text = input();
    if (suggestionDebounceTimer) {
      clearTimeout(suggestionDebounceTimer);
    }

    if (text.length < 10) {
      setSuggestions([]);
      return;
    }

    suggestionDebounceTimer = setTimeout(() => {
      fetchSuggestions(text);
    }, 500);
  });

  const handlePublisherSelect = (publisher: Publisher) => {
    const currentInput = input();
    const mention = `@${publisher.slug} `;
    setInput(currentInput + (currentInput.endsWith(" ") ? "" : " ") + mention);
    setSuggestions([]);
    inputRef?.focus();
  };

  const handleSkillDragOver = (event: DragEvent) => {
    if (!canAcceptSkillDrop(event)) return;
    event.preventDefault();
    if (event.dataTransfer) {
      event.dataTransfer.dropEffect = "copy";
    }
  };

  const handleSkillDrop = async (event: DragEvent) => {
    const payload = skillDragPayload(event);
    if (!payload) return;
    event.preventDefault();
    setCurrentSkillDragPayload(null);
    const threadId = conversationId() ?? null;
    if (!threadId) return;
    try {
      await draftSkillInvocationFromDrag(payload, {
        kind: "chat",
        threadId,
      });
    } catch (err) {
      console.error("[ChatContent] Failed to draft skill invocation:", err);
    }
  };

  const dismissSuggestions = () => {
    setSuggestions([]);
    setSuggestionsDismissed(true);
  };

  // Reset dismissed state when input is cleared
  createEffect(() => {
    if (input().length === 0) {
      setSuggestionsDismissed(false);
    }
  });

  const contextPreview = createMemo(() => {
    if (!editorStore.selectedText) return null;
    return {
      text: editorStore.selectedText,
      file: editorStore.selectedFile,
      range: editorStore.selectedRange,
    };
  });

  // Persisted per-conversation input history (oldest first, capped at 200).
  // Survives thread switches, compaction, and app restarts — decoupled from
  // conversationStore.messages which may be empty or not yet hydrated.
  const [persistedInputs, setPersistedInputs] = createSignal<string[]>([]);

  createEffect(() => {
    const convId = conversationId();
    if (!convId) {
      setPersistedInputs([]);
      return;
    }
    getInputHistory(convId)
      .then((history) => {
        if (conversationId() === convId) {
          setPersistedInputs(history);
        }
      })
      .catch((err) => {
        console.warn("[ChatContent] Failed to load input history:", err);
        if (conversationId() === convId) {
          setPersistedInputs([]);
        }
      });
  });

  // User message history for up/down arrow navigation (newest first).
  const userMessageHistory = createMemo(() => [...persistedInputs()].reverse());

  const buildContext = (): ChatContext | undefined => {
    if (!editorStore.selectedText) return undefined;
    return {
      content: editorStore.selectedText,
      file: editorStore.selectedFile,
      range: editorStore.selectedRange ?? undefined,
    };
  };

  const handleAttachImages = async () => {
    // Prevent multiple concurrent attach operations
    if (isAttaching()) {
      console.log("[ChatContent] Already attaching, skipping");
      return;
    }

    setIsAttaching(true);
    try {
      console.log("[ChatContent] handleAttachImages called");
      const files = await pickAndReadAttachments();
      console.log(
        "[ChatContent] pickAndReadAttachments returned:",
        files.length,
        "files",
      );
      if (files.length > 0) {
        setAttachedImages((prev) => [...prev, ...files]);
      }
    } catch (error) {
      console.error("[ChatContent] handleAttachImages error:", error);
      conversationStore.setError(
        `Failed to attach files: ${error instanceof Error ? error.message : "Unknown error"}`,
      );
    } finally {
      setIsAttaching(false);
    }
  };

  // Event handler for slash command - must be defined after handleAttachImages
  const handlePickImages = () => {
    if (!isPaneActive()) return;
    handleAttachImages();
  };

  // Event handler for setting chat input (e.g., from skill invocation)
  const handleSetChatInput = (event: Event) => {
    if (!isPaneActive()) return;
    // Skip if another handler already processed this event (prevents
    // duplicate sends when both AgentChat and ChatContent are mounted).
    if ((event as CustomEvent & { _handled?: boolean })._handled) return;

    const customEvent = event as CustomEvent<
      | string
      | {
          text: string;
          autoSend?: boolean;
          skipAuthGate?: boolean;
          command?: string;
        }
    >;
    const detail = customEvent.detail;

    // Support both string (legacy) and object format
    if (typeof detail === "string") {
      setInput(detail);
      inputRef?.focus();
    } else if (detail && typeof detail === "object") {
      setInput(detail.text);
      inputRef?.focus();

      // Auto-send if requested (e.g., from skill click)
      if (detail.autoSend) {
        (event as CustomEvent & { _handled?: boolean })._handled = true;
        // Use setTimeout to ensure input is set before sending
        setTimeout(() => {
          sendMessage({
            skipAuthGate: detail.skipAuthGate === true,
            command: detail.command,
          });
        }, 0);
      }
    }
  };

  const handleRemoveImage = (index: number) => {
    setAttachedImages((prev) => prev.filter((_, i) => i !== index));
  };

  const handleRunSkillEvent = (event: Event) => {
    const detail = (event as CustomEvent<RunSkillEventDetail>).detail;
    if (!detail || detail.kind !== "chat") return;
    if (conversationId() !== detail.threadId) return;

    // Run from the panel fills the composer with `/slug ` (cursor at end)
    // rather than auto-sending. Every skill surface (chip, recall button,
    // palette select, panel Run) behaves the same way — the user adds any
    // args and presses Enter to send. The `<skill-invocation>` directive
    // gets constructed at send time via resolveSkillCommand.
    setInput(`${buildSkillInvocationDisplay(detail.skill.slug)} `);
    setCommandPopupIndex(0);
    inputRef?.focus();
    const len = inputRef?.value.length ?? 0;
    inputRef?.setSelectionRange(len, len);
  };

  const executeSlashCommand = (trimmed: string) => {
    const parsed = parseCommand(trimmed, "chat");
    if (!parsed) return false;

    const ctx: CommandContext = {
      rawInput: trimmed,
      args: parsed.args,
      panel: "chat",
      clearInput: () => setInput(""),
      openPanel: (panel: string) => {
        window.dispatchEvent(
          new CustomEvent("seren:open-panel", { detail: panel }),
        );
      },
      showStatus: (message: string) => {
        setCommandStatus(message);
        setTimeout(() => setCommandStatus(null), 4000);
      },
    };

    parsed.command.execute(ctx);
    setCommandPopupIndex(0);
    return true;
  };

  const cancelStreaming = () => {
    const id = conversationId();
    if (id) {
      cancelOrchestration(id);
    }
    setMessageQueue([]);
  };

  const sendMessage = async (opts?: {
    skipAuthGate?: boolean;
    command?: string;
  }) => {
    const trimmed = input().trim();
    const images = attachedImages();
    if (!trimmed && images.length === 0) return;

    // Record the prompt in the persisted input-history buffer so up-arrow
    // recall survives thread switches, compaction, and app restarts.
    const convId = conversationId();
    if (trimmed && convId) {
      setPersistedInputs((prev) => {
        const next = [...prev, trimmed];
        return next.length > 200 ? next.slice(-200) : next;
      });
      void appendInputHistory(convId, trimmed).catch((err) => {
        console.warn("[ChatContent] Failed to persist input history:", err);
      });
    }

    // Check for slash commands first
    if (trimmed.startsWith("/") && images.length === 0) {
      if (executeSlashCommand(trimmed)) return;

      // Check if the slash command matches an installed skill
      const skillMatch = await resolveSkillCommand(trimmed);
      if (skillMatch) {
        const { skill, args } = skillMatch;
        console.log(
          "[ChatContent] Skill invocation:",
          skill.slug,
          "args:",
          args,
        );

        setInput("");
        setHistoryIndex(-1);
        setSavedInput("");

        let skillContent: string | null = null;
        try {
          skillContent = await skills.readContent(skill);
        } catch (err) {
          console.warn("[ChatContent] Failed to load skill content:", err);
        }

        const directive = buildSkillInvocationDirective({
          slug: skill.slug,
          content: skillContent,
          args,
        });
        const displayText = buildSkillInvocationDisplay(skill.slug, args);
        await sendMessageImmediate(directive, undefined, displayText);
        return;
      }
    }

    // If not authenticated, prompt sign-in — every provider eventually depends
    // on Seren auth for auto-compaction (#1641), so scoping this to Seren
    // providers hid the real failure as a "Prompt is too long" dead-end on
    // Claude Code and other agents (#1652).
    if (!authStore.isAuthenticated) {
      if (!opts?.skipAuthGate) {
        setShowSignInPrompt(true);
      }
      return;
    }

    const provider = activeThreadProvider();
    if (
      provider === "seren" &&
      !allowsSerenPublicModels(authStore.privateChatPolicy)
    ) {
      conversationStore.setError(
        "Your organization requires private model routing for Seren chat.",
      );
      return;
    }

    if (
      provider === "seren-private" &&
      !allowsSerenPrivateAgent(authStore.privateChatPolicy)
    ) {
      conversationStore.setError(
        "Your organization has disabled Seren Agent (Private).",
      );
      return;
    }

    // If currently streaming, queue the message instead
    if (conversationIsLoading()) {
      setMessageQueue((queue) => [...queue, trimmed]);
      setInput("");
      setHistoryIndex(-1);
      setSavedInput("");
      console.log("[ChatContent] Message queued:", trimmed);
      return;
    }

    // Send message immediately
    await sendMessageImmediate(trimmed, images.length > 0 ? images : undefined);
    setAttachedImages([]);
  };

  const sendMessageImmediate = async (
    messageContent: string,
    images?: Attachment[],
    displayContent?: string,
  ) => {
    const id = conversationId();
    if (!id) return;

    // Defensive auth gate: matches sendMessage()'s gate but also covers the
    // skill-invocation branch and queue-drain path that bypass it. Without
    // this, a session-expired state can still start an orchestrator turn
    // with no access token and no publishers, producing silent "no file
    // found" failures while the user believes the turn is running normally.
    // Not provider-scoped: compaction depends on Seren auth regardless of
    // which provider is active (#1652).
    if (!authStore.isAuthenticated) {
      setShowSignInPrompt(true);
      return;
    }

    // Set loading synchronously before any await. orchestrate() also sets
    // this, but only after we yield on persistMessage — a window wide
    // enough for a fast user submission to slip past `conversationIsLoading()`
    // in sendMessage() and start a parallel dispatch concurrent with the
    // in-flight or about-to-fire drain (#1997).
    conversationStore.setLoading(true, id);

    const userMessage: UnifiedMessage = {
      id: crypto.randomUUID(),
      type: "user",
      role: "user",
      content: displayContent ?? messageContent,
      images,
      timestamp: Date.now(),
      modelId: chatStore.selectedModel,
      status: "complete",
    };

    conversationStore.addMessage(userMessage, id);
    await conversationStore.persistMessage(userMessage, id);

    conversationStore.setError(null);
    setInput("");
    setHistoryIndex(-1);
    setSavedInput("");

    // Enrich prompt with editor context if available
    const context = buildContext();
    let prompt = messageContent;
    if (context) {
      const fileLabel = context.file ?? "selection";
      const rangeLabel = context.range
        ? ` (lines ${context.range.startLine}-${context.range.endLine})`
        : "";
      prompt = `Context from ${fileLabel}${rangeLabel}:\n\`\`\`\n${context.content}\n\`\`\`\n\n${messageContent}`;
    }

    await orchestrate(id, prompt, images);

    // Check if auto-compact should be triggered
    await chatStore.checkAutoCompact(
      settingsStore.get("autoCompactEnabled"),
      settingsStore.get("autoCompactThreshold"),
      settingsStore.get("autoCompactPreserveMessages"),
    );

    // Drain the next queued message strictly serially. We await the
    // recursive call (instead of fire-and-forget setTimeout) so each next
    // message can only start after the current orchestrate fully completes —
    // no overlapping turns, no loading=false gap between drains, and no
    // chance for a fast user submission to slip past `conversationIsLoading()`
    // and start a parallel dispatch (#1997).
    //
    // Capture the conversation ID so the drain targets the same conversation
    // even if the user switched threads during orchestration.
    const drainConversationId = id;
    const queue = messageQueue();
    if (queue.length > 0) {
      const [nextMessage, ...remainingQueue] = queue;
      setMessageQueue(remainingQueue);
      console.log("[ChatContent] Processing queued message:", nextMessage);
      if (conversationId() === drainConversationId) {
        await sendMessageImmediate(nextMessage);
      } else {
        console.warn(
          "[ChatContent] Skipping queued message — conversation changed",
        );
      }
    }
  };

  const attemptRetry = async (message: UnifiedMessage, isManual: boolean) => {
    if (!message.request) return;

    chatStore.setRetrying(message.id);
    const id = conversationId();
    if (!id) return;

    conversationStore.updateMessage(
      message.id,
      {
        status: "pending",
        attemptCount: message.attemptCount ?? 1,
      },
      id,
    );

    try {
      // Include conversation history up to (but not including) the failed message
      // so the AI can continue from where it left off instead of starting over
      const messages = conversationMessages();
      const messageIndex = messages.findIndex((m) => m.id === message.id);
      const historyBeforeRetry =
        messageIndex > 0 ? messages.slice(0, messageIndex) : undefined;

      const activeConvo = conversationStore.conversations.find(
        (c) => c.id === id,
      );
      const retryProvider =
        (activeConvo?.selectedProvider as ProviderId | undefined) ??
        providerStore.activeProvider;
      const content = await sendMessageWithRetry(
        message.request.prompt,
        message.modelId ?? chatStore.selectedModel,
        retryProvider,
        message.request.context,
        (attempt) => {
          conversationStore.updateMessage(
            message.id,
            {
              status: "pending",
              attemptCount: attempt + 1,
            },
            id,
          );
        },
        historyBeforeRetry,
      );

      const updated = {
        ...message,
        content,
        status: "complete" as const,
        error: null,
        timestamp: Date.now(),
      };

      conversationStore.updateMessage(message.id, updated, id);
      await conversationStore.persistMessage(updated, id);
    } catch (error) {
      const messageError = (error as Error).message;
      conversationStore.updateMessage(
        message.id,
        {
          status: "error",
          error: messageError,
        },
        id,
      );
      if (isManual) {
        conversationStore.setError(messageError);
      }
    } finally {
      chatStore.setRetrying(null);
    }
  };

  const handleManualRetry = async (message: UnifiedMessage) => {
    await attemptRetry(message, true);
  };

  const clearHistory = async () => {
    const confirmClear = await confirm("Clear all chat history?", {
      title: "Clear Chat",
      kind: "warning",
    });
    if (!confirmClear) return;
    const id = conversationId();
    if (!id) return;
    await conversationStore.clearHistory(id);
  };

  const copyAllChatHistory = async () => {
    const messages = conversationMessages();
    if (!hasExportableMessages(messages)) {
      alert("No chat history to copy");
      return;
    }

    const markdown = formatChatHistoryMarkdown(messages);

    try {
      await navigator.clipboard.writeText(markdown);
      alert("Chat history copied to clipboard!");
    } catch (error) {
      console.error("Failed to copy:", error);
      alert("Failed to copy chat history");
    }
  };

  const [isSaving, setIsSaving] = createSignal(false);

  const downloadChatHistory = async () => {
    if (isSaving()) return;
    const messages = conversationMessages();
    if (!hasExportableMessages(messages)) return;

    const now = new Date();
    const dateStr = now.toISOString().split("T")[0];
    const title = `Chat History - ${dateStr}`;

    const markdown = formatChatHistoryMarkdown(messages, { exportedAt: now });

    setIsSaving(true);
    try {
      await saveToSerenNotes(title, markdown);
    } catch (error) {
      console.error("[ChatContent] Failed to save to Seren Notes:", error);
      alert("Failed to save to Seren Notes. Are you logged in?");
    } finally {
      setIsSaving(false);
    }
  };

  const handleSkillChipClick = (event: MouseEvent) => {
    const target = event.target as HTMLElement | null;
    const chip = target?.closest<HTMLElement>("[data-skill-slug]");
    if (!chip) return;
    event.preventDefault();
    const slug = chip.dataset.skillSlug ?? "";
    if (!slug) return;
    const args = chip.dataset.skillArgs ?? "";
    setInput(buildSkillInvocationDisplay(slug, args || undefined));
    inputRef?.focus();
    const len = inputRef?.value.length ?? 0;
    inputRef?.setSelectionRange(len, len);
  };

  return (
    <section
      class="chat-surface relative flex flex-col h-full bg-background text-foreground"
      onClick={handleSkillChipClick}
    >
      <Show when={isDragging() && isPaneActive()}>
        <div class="absolute inset-0 bg-primary/10 border-2 border-dashed border-primary/50 rounded-sm z-50 pointer-events-none flex items-center justify-center">
          <span class="text-primary text-sm font-medium bg-background/90 px-3 py-1.5 rounded-md shadow-sm">
            Drop files to attach
          </span>
        </div>
      </Show>
      <Show when={showSignInPrompt()}>
        <div class="flex-1 flex flex-col items-center justify-center gap-6 p-6">
          <div class="text-center max-w-[280px]">
            <h2 class="m-0 mb-2 text-lg font-semibold text-foreground">
              Sign in to use Seren
            </h2>
            <p class="m-0 text-sm text-muted-foreground leading-normal">
              Sign in to chat with Seren, or add your own API key in Settings.
            </p>
          </div>
          <SignIn
            onSuccess={async () => {
              await checkAuth();
              setShowSignInPrompt(false);
            }}
          />
          <button
            type="button"
            class="bg-transparent border border-border text-muted-foreground px-3 py-1.5 rounded text-xs cursor-pointer transition-colors hover:bg-surface-2 hover:text-foreground"
            onClick={() => setShowSignInPrompt(false)}
          >
            Cancel
          </button>
        </div>
      </Show>
      <Show when={!showSignInPrompt()}>
        <header class="shrink-0 flex justify-between items-center px-3 py-2 border-b border-surface-2 bg-surface-1">
          <div class="flex items-center gap-3">
            <Show when={chatStore.messages.length > 0}>
              <div class="flex items-center gap-1.5 text-[10px] text-muted-foreground">
                <div
                  class="w-12 h-1.5 bg-surface-2 rounded-full overflow-hidden"
                  title={`Context: ${chatStore.contextUsagePercent}% (${chatStore.estimatedTokens.toLocaleString()} tokens)`}
                >
                  <div
                    class={`h-full rounded-full transition-all ${
                      chatStore.contextUsagePercent >= 80
                        ? "bg-destructive"
                        : chatStore.contextUsagePercent >= 50
                          ? "bg-warning"
                          : "bg-success"
                    }`}
                    style={{ width: `${chatStore.contextUsagePercent}%` }}
                  />
                </div>
                <span>{chatStore.contextUsagePercent}%</span>
              </div>
            </Show>
          </div>
          <div class="flex items-center gap-2">
            <Show
              when={
                chatStore.messages.length >
                settingsStore.get("autoCompactPreserveMessages")
              }
            >
              <button
                type="button"
                class="bg-transparent border border-border text-muted-foreground px-2 py-1 rounded text-xs cursor-pointer transition-all hover:bg-surface-2 hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
                onClick={() =>
                  chatStore.compactConversation(
                    settingsStore.get("autoCompactPreserveMessages"),
                  )
                }
                disabled={chatStore.isCompacting}
              >
                {chatStore.isCompacting ? "Compacting..." : "Compact"}
              </button>
            </Show>
            <Show when={conversationMessages().length > 0}>
              <button
                type="button"
                class="bg-transparent border border-border text-muted-foreground p-1.5 rounded text-xs cursor-pointer transition-all hover:bg-surface-2 hover:text-foreground"
                onClick={copyAllChatHistory}
                title="Copy all chat history"
              >
                <svg
                  class="w-3.5 h-3.5"
                  fill="none"
                  viewBox="0 0 24 24"
                  stroke="currentColor"
                  role="img"
                  aria-label="Copy"
                >
                  <title>Copy chat history</title>
                  <path
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    stroke-width="2"
                    d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
                  />
                </svg>
              </button>
              <button
                type="button"
                class="bg-transparent border border-border text-muted-foreground p-1.5 rounded text-xs cursor-pointer transition-all hover:bg-surface-2 hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
                onClick={downloadChatHistory}
                disabled={isSaving()}
                title="Download chat history"
              >
                <svg
                  class="w-3.5 h-3.5"
                  fill="none"
                  viewBox="0 0 24 24"
                  stroke="currentColor"
                  role="img"
                  aria-label="Download"
                >
                  <title>Download chat history</title>
                  <path
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    stroke-width="2"
                    d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
                  />
                </svg>
              </button>
            </Show>
            <button
              type="button"
              class="bg-transparent border border-border text-muted-foreground px-2 py-1 rounded text-xs cursor-pointer transition-all hover:bg-surface-2 hover:text-foreground"
              onClick={clearHistory}
            >
              Clear
            </button>
          </div>
        </header>

        <div
          class="flex-1 min-h-0 overflow-y-auto pb-4 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-surface-2 [&::-webkit-scrollbar-thumb]:rounded"
          ref={messagesRef}
        >
          <Show when={chatStore.compactedSummary}>
            {(summary) => (
              <CompactedMessage
                summary={summary()}
                onClear={() => chatStore.clearCompactedSummary()}
              />
            )}
          </Show>

          <Show
            when={
              conversationMessages().length > 0 || chatStore.compactedSummary
            }
            fallback={
              <div class="flex-1 flex flex-col items-center justify-center p-10 text-muted-foreground">
                <h3 class="m-0 mb-3 text-lg font-medium text-foreground">
                  Welcome to Seren
                </h3>
                <p class="m-0 text-sm text-center max-w-[320px] leading-relaxed">
                  Your AI Agent to create personal skills from your everyday
                  work. Try typing:
                </p>
                <p class="m-0 mt-2 text-sm text-center max-w-[320px] leading-relaxed italic text-foreground">
                  "Create the skill to identify new business leads and introduce
                  our company using Seren's Publishers"
                </p>
              </div>
            }
          >
            <For each={groupConsecutiveToolCalls(conversationMessages())}>
              {(item) => {
                // Render grouped tool calls
                if (item.type === "tool_group") {
                  return (
                    <ToolCallGroup
                      groupId={item.id}
                      toolCalls={item.toolCalls}
                      isComplete={true}
                    />
                  );
                }

                // Render single message
                const message = item.message;

                // Tool results are already embedded in the tool_call message
                if (message.type === "tool_result") return null;

                // Render individual tool call card (for 1-2 tool calls)
                if (message.type === "tool_call" && message.toolCall) {
                  return (
                    <div class="chat-tool-row px-5 py-2">
                      <ToolCallCard
                        toolCall={toToolCallEvent(message.toolCall)}
                      />
                    </div>
                  );
                }

                const boundary = providerBoundaries().get(message.id);
                return (
                  <Show
                    when={
                      message.type !== "transition" &&
                      message.type !== "reroute"
                    }
                    fallback={
                      message.type === "reroute" ? (
                        <RerouteAnnouncement message={message} />
                      ) : (
                        <TransitionAnnouncement message={message} />
                      )
                    }
                  >
                    <Show when={boundary}>
                      {(b) => (
                        <div
                          class="flex items-center gap-2 px-5 py-2 text-[11px] text-muted-foreground border-b border-surface-2 bg-surface-1/50"
                          data-testid="provider-boundary"
                          data-from-provider={b().fromProvider ?? ""}
                          data-to-provider={b().toProvider ?? ""}
                          aria-label={`Switched to ${providerDisplayName(
                            b().toProvider,
                          )}${b().toModel ? ` · ${b().toModel}` : ""}`}
                        >
                          <span
                            class="inline-block h-px flex-1 bg-border-strong/40"
                            aria-hidden="true"
                          />
                          <span class="font-medium">
                            Switched to {providerDisplayName(b().toProvider)}
                            <Show when={b().toModel}>
                              <span class="text-muted-foreground/70">
                                {" · "}
                                {b().toModel}
                              </span>
                            </Show>
                          </span>
                          <span
                            class="inline-block h-px flex-1 bg-border-strong/40"
                            aria-hidden="true"
                          />
                        </div>
                      )}
                    </Show>
                    <article
                      class={`chat-message-row group/msg px-5 py-4 border-b border-surface-2 last:border-b-0 [contain:layout] ${message.role === "user" ? "bg-surface-1" : "bg-transparent"}`}
                    >
                      <Show when={message.images && message.images.length > 0}>
                        <MessageImages images={message.images ?? []} />
                      </Show>
                      <div
                        class="chat-message-content leading-[1.7] text-foreground break-words [&_p]:m-0 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_h1]:text-[1.3em] [&_h1]:font-semibold [&_h1]:mt-5 [&_h1]:mb-3 [&_h1]:text-foreground [&_h1]:border-b [&_h1]:border-surface-2 [&_h1]:pb-2 [&_h2]:text-[1.15em] [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-foreground [&_h3]:text-[1.05em] [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2 [&_h3]:text-foreground [&_code]:bg-surface-1 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:font-mono [&_code]:text-[0.92em] [&_pre]:bg-surface-1 [&_pre]:border [&_pre]:border-border [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-[0.92em] [&_ul]:my-2 [&_ul]:pl-6 [&_ol]:my-2 [&_ol]:pl-6 [&_li]:my-1 [&_li]:leading-[1.6] [&_blockquote]:border-l-[3px] [&_blockquote]:border-border [&_blockquote]:my-3 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_a]:text-primary [&_a]:no-underline [&_a:hover]:underline [&_strong]:text-foreground [&_strong]:font-semibold"
                        innerHTML={
                          message.role === "assistant"
                            ? (htmlCache[message.id] ?? "")
                            : escapeHtmlWithSkillsAndLinks(
                                message.content,
                                installedSkillSlugs(),
                              )
                        }
                      />
                      <Show
                        when={
                          message.role === "assistant" &&
                          message.status === "complete" &&
                          message.duration
                        }
                      >
                        {(() => {
                          const dur = message.duration;
                          if (!dur) return null;
                          const { verb, duration, costDisplay } =
                            formatDurationWithVerb(dur, message.cost);
                          return (
                            <div class="mt-2 text-xs text-muted-foreground">
                              ✻ {verb} for {duration}
                              {costDisplay && ` at ${costDisplay}`}
                            </div>
                          );
                        })()}
                      </Show>
                      <Show
                        when={
                          message.role === "assistant" &&
                          message.status === "complete" &&
                          ((message.memory?.used ?? []).length > 0 ||
                            ((message.memory?.captured ?? []).length > 0 &&
                              message.memory?.captureStatus !== "undone"))
                        }
                      >
                        <div class="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
                          <Show when={(message.memory?.used ?? []).length > 0}>
                            <button
                              type="button"
                              class="bg-card border border-border text-muted-foreground px-2 py-0.5 rounded cursor-pointer hover:bg-surface-2 hover:text-foreground"
                              onClick={() => {
                                setMemoryPanelMessageId(message.id);
                                setCorrectionMessageId(null);
                                setMemoryActionStatus(
                                  message.memory?.notice ?? null,
                                );
                              }}
                            >
                              Used {(message.memory?.used ?? []).length}{" "}
                              remembered detail
                              {(message.memory?.used ?? []).length === 1
                                ? ""
                                : "s"}
                            </button>
                            <button
                              type="button"
                              class="bg-transparent border border-border text-muted-foreground px-2 py-0.5 rounded cursor-pointer hover:bg-surface-2 hover:text-foreground"
                              onClick={() => {
                                setMemoryPanelMessageId(message.id);
                                setCorrectionMessageId(null);
                                setMemoryActionStatus(
                                  message.memory?.notice ?? null,
                                );
                              }}
                            >
                              Why?
                            </button>
                            <button
                              type="button"
                              class="bg-transparent border border-border text-muted-foreground px-2 py-0.5 rounded cursor-pointer hover:bg-surface-2 hover:text-foreground"
                              onClick={() => beginMemoryCorrection(message)}
                            >
                              Something is wrong
                            </button>
                          </Show>
                          <Show
                            when={
                              (message.memory?.captured ?? []).length > 0 &&
                              message.memory?.captureStatus !== "undone"
                            }
                          >
                            <span class="px-2 py-0.5 border border-border rounded bg-transparent">
                              Remembered for this project
                            </span>
                            <button
                              type="button"
                              class="bg-transparent border border-border text-muted-foreground px-2 py-0.5 rounded cursor-pointer hover:bg-surface-2 hover:text-foreground disabled:opacity-50"
                              onClick={() => handleUndoRemember(message)}
                              disabled={memoryActionBusy()}
                            >
                              Undo remember
                            </button>
                          </Show>
                        </div>
                      </Show>
                      <Show when={(message.rlmSteps ?? []).length > 0}>
                        {(_) => (
                          <RLMStepsBlock steps={message.rlmSteps ?? []} />
                        )}
                      </Show>
                      <Show
                        when={
                          message.role === "assistant" &&
                          message.status === "complete"
                        }
                      >
                        <div class="mt-1.5 flex justify-end">
                          <SatisfactionSignal messageId={message.id} />
                        </div>
                      </Show>
                      <Show when={message.status === "error"}>
                        <Show
                          when={!isPaymentError(message.error)}
                          fallback={
                            <div class="mt-2 px-2 py-1.5 bg-warning/10 border border-warning/30 rounded flex items-center gap-2 text-xs text-foreground">
                              <span>Insufficient SerenBucks balance.</span>
                              <button
                                type="button"
                                class="bg-transparent border border-warning/40 text-warning px-2 py-0.5 rounded text-xs cursor-pointer hover:bg-warning/15 font-medium"
                                onClick={() => setShowDepositFromError(true)}
                              >
                                Top Up
                              </button>
                              <Show when={message.request}>
                                <button
                                  type="button"
                                  class="bg-transparent border border-warning/40 text-warning px-2 py-0.5 rounded text-xs cursor-pointer hover:bg-warning/15"
                                  onClick={() => handleManualRetry(message)}
                                >
                                  Retry
                                </button>
                              </Show>
                              <Show
                                when={
                                  !message.request &&
                                  isRetryableWorker(message.workerType)
                                }
                              >
                                <button
                                  type="button"
                                  class="bg-transparent border border-warning/40 text-warning px-2 py-0.5 rounded text-xs cursor-pointer hover:bg-warning/15"
                                  onClick={() => retryOrchestration()}
                                >
                                  Retry
                                </button>
                              </Show>
                            </div>
                          }
                        >
                          <div class="mt-2 px-2 py-1.5 bg-destructive/10 border border-destructive/40 rounded flex items-center gap-2 text-xs text-destructive">
                            <Show
                              when={
                                !isAuthError(message.error) &&
                                !(
                                  isContextOverflowError(message.error) &&
                                  !authStore.isAuthenticated
                                )
                              }
                              fallback={
                                <>
                                  <span>
                                    Session expired. Please sign in to continue.
                                  </span>
                                  <button
                                    type="button"
                                    class="bg-transparent border border-destructive/40 text-destructive px-2 py-0.5 rounded text-xs cursor-pointer hover:bg-destructive/15"
                                    onClick={() => setShowSignInPrompt(true)}
                                  >
                                    Sign In
                                  </button>
                                </>
                              }
                            >
                              <span>{message.error ?? "Message failed"}</span>
                              <Show
                                when={
                                  chatStore.retryingMessageId === message.id
                                }
                              >
                                <span>
                                  Retrying (
                                  {Math.min(
                                    message.attemptCount ?? 1,
                                    CHAT_MAX_RETRIES,
                                  )}
                                  /{CHAT_MAX_RETRIES})…
                                </span>
                              </Show>
                              <Show when={message.request}>
                                <button
                                  type="button"
                                  class="bg-transparent border border-destructive/40 text-destructive px-2 py-0.5 rounded text-xs cursor-pointer hover:bg-destructive/15"
                                  onClick={() => handleManualRetry(message)}
                                >
                                  Retry
                                </button>
                              </Show>
                              <Show
                                when={
                                  !message.request &&
                                  isRetryableWorker(message.workerType)
                                }
                              >
                                <button
                                  type="button"
                                  class="bg-transparent border border-destructive/40 text-destructive px-2 py-0.5 rounded text-xs cursor-pointer hover:bg-destructive/15"
                                  onClick={() => retryOrchestration()}
                                >
                                  Retry
                                </button>
                              </Show>
                            </Show>
                          </div>
                        </Show>
                      </Show>
                    </article>
                  </Show>
                );
              }}
            </For>
          </Show>

          <Show when={conversationIsLoading() && !streamingContent()}>
            <article class="chat-message-row px-5 py-4 border-b border-surface-2">
              <Show
                when={conversationIsRLMProcessing()}
                fallback={<ThinkingStatus />}
              >
                <span class="inline-flex items-center gap-2 text-[0.93em] text-foreground">
                  <span class="inline-flex items-center gap-[3px]">
                    <span class="inline-block w-[6px] h-[6px] rounded-full bg-primary thinking-dot thinking-dot-1" />
                    <span class="inline-block w-[6px] h-[6px] rounded-full bg-primary thinking-dot thinking-dot-2" />
                    <span class="inline-block w-[6px] h-[6px] rounded-full bg-primary thinking-dot thinking-dot-3" />
                  </span>
                  <span>Processing large input…</span>
                </span>
              </Show>
            </article>
          </Show>

          <Show when={streamingThinking()}>
            <article class="chat-message-row px-5 py-4 border-b border-surface-2">
              <details open class="text-[0.8em] text-muted-foreground">
                <summary class="cursor-pointer select-none mb-1">
                  Thinking…
                </summary>
                <div class="whitespace-pre-wrap opacity-70">
                  {streamingThinking()}
                </div>
              </details>
            </article>
          </Show>

          <Show when={streamingContent()}>
            <article class="chat-message-row px-5 py-4 border-b border-surface-2">
              <div class="chat-message-content leading-[1.7] text-foreground break-words whitespace-pre-wrap">
                {streamingContent()}
                <Show when={conversationIsLoading()}>
                  <span class="inline-block w-[6px] h-[14px] bg-primary ml-0.5 animate-pulse" />
                </Show>
              </div>
              <Show when={streamingStalled()}>
                <div class="mt-2 text-[11.5px] text-muted-foreground italic">
                  Still working - the runtime hasn't sent a token in a while.
                </div>
              </Show>
            </article>
          </Show>
        </div>

        <Show when={contextPreview()}>
          {(ctx) => (
            <div class="mx-3 my-2 bg-surface-1 border border-border rounded-lg overflow-hidden">
              <div class="flex justify-between items-center px-2 py-1.5 bg-surface-2 text-xs text-muted-foreground">
                <span>
                  Context from {ctx().file ?? "selection"}
                  {ctx().range &&
                    ` (${ctx().range?.startLine}-${ctx().range?.endLine})`}
                </span>
                <button
                  type="button"
                  class="bg-transparent border-none text-muted-foreground cursor-pointer px-1 py-0.5 text-sm leading-none hover:text-foreground"
                  onClick={() => editorStore.clearSelection()}
                >
                  ×
                </button>
              </div>
              <pre class="m-0 p-2 max-h-[80px] overflow-y-auto text-xs leading-normal bg-transparent">
                {ctx().text}
              </pre>
            </div>
          )}
        </Show>

        <div class="shrink-0 border-t border-surface-2 bg-surface-1">
          <form
            class="chat-composer-form flex flex-col gap-2 px-4 pb-3.5 pt-1.5"
            onSubmit={(event) => {
              event.preventDefault();
              sendMessage();
            }}
          >
            <PublisherSuggestions
              suggestions={suggestions()}
              isLoading={suggestionsLoading()}
              onSelect={handlePublisherSelect}
              onDismiss={dismissSuggestions}
            />
            <ImageAttachmentBar
              images={attachedImages()}
              onAttach={handleAttachImages}
              onRemove={handleRemoveImage}
              isLoading={isAttaching()}
            />
            <div class="relative">
              <SlashCommandPopup
                input={input()}
                panel="chat"
                selectedIndex={commandPopupIndex()}
                onSelect={(cmd) => {
                  if (cmd.argHint) {
                    setInput(`/${cmd.name} `);
                    inputRef?.focus();
                  } else {
                    executeSlashCommand(`/${cmd.name}`);
                  }
                  setCommandPopupIndex(0);
                }}
              />
              <ResizableTextarea
                workspaceDefaultFocus={true}
                ref={(el) => {
                  inputRef = el;
                  console.log(
                    "[ChatContent] Textarea ref set, disabled:",
                    el.disabled,
                    "isLoading:",
                    conversationIsLoading(),
                  );
                }}
                value={input()}
                placeholder={
                  conversationIsLoading()
                    ? "Type to queue message..."
                    : "Ask Seren anything... (type / for commands)"
                }
                class="w-full bg-background border border-border rounded-xl text-foreground px-3.5 py-3 font-inherit text-[14px] leading-normal transition-all duration-150 focus:outline-none focus:border-primary/60 focus:shadow-[var(--input-focus-glow)] placeholder:text-muted-foreground/60"
                minHeight={72}
                maxHeight={window.innerHeight * 0.5}
                onInput={(event) => {
                  setInput(event.currentTarget.value);
                  setCommandPopupIndex(0);
                  if (historyIndex() !== -1) {
                    setHistoryIndex(-1);
                  }
                }}
                onDragOver={handleSkillDragOver}
                onDrop={(event) => void handleSkillDrop(event)}
                onKeyDown={(event) => {
                  // Slash command popup keyboard navigation
                  const isSlashInput =
                    input().startsWith("/") && !input().includes(" ");
                  if (isSlashInput) {
                    const matches = getCompletions(input(), "chat");
                    if (matches.length > 0) {
                      if (event.key === "ArrowUp") {
                        event.preventDefault();
                        setCommandPopupIndex((i) =>
                          i > 0 ? i - 1 : matches.length - 1,
                        );
                        return;
                      }
                      if (event.key === "ArrowDown") {
                        event.preventDefault();
                        setCommandPopupIndex((i) =>
                          i < matches.length - 1 ? i + 1 : 0,
                        );
                        return;
                      }
                      if (
                        event.key === "Tab" ||
                        (event.key === "Enter" && !event.shiftKey)
                      ) {
                        event.preventDefault();
                        const selected = matches[commandPopupIndex()];
                        if (selected) {
                          if (event.key === "Tab" || selected.argHint) {
                            setInput(`/${selected.name} `);
                          } else {
                            executeSlashCommand(`/${selected.name}`);
                          }
                          setCommandPopupIndex(0);
                        }
                        return;
                      }
                      if (event.key === "Escape") {
                        event.preventDefault();
                        setInput("");
                        return;
                      }
                    }
                  }

                  const history = userMessageHistory();

                  // Up arrow: navigate to older message
                  if (event.key === "ArrowUp" && history.length > 0) {
                    const textarea = event.currentTarget;
                    if (textarea.selectionStart === 0 || input() === "") {
                      event.preventDefault();

                      if (historyIndex() === -1) {
                        setSavedInput(input());
                      }

                      const newIndex = Math.min(
                        historyIndex() + 1,
                        history.length - 1,
                      );
                      setHistoryIndex(newIndex);
                      setInput(history[newIndex]);
                      queueMicrotask(() => {
                        textarea.setSelectionRange(
                          textarea.value.length,
                          textarea.value.length,
                        );
                      });
                    }
                  }

                  // Down arrow: navigate to newer message
                  if (event.key === "ArrowDown" && historyIndex() >= 0) {
                    const textarea = event.currentTarget;
                    if (textarea.selectionStart === textarea.value.length) {
                      event.preventDefault();

                      const newIndex = historyIndex() - 1;
                      setHistoryIndex(newIndex);

                      if (newIndex < 0) {
                        setInput(savedInput());
                        setSavedInput("");
                      } else {
                        setInput(history[newIndex]);
                      }
                      queueMicrotask(() => {
                        textarea.setSelectionRange(
                          textarea.value.length,
                          textarea.value.length,
                        );
                      });
                    }
                  }

                  // Enter key handling
                  if (event.key === "Enter") {
                    const enterToSend = settingsStore.get("chatEnterToSend");
                    if (enterToSend) {
                      if (!event.shiftKey) {
                        event.preventDefault();
                        sendMessage();
                      }
                    } else {
                      if (event.metaKey || event.ctrlKey) {
                        event.preventDefault();
                        sendMessage();
                      }
                    }
                  }
                }}
              />
            </div>
            <Show when={commandStatus()}>
              <div class="px-3 py-2 bg-surface-2 border border-border rounded-lg text-xs text-muted-foreground whitespace-pre-wrap">
                {commandStatus()}
              </div>
            </Show>
            <div class={COMPOSER_TOOLBAR_ROOT_CLASSES}>
              <div class={COMPOSER_TOOLBAR_LEFT_GROUP_CLASSES}>
                <Show when={!privateChatLocked()}>
                  <ModelSelector threadId={conversationId()} />
                  <ToolsetSelector />
                  <ReasoningEffortSelector />
                  <SkillsButton
                    recentSkill={recentSkill()}
                    onLaunch={() => {
                      setInput("/");
                      setCommandPopupIndex(0);
                      inputRef?.focus();
                      const len = inputRef?.value.length ?? 0;
                      inputRef?.setSelectionRange(len, len);
                    }}
                    onRecall={(skill) => {
                      // Same shape as the transcript chip click: fill the
                      // composer with `/slug ` and let the user add args or
                      // hit Enter to send. No auto-submit.
                      setInput(`${buildSkillInvocationDisplay(skill.slug)} `);
                      setCommandPopupIndex(0);
                      inputRef?.focus();
                      const len = inputRef?.value.length ?? 0;
                      inputRef?.setSelectionRange(len, len);
                    }}
                  />
                </Show>
                <Show when={messageQueue().length > 0}>
                  <span class="flex items-center gap-2 px-2 py-1 bg-surface-2 border border-border rounded text-xs text-muted-foreground">
                    {messageQueue().length} message
                    {messageQueue().length > 1 ? "s" : ""} queued
                    <button
                      type="button"
                      class="text-destructive hover:underline"
                      onClick={() => setMessageQueue([])}
                    >
                      Clear
                    </button>
                  </span>
                </Show>
              </div>
              <div class={COMPOSER_TOOLBAR_RIGHT_GROUP_CLASSES}>
                <VoiceInputButton
                  onTranscript={(text) => {
                    setInput((prev) => (prev ? `${prev} ${text}` : text));
                    if (settingsStore.get("voiceAutoSubmit")) {
                      sendMessage();
                    } else {
                      inputRef?.focus();
                    }
                  }}
                />
                <Show
                  when={conversationIsLoading()}
                  fallback={
                    <button
                      type="submit"
                      class="bg-success text-white border-none px-4 py-1.5 rounded-lg text-[13px] font-medium cursor-pointer transition-all hover:bg-emerald-700 hover:shadow-[0_2px_8px_var(--success)] disabled:bg-surface-2 disabled:text-muted-foreground disabled:shadow-none"
                      disabled={
                        !input().trim() && attachedImages().length === 0
                      }
                    >
                      Send
                    </button>
                  }
                >
                  <button
                    type="button"
                    class="bg-destructive text-white border-none px-4 py-1.5 rounded-lg text-[13px] font-medium cursor-pointer transition-all hover:bg-red-500 hover:shadow-[0_2px_8px_var(--destructive)]"
                    onClick={cancelStreaming}
                  >
                    Stop
                  </button>
                </Show>
              </div>
            </div>
          </form>
        </div>
      </Show>
      <Show when={showDepositFromError()}>
        <DepositModal onClose={() => setShowDepositFromError(false)} />
      </Show>
      <SlidePanel
        open={memoryPanelMessage() !== undefined}
        onClose={() => {
          setMemoryPanelMessageId(null);
          setCorrectionMessageId(null);
          setCorrectionDraft("");
          setMemoryActionStatus(null);
        }}
      >
        <div class="px-5 pb-6 pt-12">
          <h2 class="m-0 text-base font-semibold text-foreground">
            Memory used in this answer
          </h2>
          <p class="mt-2 mb-4 text-[12px] leading-relaxed text-muted-foreground">
            These are the remembered details attached to this answer. This panel
            only covers the current response.
          </p>
          <Show when={memoryPanelMessage()}>
            {(panelMessage) => (
              <>
                <Show
                  when={(panelMessage().memory?.used ?? []).length > 0}
                  fallback={
                    <div class="rounded border border-border bg-card px-3 py-2 text-[12px] text-muted-foreground">
                      No remembered details are attached to this answer.
                    </div>
                  }
                >
                  <div class="space-y-2">
                    <For each={panelMessage().memory?.used ?? []}>
                      {(detail) => (
                        <div class="rounded border border-border bg-card px-3 py-2">
                          <div class="flex items-start justify-between gap-3">
                            <div class="min-w-0">
                              <div class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/70">
                                {detail.type}
                              </div>
                              <div class="mt-1 text-[13px] leading-relaxed text-foreground">
                                {detail.summary}
                              </div>
                              <div class="mt-1 flex flex-wrap gap-2 text-[11px] text-muted-foreground">
                                <Show when={detail.confidence !== undefined}>
                                  <span>
                                    confidence{" "}
                                    {Math.round((detail.confidence ?? 0) * 100)}
                                    %
                                  </span>
                                </Show>
                                <Show when={detail.recency}>
                                  <span>{detail.recency}</span>
                                </Show>
                                <Show when={detail.source}>
                                  <span>source {detail.source}</span>
                                </Show>
                              </div>
                            </div>
                          </div>
                          <div class="mt-2 flex flex-wrap gap-1.5">
                            <button
                              type="button"
                              class="bg-transparent border border-border text-muted-foreground px-2 py-1 rounded text-[11px] cursor-pointer hover:bg-surface-2 hover:text-foreground"
                              onClick={() => {
                                setCorrectionMessageId(panelMessage().id);
                                setCorrectionDraft("");
                              }}
                            >
                              This is wrong
                            </button>
                            <button
                              type="button"
                              class="bg-transparent border border-border text-muted-foreground px-2 py-1 rounded text-[11px] cursor-pointer hover:bg-surface-2 hover:text-foreground disabled:opacity-50"
                              onClick={() =>
                                handleSuppressMemoryDetail(
                                  panelMessage(),
                                  detail.id,
                                )
                              }
                              disabled={!detail.id || memoryActionBusy()}
                            >
                              Do not use here
                            </button>
                            <button
                              type="button"
                              class="bg-transparent border border-border text-muted-foreground px-2 py-1 rounded text-[11px] cursor-pointer hover:bg-surface-2 hover:text-foreground disabled:opacity-50"
                              onClick={() =>
                                handleForgetMemoryDetail(
                                  panelMessage(),
                                  detail.id,
                                )
                              }
                              disabled={!detail.id || memoryActionBusy()}
                            >
                              Forget this
                            </button>
                          </div>
                        </div>
                      )}
                    </For>
                  </div>
                </Show>

                <Show when={correctionMessageId() === panelMessage().id}>
                  <div class="mt-4 border-t border-border pt-4">
                    <label
                      for="memory-correction"
                      class="block mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/70"
                    >
                      Plain-language correction
                    </label>
                    <textarea
                      id="memory-correction"
                      class="w-full min-h-[92px] bg-card border border-border rounded px-3 py-2 text-[13px] leading-relaxed text-foreground resize-y focus:outline-none focus:border-primary"
                      value={correctionDraft()}
                      onInput={(e) => setCorrectionDraft(e.currentTarget.value)}
                      disabled={memoryActionBusy()}
                    />
                    <div class="mt-2 flex justify-end gap-2">
                      <button
                        type="button"
                        class="bg-transparent border border-border text-muted-foreground px-3 py-1.5 rounded text-[12px] cursor-pointer hover:bg-surface-2 hover:text-foreground"
                        onClick={() => {
                          setCorrectionMessageId(null);
                          setCorrectionDraft("");
                        }}
                        disabled={memoryActionBusy()}
                      >
                        Cancel
                      </button>
                      <button
                        type="button"
                        class="bg-primary text-primary-foreground border border-primary px-3 py-1.5 rounded text-[12px] cursor-pointer disabled:opacity-50"
                        onClick={() => submitMemoryCorrection(panelMessage())}
                        disabled={
                          memoryActionBusy() ||
                          correctionDraft().trim().length === 0
                        }
                      >
                        Save correction
                      </button>
                    </div>
                  </div>
                </Show>
                <Show
                  when={memoryActionStatus() ?? panelMessage().memory?.notice}
                >
                  {(status) => (
                    <div class="mt-4 rounded border border-border bg-surface-2 px-3 py-2 text-[12px] text-muted-foreground">
                      {status()}
                    </div>
                  )}
                </Show>
              </>
            )}
          </Show>
        </div>
      </SlidePanel>
    </section>
  );
};
