// /Users/giers/Heimgeist/src/App.jsx
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom';
import TextareaAutosize from 'react-textarea-autosize';
import GeneralSettings from './GeneralSettings'
import InterfaceSettings from './InterfaceSettings'
import LibraryManager from './LibraryManager'
import WebsearchSettings from './WebsearchSettings'
import { markdownToHTML  } from './markdown';
import { applyColorScheme } from './colorSchemes'
import {
  loadStoredWebsearchEngines,
  normalizeWebsearchEngines,
} from './websearchEngines'
import {
  getAudioInputConstraints,
  getPreferredAudioRecorderMimeType,
  stopMediaStream,
  supportsAudioInputCapture,
} from './audioInput'

function sanitizeChatTitle(title) {
  let cleanedTitle = String(title || '')
    .replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, '')
    .trim()

  let previousTitle = null
  while (cleanedTitle && cleanedTitle !== previousTitle) {
    previousTitle = cleanedTitle
    cleanedTitle = cleanedTitle
      .replace(/^\s*#+\s*/, '')
      .replace(/^\s*\*{1,2}\s*/, '')
      .replace(/\s*\*{1,2}\s*$/, '')
      .trim()
  }

  return cleanedTitle.replace(/\s+/g, ' ').trim()
}

function appendOllamaErrorHint(text, marker, block) {
  if (text.includes(marker)) {
    return text
  }
  return `${text}\n\n${block}`
}

function enrichOllamaErrorText(text) {
  const raw = String(text || '')
  const trimmed = raw.trim()
  if (!trimmed) {
    return raw
  }

  const lower = trimmed.toLowerCase()
  const looksLikeOllamaError = (
    lower.startsWith('error:') ||
    lower.startsWith('ollama error:') ||
    lower.includes("client error '") ||
    lower.includes("server error '") ||
    (lower.includes('http') && lower.includes('error')) ||
    lower.includes('unknown model architecture') ||
    lower.includes('out of memory')
  )

  if (!looksLikeOllamaError) {
    return raw
  }

  if (lower.includes('unknown model architecture')) {
    return appendOllamaErrorHint(
      raw,
      '[ERROR - Unsupported Model]',
      '[ERROR - Unsupported Model]\nThis Ollama version does not support the model.\nUpdate Ollama.'
    )
  }
  if (lower.includes('out of memory')) {
    return appendOllamaErrorHint(
      raw,
      '[ERROR - Out of Memory]',
      '[ERROR - Out of Memory]\nThe model is too large for available memory.\nUse a smaller or quantized model.'
    )
  }
  if (lower.includes('502')) {
    return appendOllamaErrorHint(
      raw,
      '[ERROR 502 - Bad Gateway]',
      '[ERROR 502 - Bad Gateway]\nOllama did not return a valid response.\nTry restarting or updating Ollama.'
    )
  }
  if (lower.includes('500')) {
    return appendOllamaErrorHint(
      raw,
      '[ERROR 500 - Internal Server Error]',
      "[ERROR 500 - Internal Server Error]\nOllama crashed while processing the request.\nCheck 'ollama logs' and memory usage."
    )
  }
  if (lower.includes('404')) {
    return appendOllamaErrorHint(
      raw,
      '[ERROR 404 - Not Found]',
      "[ERROR 404 - Not Found]\nThe model or endpoint was not found.\nCheck the model name or run 'ollama pull <model>'."
    )
  }
  if (lower.includes('400')) {
    return appendOllamaErrorHint(
      raw,
      '[ERROR 400 - Bad Request]',
      '[ERROR 400 - Bad Request]\nThe request sent to Ollama was invalid.\nCheck parameters or payload format.'
    )
  }
  return raw
}

// Extract <think> or <thinking> block (first occurrence) and return { think, answer }
function splitThinkBlocks(text) {
  if (!text) return { think: null, answer: '' };

  const openTagRe = /<think(?:ing)?>/i;
  const closeTagRe = /<\/think(?:ing)?>/i;

  const openMatch = text.match(openTagRe);

  if (!openMatch) {
    // No opening <think> tag found, so all content is answer
    return { think: null, answer: text };
  }

  const openTagIndex = openMatch.index;
  const openTagLength = openMatch[0].length;

  const answerPartBeforeThink = text.substring(0, openTagIndex).trim();
  let contentAfterOpenTag = text.substring(openTagIndex + openTagLength);

  const closeMatch = contentAfterOpenTag.match(closeTagRe);

  let thinkInner = null;
  let finalAnswer = answerPartBeforeThink;

  if (closeMatch) {
    // Both open and close tags are present
    thinkInner = contentAfterOpenTag.substring(0, closeMatch.index).trim();
    finalAnswer += contentAfterOpenTag.substring(closeMatch.index + closeMatch[0].length);
  } else {
    // Only open tag found (streaming case), take everything after it as think
    thinkInner = contentAfterOpenTag.trim();
  }

  return { think: thinkInner || null, answer: finalAnswer.trim() };
}

// Renders assistant message with a collapsible "Thoughts" block (if present)
function AssistantMessageContent({ content, streamOutput, sources }) {
  const displayContent = enrichOllamaErrorText(content || '');
  const { think, answer } = splitThinkBlocks(displayContent);
  const [open, setOpen] = React.useState(false);
  const showThinkButton = !!think;

  return (
    <div className="assistant-message">
      {showThinkButton && (
        <div className="assistant-thoughts">
          <button
            className="think-toggle"
            onClick={() => setOpen(o => !o)}
            aria-expanded={open ? 'true' : 'false'}
            aria-controls="think-content"
          >
            <span className="think-toggle-icon" aria-hidden="true">
              {open ? '▾' : '▸'}
            </span>
            Thoughts
          </button>
          {open && (
            <div
              id="think-content"
              className="think-content"
              dangerouslySetInnerHTML={{ __html: markdownToHTML(think) }}
            />
          )}
        </div>
      )}
      <div
        className="msg-content"
        dangerouslySetInnerHTML={{ __html: markdownToHTML(answer || displayContent || '') }}
      />
      {Array.isArray(sources) && sources.length > 0 && (
        <div className="msg-sources chips">
          {sources.map((u, i) => {
            let label = u;
            let isFile = false;
            try {
              const parsed = new URL(u);
              if (parsed.protocol === 'file:') {
                isFile = true;
                const parts = parsed.pathname.split('/').filter(Boolean);
                label = decodeURIComponent(parts[parts.length - 1] || u);
              } else {
                const host = parsed.hostname || u;
                label = host.replace(/^www\./i, '');
              }
            } catch {}
            return (
              <a
                key={u + i}
                className="chip"
                href={u}
                target="_blank"
                rel="noreferrer"
                title={u}
                onClick={(event) => {
                  if (!isFile) return;
                  event.preventDefault();
                  try {
                    const parsed = new URL(u);
                    window.electronAPI?.openPath?.(decodeURIComponent(parsed.pathname));
                  } catch {}
                }}
              >
                {label}
              </a>
            );
          })}
        </div>
      )}
    </div>
  );
}

function ImageAttachmentStrip({ attachments, className = '', removable = false, onRemove }) {
  if (!Array.isArray(attachments) || attachments.length === 0) {
    return null;
  }

  return (
    <div className={`image-attachment-strip ${className}`.trim()}>
      {attachments.map((attachment, index) => {
        const src = attachment?.data_url;
        if (!src) return null;

        const key = attachment.id || `${attachment.name || 'image'}-${index}-${src.length}`;
        return (
          <div key={key} className="image-attachment-card">
            {removable && (
              <button
                type="button"
                className="image-attachment-remove"
                onClick={() => onRemove?.(attachment.id)}
                aria-label={`Remove ${attachment.name || 'image'}`}
                title="Remove image"
              >
                ×
              </button>
            )}
            <img
              className="image-attachment-thumb"
              src={src}
              alt={attachment.name || `Attachment ${index + 1}`}
              loading="lazy"
            />
          </div>
        );
      })}
    </div>
  );
}

const API_URL_KEY = 'backendApiUrl';
const COLOR_SCHEME_KEY = 'colorScheme';
const WEBSEARCH_URL_KEY = 'websearch.searxUrl';
const WEBSEARCH_ENGINES_KEY = 'websearch.engines';
const CHAT_LIBRARY_MAP_KEY = 'chat.libraryBySession';
const DEFAULT_SEARX_URL = 'http://127.0.0.1:8888';
const MAX_IMAGE_ATTACHMENTS = 6;
const MAX_IMAGE_ATTACHMENT_BYTES = 20 * 1024 * 1024;
const MAX_AUDIO_RECORDING_MS = 5 * 60 * 1000;
const AUDIO_RECORDING_TICK_MS = 200;

// Initial API value will be set by useEffect after settings are loaded
let API = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000';
const TOP_ALIGN_OFFSET = 48; // match .chat padding + header height for exact top alignment (should be more dynamic depending on header height)
const BOTTOM_EPSILON = 24; // px tolerance for treating as bottom

function resolveBackendApiUrl(settings) {
  return settings.backendApiUrl || settings.ollamaApiUrl || API;
}

function migrateLegacySearxUrl(value) {
  const trimmed = typeof value === 'string' ? value.trim() : '';
  if (!trimmed) return DEFAULT_SEARX_URL;
  if (trimmed === 'http://localhost:8888') return DEFAULT_SEARX_URL;
  return trimmed;
}

function hasFilePayload(event) {
  const types = Array.from(event?.dataTransfer?.types || []);
  return types.includes('Files');
}

function isImageFile(file) {
  if (!file) return false;
  if (typeof file.type === 'string' && file.type.toLowerCase().startsWith('image/')) {
    return true;
  }
  return /\.(png|jpe?g|gif|bmp|webp|tiff?|heic|avif)$/i.test(file.name || '');
}

function eventHasImageFiles(event) {
  const items = Array.from(event?.dataTransfer?.items || []);
  if (items.length > 0) {
    return items.some(item => item.kind === 'file' && isImageFile(item.getAsFile?.()));
  }
  const files = Array.from(event?.dataTransfer?.files || []);
  return files.some(isImageFile);
}

function readFileAsDataUrl(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onerror = () => reject(reader.error || new Error(`Failed to read ${file?.name || 'image'}`));
    reader.onload = () => resolve(String(reader.result || ''));
    reader.readAsDataURL(file);
  });
}

function appendTranscriptToComposer(currentInput, transcript) {
  const nextTranscript = String(transcript || '').trim()
  if (!nextTranscript) {
    return currentInput || ''
  }

  const existing = String(currentInput || '')
  if (!existing.trim()) {
    return nextTranscript
  }

  const separator = /[\s\n]$/.test(existing) ? '' : '\n'
  return `${existing}${separator}${nextTranscript}`
}

function formatRecordingDuration(milliseconds) {
  const totalSeconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000))
  const minutes = Math.floor(totalSeconds / 60)
  const seconds = totalSeconds % 60
  return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}

function buildModelPickerOptions(values, currentValue, missingLabel) {
  const uniqueValues = [...new Set((Array.isArray(values) ? values : []).filter(Boolean))]
  const options = uniqueValues.map(value => ({ value, label: value }))

  if (currentValue && !uniqueValues.includes(currentValue)) {
    options.unshift({
      value: currentValue,
      label: `${currentValue} (${missingLabel})`,
    })
  }

  return options
}

export default function App() {
  const [chatSessions, setChatSessions] = useState([])
  const [activeSessionId, setActiveSessionId] = useState(null)
  const [activeSidebarMode, setActiveSidebarMode] = useState('chats') // 'chats', 'dbs', 'settings'
  const activeSidebarModeRef = useRef(activeSidebarMode)
  const [activeSettingsSubmenu, setActiveSettingsSubmenu] = useState('General');
  const [editingSessionId, setEditingSessionId] = useState(null); // ID of the session being edited
  const [editingLibrarySlug, setEditingLibrarySlug] = useState(null)
  const [libraries, setLibraries] = useState([])
  const [libraryJobs, setLibraryJobs] = useState([])
  const [activeLibrarySlug, setActiveLibrarySlug] = useState(null)
  const [chatLibraryBySession, setChatLibraryBySession] = useState(() => {
    try {
      const raw = localStorage.getItem(CHAT_LIBRARY_MAP_KEY)
      return raw ? JSON.parse(raw) : {}
    } catch {
      return {}
    }
  })
  const [isCreatingLibrary, setIsCreatingLibrary] = useState(false)
  const [newLibraryName, setNewLibraryName] = useState('')
  const [libraryCreateError, setLibraryCreateError] = useState('')
  const [isDbPickerOpen, setIsDbPickerOpen] = useState(false)
  const [isChatModelPickerOpen, setIsChatModelPickerOpen] = useState(false)
  const [availableChatModels, setAvailableChatModels] = useState([])
  const [availableVisionModels, setAvailableVisionModels] = useState([])
  const [isLoadingModelCatalog, setIsLoadingModelCatalog] = useState(false)

  // Use currentSessionId for the actual chat operations
  const [model, setModel] = useState('')
  const [visionModel, setVisionModel] = useState('')
  const [transcriptionModel, setTranscriptionModel] = useState('base')
  const [selectedVisionModelSupportsVision, setSelectedVisionModelSupportsVision] = useState(false)
  const [input, setInput] = useState('')
  const [composerAttachments, setComposerAttachments] = useState([])
  const [isChatDragActive, setIsChatDragActive] = useState(false)
  const chatRef = useRef(null)
  const textareaRef = useRef(null); // Ref for the textarea
  const dbPickerRef = useRef(null)
  const chatModelPickerRef = useRef(null)
  const imageInputRef = useRef(null)
  const imageDragDepthRef = useRef(0)
  const [audioInputEnabled, setAudioInputEnabled] = useState(true)
  const [audioInputDeviceId, setAudioInputDeviceId] = useState('')
  const [audioInputLanguage, setAudioInputLanguage] = useState('')
  const [audioInputRuntimeReady, setAudioInputRuntimeReady] = useState(true)
  const [audioInputRuntimeMessage, setAudioInputRuntimeMessage] = useState('')
  const [isRecordingAudio, setIsRecordingAudio] = useState(false)
  const [isTranscribingAudio, setIsTranscribingAudio] = useState(false)
  const [audioRecordingMs, setAudioRecordingMs] = useState(0)
  const audioRecorderRef = useRef(null)
  const audioStreamRef = useRef(null)
  const audioChunksRef = useRef([])
  const audioStopPromiseRef = useRef(null)
  const audioRecorderMimeTypeRef = useRef('')
  const audioTickTimerRef = useRef(null)
  const audioAutoStopTimerRef = useRef(null)
  const audioStartedAtRef = useRef(0)
  const audioTranscriptionAbortRef = useRef(null)
  const [backendApiUrl, setBackendApiUrl] = useState(API); // State for Heimgeist backend URL
  const [colorScheme, setColorScheme] = useState('Default'); // State for color scheme
  const [streamOutput, setStreamOutput] = useState(false);
  const [startupTaskMessage, setStartupTaskMessage] = useState('');
  const [startupTaskBusy, setStartupTaskBusy] = useState(false);
  const [searxUrl, setSearxUrl] = useState(() => migrateLegacySearxUrl(localStorage.getItem(WEBSEARCH_URL_KEY)));
  const [searxEngines, setSearxEngines] = useState(() =>
    loadStoredWebsearchEngines(localStorage.getItem(WEBSEARCH_ENGINES_KEY))
  );
  useEffect(() => {
    localStorage.setItem(WEBSEARCH_URL_KEY, searxUrl || '');
  }, [searxUrl]);

  useEffect(() => {
    try {
      localStorage.setItem(
        WEBSEARCH_ENGINES_KEY,
        JSON.stringify(normalizeWebsearchEngines(searxEngines))
      );
    } catch {}
  }, [searxEngines]);
  const [webSearchEnabled, setWebSearchEnabled] = useState(false);
  const [isSending, setIsSending] = useState(false);
  const [loading, setLoading] = useState(true); // Loading state for initial session fetch
  const [unreadSessions, setUnreadSessions] = useState([]); // Track unread messages
  const [scrollPositions, setScrollPositions] = useState({}); // Store scroll positions for each session
  const [settingsLoaded, setSettingsLoaded] = useState(false);
  const startupOllamaCheckRanRef = useRef(false);
  // Editing state for user messages
  const [editingMessageIndex, setEditingMessageIndex] = useState(null);
  const [editText, setEditText] = useState('');
  // Helpers + handlers for message copy/edit/regenerate (must live inside App)
  function getMarkdownForCopy(message) {
    const raw = message.content || '';

    if (message.role === 'assistant') {
      // Copy the assistant's raw *markdown answer*, not rendered text,
      // and strip any <think>...</think> block.
      try {
        const { answer } = splitThinkBlocks(raw);
        return (answer || raw).trim();
      } catch {
        return raw.trim();
      }
    }

    // User messages: copy exactly as typed
    return raw;
  }

  async function handleCopyMessage(message) {
    try {
      await navigator.clipboard.writeText(getMarkdownForCopy(message));
    } catch (err) {
      console.error('Failed to copy message:', err);
    }
  }

  function setAssistantMessageContent(sessionId, messageId, content, options = {}) {
    const { removeIfEmpty = false } = options

    setChatSessions(prevSessions =>
      prevSessions.map(session => {
        if (session.session_id !== sessionId) return session

        const nextMessages = []
        for (const message of session.messages || []) {
          if (message.id !== messageId) {
            nextMessages.push(message)
            continue
          }

          if (removeIfEmpty && !content) continue
          nextMessages.push({ ...message, content })
        }

        return { ...session, messages: nextMessages }
      })
    )
  }

  function isAbortError(error) {
    return error?.name === 'AbortError'
  }

  function messageHasImageAttachments(message) {
    return Array.isArray(message?.attachments) && message.attachments.length > 0
  }

  function resolveChatRequestModel(attachments = []) {
    return Array.isArray(attachments) && attachments.length > 0
      ? (visionModel || model)
      : model
  }

  function getErrorText(error) {
    if (error instanceof Error && error.message) return error.message
    return String(error)
  }

  async function readBackendErrorText(response) {
    const bodyText = await response.text().catch(() => '')
    if (bodyText) {
      try {
        const data = JSON.parse(bodyText)
        if (typeof data?.detail === 'string' && data.detail.trim()) {
          return data.detail.trim()
        }
        if (typeof data?.message === 'string' && data.message.trim()) {
          return data.message.trim()
        }
      } catch {}
      return bodyText.trim()
    }
    return `HTTP ${response.status}`
  }

  async function expectBackendJson(response) {
    const data = await response.json().catch(() => null)
    if (response.ok) return data
    const detail = typeof data?.detail === 'string'
      ? data.detail
      : (typeof data?.message === 'string' ? data.message : '')
    throw new Error(detail || `HTTP ${response.status}`)
  }

  async function fetchModelCapabilities(modelName, signal) {
    const response = await fetch(
      `${backendApiUrl}/models/capabilities?name=${encodeURIComponent(modelName)}`,
      { signal }
    )
    return expectBackendJson(response)
  }

  async function fetchStartupOllamaStatus() {
    const response = await fetch(`${backendApiUrl}/ollama/startup-status`)
    return expectBackendJson(response)
  }

  async function syncVisionModelFromChatModel(nextModel, options = {}) {
    const { allowCapabilityLookup = true } = options
    if (!nextModel) {
      return false
    }

    if (availableVisionModels.includes(nextModel)) {
      setVisionModel(nextModel)
      window.electronAPI.setSetting('visionModel', nextModel)
      return true
    }

    if (!allowCapabilityLookup || !backendApiUrl) {
      return false
    }

    try {
      const data = await fetchModelCapabilities(nextModel)
      if (!data?.supports_vision) {
        return false
      }
      setVisionModel(nextModel)
      window.electronAPI.setSetting('visionModel', nextModel)
      return true
    } catch (error) {
      if (!isAbortError(error)) {
        console.warn('Failed to check chat model vision capabilities', error)
      }
      return false
    }
  }

  async function handleChatModelSelect(nextModel) {
    if (!nextModel || nextModel === model) {
      setIsChatModelPickerOpen(false)
      return
    }

    setIsChatModelPickerOpen(false)
    setModel(nextModel)
    window.electronAPI.setSetting('chatModel', nextModel)
    await syncVisionModelFromChatModel(nextModel)
  }

  async function prepareStartupModels() {
    const response = await fetch(`${backendApiUrl}/startup/prepare-models`, { method: 'POST' })
    return expectBackendJson(response)
  }

  function syncAudioInputRuntimeFromStartupStatus(status) {
    const whisperReady = Boolean(status?.whisper_model_available)
    if (whisperReady) {
      setAudioInputRuntimeReady(true)
      setAudioInputRuntimeMessage('')
      return
    }

    const modelName = status?.whisper_model || 'base'
    const reason = String(status?.whisper_error || '').trim()
    setAudioInputRuntimeReady(false)
    setAudioInputRuntimeMessage(reason || `Whisper ${modelName} is not available.`)
  }

  async function fetchLocalLibraryContext(slug, prompt, signal) {
    if (!slug) return { contextBlock: null, sources: [] }

    const resp = await fetch(`${backendApiUrl}/libraries/${slug}/context`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      signal,
      body: JSON.stringify({
        prompt,
        top_k: 5
      })
    })
    const data = await resp.json()
    return {
      contextBlock: typeof data?.context_block === 'string' && data.context_block.trim() ? data.context_block.trim() : null,
      sources: Array.isArray(data?.sources) ? data.sources : [],
    }
  }

  async function appendComposerImageFiles(fileList) {
    const incoming = Array.from(fileList || []).filter(isImageFile)
    if (!incoming.length) {
      return
    }
    if (!selectedVisionModelSupportsVision) {
      return
    }

    const remainingSlots = Math.max(0, MAX_IMAGE_ATTACHMENTS - composerAttachments.length)
    if (remainingSlots <= 0) {
      window.alert(`You can attach up to ${MAX_IMAGE_ATTACHMENTS} images per message.`)
      return
    }

    const candidates = incoming.slice(0, remainingSlots)
    const oversized = candidates.filter(file => Number(file.size) > MAX_IMAGE_ATTACHMENT_BYTES)
    const acceptedFiles = candidates.filter(file => Number(file.size) <= MAX_IMAGE_ATTACHMENT_BYTES)

    if (oversized.length > 0) {
      window.alert(`Images must be ${Math.round(MAX_IMAGE_ATTACHMENT_BYTES / (1024 * 1024))} MB or smaller.`)
    }

    if (!acceptedFiles.length) {
      return
    }

    try {
      const nextAttachments = await Promise.all(
        acceptedFiles.map(async (file, index) => ({
          id: `attachment-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`,
          name: file.name || 'image',
          mime_type: file.type || 'image/*',
          data_url: await readFileAsDataUrl(file),
        }))
      )

      setComposerAttachments(prev => [...prev, ...nextAttachments])

      if (incoming.length > remainingSlots) {
        window.alert(`Only the first ${MAX_IMAGE_ATTACHMENTS} images can be attached.`)
      }
    } catch (error) {
      console.error('Failed to load image attachments', error)
      window.alert(`Image import failed: ${getErrorText(error)}`)
    }
  }

  function removeComposerAttachment(attachmentId) {
    setComposerAttachments(prev => prev.filter(attachment => attachment.id !== attachmentId))
  }

  function openImagePicker() {
    if (!selectedVisionModelSupportsVision) {
      return
    }
    imageInputRef.current?.click()
  }

  function clearAudioTimers() {
    if (audioTickTimerRef.current) {
      window.clearInterval(audioTickTimerRef.current)
      audioTickTimerRef.current = null
    }
    if (audioAutoStopTimerRef.current) {
      window.clearTimeout(audioAutoStopTimerRef.current)
      audioAutoStopTimerRef.current = null
    }
  }

  function cleanupAudioRecorder(stream = audioStreamRef.current) {
    clearAudioTimers()
    stopMediaStream(stream)
    if (audioStreamRef.current === stream) {
      audioStreamRef.current = null
    }
    audioRecorderRef.current = null
  }

  async function transcribeRecordedAudio(blob, mimeType) {
    if (!backendApiUrl) {
      throw new Error('The Heimgeist backend is not configured.')
    }

    const controller = new AbortController()
    audioTranscriptionAbortRef.current = controller
    setIsTranscribingAudio(true)

    try {
      const dataUrl = await readFileAsDataUrl(blob)
      const commaIndex = dataUrl.indexOf(',')
      if (commaIndex <= 5) {
        throw new Error('Recorded audio could not be encoded for upload.')
      }
      const header = dataUrl.slice(0, commaIndex)
      const payload = dataUrl.slice(commaIndex + 1)
      if (!/^data:[^,]+;base64$/i.test(header) || !payload) {
        throw new Error('Recorded audio could not be encoded for upload.')
      }
      const detectedMimeType = header
        .slice(5)
        .replace(/;base64$/i, '')
        .trim()

      const response = await fetch(`${backendApiUrl}/audio/transcribe`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        signal: controller.signal,
        body: JSON.stringify({
          mime_type: mimeType || detectedMimeType || 'audio/webm',
          audio_base64: payload,
          model: transcriptionModel || null,
          language: audioInputLanguage || null,
        }),
      })
      const data = await expectBackendJson(response)
      const transcript = String(data?.text || '').trim()

      if (!transcript) {
        window.alert('No speech was detected. Try again and speak closer to the selected microphone.')
        return
      }

      setInput(prev => appendTranscriptToComposer(prev, transcript))
      requestAnimationFrame(() => textareaRef.current?.focus())
    } catch (error) {
      if (isAbortError(error)) {
        return
      }
      throw error
    } finally {
      if (audioTranscriptionAbortRef.current === controller) {
        audioTranscriptionAbortRef.current = null
      }
      setIsTranscribingAudio(false)
    }
  }

  async function stopAudioRecording(options = {}) {
    const { shouldTranscribe = true } = options
    const recorder = audioRecorderRef.current
    const stopPromise = audioStopPromiseRef.current
    const mimeType = audioRecorderMimeTypeRef.current || recorder?.mimeType || 'audio/webm'

    if (!recorder || !stopPromise) {
      return
    }

    setIsRecordingAudio(false)
    clearAudioTimers()

    try {
      if (recorder.state !== 'inactive') {
        recorder.stop()
      }

      const blob = await stopPromise
      if (!shouldTranscribe) {
        return
      }
      if (!blob || blob.size <= 0) {
        throw new Error('Recorded audio was empty.')
      }
      await transcribeRecordedAudio(blob, mimeType)
    } catch (error) {
      if (shouldTranscribe) {
        console.error('Failed to stop microphone recording', error)
        window.alert(`Microphone transcription failed: ${getErrorText(error)}`)
      }
    } finally {
      audioStopPromiseRef.current = null
      audioChunksRef.current = []
      audioRecorderMimeTypeRef.current = ''
      audioStartedAtRef.current = 0
      setAudioRecordingMs(0)
    }
  }

  async function startAudioRecording() {
    if (!audioInputEnabled || !audioInputRuntimeReady || isRecordingAudio || isTranscribingAudio || isSending) {
      return
    }
    if (!supportsAudioInputCapture()) {
      window.alert('Microphone capture is not available in this environment.')
      return
    }

    let stream = null
    try {
      stream = await navigator.mediaDevices.getUserMedia(getAudioInputConstraints(audioInputDeviceId))
    } catch (error) {
      console.error('Failed to access microphone', error)
      window.alert(`Microphone access failed: ${getErrorText(error)}`)
      return
    }

    const preferredMimeType = getPreferredAudioRecorderMimeType()
    let recorder = null
    try {
      recorder = preferredMimeType
        ? new MediaRecorder(stream, { mimeType: preferredMimeType })
        : new MediaRecorder(stream)
    } catch (error) {
      cleanupAudioRecorder(stream)
      console.error('Failed to create audio recorder', error)
      window.alert(`Microphone recording is not available: ${getErrorText(error)}`)
      return
    }

    audioStreamRef.current = stream
    audioRecorderRef.current = recorder
    audioChunksRef.current = []
    audioRecorderMimeTypeRef.current = recorder.mimeType || preferredMimeType || 'audio/webm'
    audioStartedAtRef.current = Date.now()
    setAudioRecordingMs(0)
    setIsRecordingAudio(true)

    recorder.ondataavailable = (event) => {
      if (event.data && event.data.size > 0) {
        audioChunksRef.current.push(event.data)
      }
    }

    audioStopPromiseRef.current = new Promise((resolve, reject) => {
      recorder.onstop = () => {
        const blob = new Blob(audioChunksRef.current, {
          type: recorder.mimeType || audioRecorderMimeTypeRef.current || 'audio/webm',
        })
        cleanupAudioRecorder(stream)
        resolve(blob)
      }
      recorder.onerror = (event) => {
        cleanupAudioRecorder(stream)
        reject(event?.error || new Error('Audio recording failed.'))
      }
    })

    audioTickTimerRef.current = window.setInterval(() => {
      setAudioRecordingMs(Date.now() - audioStartedAtRef.current)
    }, AUDIO_RECORDING_TICK_MS)
    audioAutoStopTimerRef.current = window.setTimeout(() => {
      stopAudioRecording()
    }, MAX_AUDIO_RECORDING_MS)

    recorder.start()
  }

  async function toggleAudioRecording() {
    if (isTranscribingAudio) {
      return
    }
    if (isRecordingAudio) {
      await stopAudioRecording()
      return
    }
    await startAudioRecording()
  }

  async function handleComposerImageSelection(event) {
    const files = event.target?.files
    try {
      await appendComposerImageFiles(files)
    } finally {
      if (event.target) {
        event.target.value = ''
      }
    }
  }

  function startEditMessage(index, content) {
    setEditingMessageIndex(index);
    setEditText(content || '');
  }

  function cancelEditMessage() {
    setEditingMessageIndex(null);
    setEditText('');
  }

  async function commitEditMessage(index) {
    const original = (messages[index]?.content || '').trim();
    const nextRaw = editText ?? '';
    const next = nextRaw.trim();

    // NEW: If empty after trimming, cancel edit (revert to original)
    if (next.length === 0) {
      cancelEditMessage();
      return;
    }

    // If nothing changed, cancel edit
    if (next === original) {
      cancelEditMessage();
      return;
    }

    const sessionId = activeSessionId;
    if (!sessionId) return;

    // Optimistically update UI: set edited content and prune following messages
    setChatSessions(prev =>
      prev.map(s => {
        if (s.session_id !== sessionId) return s;
        const old = s.messages || [];
        const updated = old.slice(0, index + 1).map((m, j) =>
          j === index ? { ...m, content: next } : m
        );
        return { ...s, messages: updated };
      })
    );

    // Exit edit mode immediately
    setEditingMessageIndex(null);
    setEditText('');

    // ⬇️ Scroll the chat frame to the bottom after the DOM updates
    requestAnimationFrame(() => scrollToBottom('auto', sessionId));

    try {
      const resp = await fetch(`${backendApiUrl}/sessions/${sessionId}/messages/${index}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: next })
      });
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    } catch (err) {
      // Roll back to original content on failure
      console.error('Failed to update message:', err);
      setChatSessions(prev =>
        prev.map(s => {
          if (s.session_id !== sessionId) return s;
          const old = s.messages || [];
          const restored = old.map((m, j) =>
            j === index ? { ...m, content: original } : m
          );
          return { ...s, messages: restored };
        })
      );
      return; // don't regenerate on failure
    }

    // Continue conversation from the edited message
    await regenerateFromIndex(index, next);
  }

async function regenerateFromIndex(index, overrideUserText = null) {
  const sessionId = activeSessionId
  if (isSending || !sessionId || typeof index !== 'number') return

  const msgs = (chatSessions.find(s => s.session_id === sessionId)?.messages) || []
  let lastUserIdx = index
  for (let i = index; i >= 0; i--) {
    if (msgs[i]?.role === 'user') {
      lastUserIdx = i
      break
    }
  }

  setChatSessions(prev =>
    prev.map(s => s.session_id === sessionId
      ? { ...s, messages: (s.messages || []).slice(0, lastUserIdx + 1) }
      : s
    )
  )

  const conversationNeedsVision = msgs
    .slice(0, lastUserIdx + 1)
    .some(messageHasImageAttachments)
  const requestModel = conversationNeedsVision ? (visionModel || model) : model
  if (conversationNeedsVision && !selectedVisionModelSupportsVision) {
    window.alert('The selected vision model does not support image inputs.')
    return
  }

  const requestController = beginCancelableRequest(sessionId)

  let enrichedPrompt = overrideUserText != null ? overrideUserText : (msgs[lastUserIdx]?.content || '')
  let citationSources = []
  const contextBlocks = []
  try {
    const selectedLibrary = getChatLibraryForSession(sessionId)
    const promptText = overrideUserText != null ? overrideUserText : (msgs[lastUserIdx]?.content || '')
    const hasPromptText = Boolean((promptText || '').trim())

    if (hasPromptText && selectedLibrary?.states?.is_indexed) {
      try {
        const localContext = await fetchLocalLibraryContext(selectedLibrary.slug, promptText, requestController.signal)
        if (localContext.contextBlock) {
          contextBlocks.push(localContext.contextBlock)
        }
        if (Array.isArray(localContext.sources)) {
          citationSources.push(...localContext.sources)
        }
      } catch (error) {
        if (isAbortError(error)) throw error
        console.warn('local library enrichment (regenerate) failed', error)
      }
    }

    if (hasPromptText && webSearchEnabled) {
      try {
        const historyForSearch = msgs
          .slice(Math.max(0, lastUserIdx - 7), lastUserIdx + 1)
          .map(m => ({ role: m.role, content: m.content || '' }))
        if (historyForSearch.length > 0) {
          historyForSearch[historyForSearch.length - 1] = { role: 'user', content: promptText }
        }

        const resp = await fetch(`${backendApiUrl}/websearch`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          signal: requestController.signal,
          body: JSON.stringify({
            prompt: promptText,
            model,
            messages: historyForSearch,
            history_limit: 8,
            searx_url: searxUrl || null,
            engines: Array.isArray(searxEngines) ? searxEngines : null,
          })
        })
        const data = await resp.json()
        if (data && typeof data.context_block === 'string' && data.context_block.trim()) {
          contextBlocks.push(data.context_block.trim())
        }
        if (Array.isArray(data?.sources)) {
          citationSources.push(...data.sources)
        }
      } catch (error) {
        if (isAbortError(error)) throw error
        console.warn('web search enrichment (regenerate) failed', error)
      }
    }

    citationSources = [...new Set(citationSources)]
    if (hasPromptText && contextBlocks.length > 0) {
      enrichedPrompt = `${promptText}\n\n${contextBlocks.join('\n\n')}`
    } else {
      enrichedPrompt = null
    }

    if (streamOutput) {
      const assistantMsgId = `msg-${Date.now()}-${Math.random()}`
      let full = ''

      setChatSessions(prev =>
        prev.map(s => s.session_id === sessionId
          ? { ...s, messages: [...(s.messages || []), { id: assistantMsgId, role: 'assistant', content: '', sources: citationSources }] }
          : s
        )
      )

      try {
        const res = await fetch(`${backendApiUrl}/sessions/${sessionId}/regenerate`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          signal: requestController.signal,
          body: JSON.stringify({
            index,
            model: requestModel,
            stream: true,
            enriched_message: enrichedPrompt,
            sources: citationSources || []
          })
        })
        if (!res.ok) throw new Error(await readBackendErrorText(res))

        const reader = res.body?.getReader()
        if (!reader) throw new Error('Missing response body')

        const decoder = new TextDecoder()
        let unreadMarked = false

        while (true) {
          const { value, done } = await reader.read()
          if (done) break

          const chunk = decoder.decode(value, { stream: true })
          full += chunk
          setAssistantMessageContent(sessionId, assistantMsgId, full)

          if (!unreadMarked && activeSessionIdRef.current !== sessionId) {
            unreadMarked = true
            setPendingScrollToLastUser(prev => ({ ...prev, [sessionId]: assistantMsgId }))
            setUnreadSessions(prev => [...new Set([...prev, sessionId])])
          }
        }

        if (activeSessionIdRef.current !== sessionId) {
          setPendingScrollToLastUser(prev => ({ ...prev, [sessionId]: assistantMsgId }))
          setUnreadSessions(prev => [...new Set([...prev, sessionId])])
        } else if (!userScrolledUpRef.current[sessionId]) {
          requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', sessionId))
        } else {
          setNewMsgTip(prev => ({ ...prev, [sessionId]: assistantMsgId }))
        }
      } catch (error) {
        if (isAbortError(error)) {
          setAssistantMessageContent(sessionId, assistantMsgId, full, { removeIfEmpty: true })
          return
        }

        console.error(error)
        setAssistantMessageContent(sessionId, assistantMsgId, `Error: ${getErrorText(error)}`, { removeIfEmpty: true })
        return
      }
    } else {
      const res = await fetch(`${backendApiUrl}/sessions/${sessionId}/regenerate`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        signal: requestController.signal,
        body: JSON.stringify({
          index,
          model: requestModel,
          stream: false,
          enriched_message: enrichedPrompt,
          sources: citationSources || []
        })
      })
      if (!res.ok) throw new Error(await readBackendErrorText(res))

      const data = await res.json()
      const assistantMsgId = `msg-${Date.now()}`
      setChatSessions(prev =>
        prev.map(s => s.session_id === sessionId
          ? { ...s, messages: [...(s.messages || []), { role: 'assistant', content: data.reply, id: assistantMsgId, sources: citationSources }] }
          : s
        )
      )

      if (activeSessionIdRef.current !== sessionId) {
        setPendingScrollToLastUser(prev => ({ ...prev, [sessionId]: assistantMsgId }))
        setUnreadSessions(prev => [...new Set([...prev, sessionId])])
      } else if (!userScrolledUpRef.current[sessionId]) {
        requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', sessionId))
      } else {
        setNewMsgTip(prev => ({ ...prev, [sessionId]: assistantMsgId }))
      }
    }
  } catch (error) {
    if (!isAbortError(error)) {
      console.error(error)
    }
  } finally {
    finishCancelableRequest(requestController)
  }
}


  // Persist userScrolledUp state per session + live ref for closures (streaming)
  const [userScrolledUpState, setUserScrolledUpState] = useState({});
  const userScrolledUpRef = useRef({});

  // When a response arrives in a non-active chat, remember to scroll to the new ASSISTANT message on open
  const [pendingScrollToLastUser, setPendingScrollToLastUser] = useState({}); // { [sessionId]: assistantMsgId }

  // Live per-session scrollTop tracker to avoid races
  const scrollTopsRef = useRef({});
  // Live per-session previous scrollTop tracker to detect scroll direction
  const prevScrollTopsRef = useRef({});

  // Tip state: { [sessionId]: messageId }
  const [newMsgTip, setNewMsgTip] = useState({});

  // Collapse state per user message: { [msgKey]: boolean } — true means "collapsed"
  const [collapsedUserMsgs, setCollapsedUserMsgs] = useState({});

  useEffect(() => {
    activeSidebarModeRef.current = activeSidebarMode
  }, [activeSidebarMode])

  // Compute a stable key for collapse map (prefer id, else session:index)
  const collapseKeyFor = (m, i, sessionId) => (m?.id ? m.id : `${sessionId}:${i}`);

  // Initialize/maintain collapsed map whenever messages or the active session change
  useEffect(() => {
    if (!activeSessionId) return;

    const msgs =
      (chatSessions.find(s => s.session_id === activeSessionId)?.messages) || [];

    setCollapsedUserMsgs(prev => {
      const next = {};
      msgs.forEach((m, i) => {
        if (m.role !== 'user') return;
        const key = collapseKeyFor(m, i, activeSessionId);
        const lineCount = (m.content || '').split(/\r\n|\r|\n/).length;
        const needsCollapse = lineCount > 30;
        // Default collapsed = true when needsCollapse; preserve user toggles
        next[key] = needsCollapse ? (prev[key] ?? true) : false;
      });
      return next;
    });
  }, [chatSessions, activeSessionId]);

  // Toggle collapse/expand for a specific message
  function toggleUserMsgCollapse(key) {
    setCollapsedUserMsgs(prev => ({ ...prev, [key]: !(prev[key] ?? true) }));
  }

  const setUserScrolledUp = React.useCallback((sessionId, value) => {
    setUserScrolledUpState(prev => {
      const next = { ...prev, [sessionId]: value };
      userScrolledUpRef.current = next;
      return next;
    });
  }, []);

  const activeRequestRef = useRef(null);
  const justSentMessage = useRef(false);
  const lastSentSessionRef = useRef(null);
  const activeSessionIdRef = useRef(activeSessionId);
  useEffect(() => {
    activeSessionIdRef.current = activeSessionId;
  }, [activeSessionId]);

  const beginCancelableRequest = React.useCallback((sessionId) => {
    const controller = new AbortController()
    activeRequestRef.current = { controller, sessionId }
    setIsSending(true)
    return controller
  }, [])

  const finishCancelableRequest = React.useCallback((controller) => {
    if (activeRequestRef.current?.controller !== controller) return
    activeRequestRef.current = null
    setIsSending(false)
  }, [])

  const cancelActiveRequest = React.useCallback(() => {
    const activeRequest = activeRequestRef.current
    if (!activeRequest) return
    activeRequestRef.current = null
    activeRequest.controller.abort()
    setIsSending(false)
  }, [])

  useEffect(() => {
    return () => {
      activeRequestRef.current?.controller.abort()
    }
  }, [])

  // Flag to ensure we only restore once per open of a chat
  const restoredForRef = useRef(null);

  // Sidebar resizing state
  const [sidebarWidth, setSidebarWidth] = useState(230);
  const [isResizing, setIsResizing] = useState(false);

  const startResizing = React.useCallback((mouseDownEvent) => {
    setIsResizing(true);
  }, []);

  const stopResizing = React.useCallback(() => {
    setIsResizing(false);
  }, []);

  const resizeSidebar = React.useCallback((mouseMoveEvent) => {
    if (isResizing) {
      const newWidth = Math.max(230, Math.min(500, mouseMoveEvent.clientX));
      setSidebarWidth(newWidth);
    }
  }, [isResizing]);

  React.useEffect(() => {
    window.addEventListener('mousemove', resizeSidebar);
    window.addEventListener('mouseup', stopResizing);
    window.addEventListener('blur', stopResizing);
    return () => {
      window.removeEventListener('mousemove', resizeSidebar);
      window.removeEventListener('mouseup', stopResizing);
      window.removeEventListener('blur', stopResizing);
    };
  }, [resizeSidebar, stopResizing]);

  React.useEffect(() => {
    if (isResizing) {
      document.body.classList.add('no-select');
    } else {
      document.body.classList.remove('no-select');
    }

    return () => {
      document.body.classList.remove('no-select');
    };
  }, [isResizing]);

  React.useEffect(() => {
    const onClick = async (e) => {
      const btn = e.target.closest('.codeblock__copy');
      if (!btn) return;

      const wrapper = btn.closest('.codeblock');
      const codeEl = wrapper?.querySelector('pre > code');
      if (!codeEl) return;

      try {
        // Use textContent to copy the plain code accurately
        await navigator.clipboard.writeText(codeEl.textContent || '');
        // Optional: brief visual feedback
        btn.classList.add('copied');
        setTimeout(() => btn.classList.remove('copied'), 800);
      } catch (err) {
        console.error('Copy failed:', err);
      }
    };

    document.addEventListener('click', onClick);
    return () => document.removeEventListener('click', onClick);
  }, []);


  useEffect(() => {
    let cancelled = false

    window.electronAPI.getSettings().then(settings => {
      if (cancelled) return
      setBackendApiUrl(resolveBackendApiUrl(settings));
      setColorScheme(settings.colorScheme || 'Default');
      setModel(settings.chatModel || ''); // Load the selected model, with a fallback
      setVisionModel(settings.visionModel || settings.chatModel || '');
      setTranscriptionModel(settings.transcriptionModel || 'base');
      setStreamOutput(settings.streamOutput || false);
      setAudioInputEnabled(true);
      if (settings.audioInputEnabled !== true) {
        window.electronAPI.setSetting('audioInputEnabled', true)
      }
      setAudioInputDeviceId(typeof settings.audioInputDeviceId === 'string' ? settings.audioInputDeviceId : '');
      setAudioInputLanguage(typeof settings.audioInputLanguage === 'string' ? settings.audioInputLanguage : '');
      setScrollPositions(settings.scrollPositions || {}); // Load scroll positions
      applyColorScheme(settings.colorScheme || 'Default'); // Apply initial scheme
    }).finally(() => {
      if (!cancelled) {
        setSettingsLoaded(true);
      }
    });

    return () => {
      cancelled = true
    };
  }, []);

  useEffect(() => {
    let cancelled = false
    const controller = new AbortController()

    if (!backendApiUrl) {
      setAvailableChatModels([])
      setAvailableVisionModels([])
      setIsLoadingModelCatalog(false)
      return () => {
        controller.abort()
      }
    }

    setIsLoadingModelCatalog(true)

    ;(async () => {
      try {
        const response = await fetch(`${backendApiUrl}/models`, { signal: controller.signal })
        const data = await expectBackendJson(response)
        if (cancelled) {
          return
        }
        setAvailableChatModels(Array.isArray(data?.chat_models) ? data.chat_models.filter(Boolean) : [])
        setAvailableVisionModels(Array.isArray(data?.vision_models) ? data.vision_models.filter(Boolean) : [])
      } catch (error) {
        if (!cancelled && !isAbortError(error)) {
          console.warn('Failed to load chat model catalog', error)
        }
      } finally {
        if (!cancelled) {
          setIsLoadingModelCatalog(false)
        }
      }
    })()

    return () => {
      cancelled = true
      controller.abort()
    }
  }, [backendApiUrl])

  useEffect(() => {
    const handleFocus = () => {
      if (activeSidebarModeRef.current === 'chats') {
        textareaRef.current?.focus();
      }
    };

    window.electronAPI.onWindowFocus(handleFocus);

    return () => {
      window.electronAPI.offWindowFocus(handleFocus);
    };
  }, []);

  useEffect(() => {
    let cancelled = false
    const controller = new AbortController()

    if (!backendApiUrl || !visionModel) {
      setSelectedVisionModelSupportsVision(false)
      return () => {
        controller.abort()
      }
    }

    ;(async () => {
      try {
        const data = await fetchModelCapabilities(visionModel, controller.signal)
        if (!cancelled) {
          setSelectedVisionModelSupportsVision(Boolean(data?.supports_vision))
        }
      } catch (error) {
        if (!cancelled && !isAbortError(error)) {
          console.warn('Failed to load model capabilities', error)
          setSelectedVisionModelSupportsVision(false)
        }
      }
    })()

    return () => {
      cancelled = true
      controller.abort()
    }
  }, [backendApiUrl, visionModel])

  useEffect(() => {
    imageDragDepthRef.current = 0
    setIsChatDragActive(false)
  }, [selectedVisionModelSupportsVision, activeSidebarMode])

  useEffect(() => {
    if (audioInputEnabled || !isRecordingAudio) {
      return
    }
    stopAudioRecording({ shouldTranscribe: false })
  }, [audioInputEnabled, isRecordingAudio])

  useEffect(() => {
    if (audioInputRuntimeReady || !isRecordingAudio) {
      return
    }
    stopAudioRecording({ shouldTranscribe: false })
  }, [audioInputRuntimeReady, isRecordingAudio])

  useEffect(() => {
    if (activeSidebarMode === 'chats' || !isRecordingAudio) {
      return
    }
    stopAudioRecording()
  }, [activeSidebarMode, isRecordingAudio])

  useEffect(() => {
    return () => {
      audioTranscriptionAbortRef.current?.abort()
      clearAudioTimers()
      const recorder = audioRecorderRef.current
      if (recorder) {
        recorder.ondataavailable = null
        recorder.onstop = null
        recorder.onerror = null
        try {
          if (recorder.state !== 'inactive') {
            recorder.stop()
          }
        } catch {}
      }
      stopMediaStream(audioStreamRef.current)
    }
  }, [])

  useEffect(() => {
    if (!settingsLoaded || loading || !backendApiUrl || startupOllamaCheckRanRef.current) return
    startupOllamaCheckRanRef.current = true

    let cancelled = false
    const timerId = window.setTimeout(() => { ;(async () => {
      let actionStarted = false
      try {
        let status = await fetchStartupOllamaStatus()
        if (cancelled) return
        syncAudioInputRuntimeFromStartupStatus(status)

        if (!status?.ollama_running && status?.can_manage_locally) {
          const confirmed = window.confirm(
            `Ollama is not running at ${status.ollama_url}. Start it in the background now with "ollama serve"?`
          )
          if (cancelled) return
          if (confirmed) {
            actionStarted = true
            setStartupTaskBusy(true)
            setStartupTaskMessage('Starting Ollama in the background...')
            const response = await fetch(`${backendApiUrl}/ollama/start`, { method: 'POST' })
            status = await expectBackendJson(response)
            if (cancelled) return
          }
        }

        const needsWhisper = !status?.whisper_model_available
        const needsEmbedding = Boolean(status?.ollama_running && status?.can_manage_locally && !status?.embedding_model_available)

        if (needsWhisper || needsEmbedding) {
          actionStarted = true
          setStartupTaskBusy(true)
          if (needsWhisper && needsEmbedding) {
            setStartupTaskMessage(
              `Downloading Whisper ${status?.whisper_model || 'base'} and ${status.selected_embed_model}. This can take a while on first install.`
            )
          } else if (needsWhisper) {
            setStartupTaskMessage(`Downloading Whisper ${status?.whisper_model || 'base'}. This can take a while on first install.`)
          } else {
            setStartupTaskMessage(`Downloading ${status.selected_embed_model} from Ollama. This can take a while on first install.`)
          }
          const prepared = await prepareStartupModels()
          if (cancelled) return
          syncAudioInputRuntimeFromStartupStatus(prepared?.ollama || status)
        }
      } catch (error) {
        if (!cancelled) {
          console.warn('startup Ollama check failed', error)
          setAudioInputRuntimeReady(false)
          setAudioInputRuntimeMessage(`Whisper availability could not be verified: ${getErrorText(error)}`)
          if (actionStarted) {
            window.alert(`Startup action failed: ${getErrorText(error)}`)
          }
        }
      } finally {
        if (!cancelled) {
          setStartupTaskBusy(false)
          setStartupTaskMessage('')
        }
      }
    })() }, 1200)

    return () => {
      cancelled = true
      window.clearTimeout(timerId)
    }
  }, [backendApiUrl, loading, settingsLoaded]);

  // Apply color scheme whenever it changes
  useEffect(() => {
    applyColorScheme(colorScheme);
  }, [colorScheme]);

  const fetchHistory = (sessionId) => {
    if (!sessionId || !backendApiUrl) return;
    fetch(`${backendApiUrl}/history?session_id=${encodeURIComponent(sessionId)}`)
      .then(r => r.json())
      .then(data => {
        setChatSessions(prevSessions =>
          prevSessions.map(session =>
            session.session_id === sessionId
              ? { ...session, messages: data.messages || [] }
              : session
          )
        );
      })
      .catch(() => {});
  };

  async function refreshLibraries() {
    if (!backendApiUrl) return;
    try {
      const response = await fetch(`${backendApiUrl}/libraries`);
      const data = await response.json();
      const nextLibraries = Array.isArray(data.libraries) ? data.libraries : [];
      setLibraries(nextLibraries);

      if (nextLibraries.length === 0) {
        setActiveLibrarySlug(null);
        return;
      }

      if (!nextLibraries.some(lib => lib.slug === activeLibrarySlug)) {
        setActiveLibrarySlug(nextLibraries[0].slug);
      }
    } catch (error) {
      console.warn('Failed to load libraries', error);
    }
  }

  async function refreshLibraryJobs() {
    if (!backendApiUrl) return;
    try {
      const response = await fetch(`${backendApiUrl}/jobs`);
      const data = await response.json();
      setLibraryJobs(Array.isArray(data.jobs) ? data.jobs : []);
    } catch (error) {
      console.warn('Failed to load library jobs', error);
    }
  }

  async function createLibrary(nameOverride = null) {
    const rawName = typeof nameOverride === 'string' ? nameOverride : newLibraryName
    const name = rawName.trim()
    if (!name) {
      setLibraryCreateError('Name is required.')
      return
    }
    try {
      setLibraryCreateError('')
      const response = await fetch(`${backendApiUrl}/libraries`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name })
      });
      if (!response.ok) {
        const detail = await response.text()
        throw new Error(detail || `HTTP ${response.status}`)
      }
      const data = await response.json();
      setIsCreatingLibrary(false)
      setNewLibraryName('')
      await refreshLibraries();
      if (data?.slug) {
        setActiveLibrarySlug(data.slug);
      }
    } catch (error) {
      console.error('Failed to create library', error);
      setLibraryCreateError(String(error?.message || error))
    }
  }

  async function handleLibrariesPurged() {
    setLibraries([])
    setLibraryJobs([])
    setActiveLibrarySlug(null)
    setEditingLibrarySlug(null)
    setIsDbPickerOpen(false)
    setChatLibraryBySession({})
    await refreshLibraries()
    await refreshLibraryJobs()
  }

  // Load chat sessions from backend on initial render
  useEffect(() => {
    if (!backendApiUrl) return;
    setLoading(true);
    fetch(`${backendApiUrl}/sessions`)
      .then(r => r.json())
      .then(data => {
        const sessionsWithMessages = data.sessions.map(s => ({
          ...s,
          name: sanitizeChatTitle(s.name),
          messages: [],
        }));
        setChatSessions(sessionsWithMessages);
        if (sessionsWithMessages.length > 0) {
          setActiveSessionId(sessionsWithMessages[0].session_id);
        } else {
          setActiveSessionId(null);
        }
        setLoading(false);
      })
      .catch(() => {
        setLoading(false);
      });
  }, [backendApiUrl]);

  useEffect(() => {
    if (!backendApiUrl) return;
    refreshLibraries();
    refreshLibraryJobs();
  }, [backendApiUrl]);

  useEffect(() => {
    try {
      localStorage.setItem(CHAT_LIBRARY_MAP_KEY, JSON.stringify(chatLibraryBySession || {}));
    } catch {}
  }, [chatLibraryBySession]);

  useEffect(() => {
    if (!backendApiUrl) return;
    const interval = setInterval(() => {
      refreshLibraries();
      refreshLibraryJobs();
    }, 3000);
    return () => clearInterval(interval);
  }, [backendApiUrl, activeSidebarMode, activeLibrarySlug]);

  // Load messages for the active session
  useEffect(() => {
    fetchHistory(activeSessionId);
  }, [activeSessionId, backendApiUrl]);

  useEffect(() => {
    const validSlugs = new Set(libraries.map(library => library.slug))
    setChatLibraryBySession(prev => {
      let changed = false
      const next = {}
      for (const [sessionId, slug] of Object.entries(prev || {})) {
        if (validSlugs.has(slug)) {
          next[sessionId] = slug
        } else {
          changed = true
        }
      }
      return changed ? next : prev
    })
  }, [libraries])

  const handleSidebarClick = (mode) => {
    // Saving happens in the centralized cleanup effect below
    setActiveSidebarMode(mode);
  };

  const handleSelectChat = (sessionId) => {
    // Saving happens in the centralized cleanup effect below
    selectChat(sessionId);
  };

  const messages = useMemo(() => {
    return chatSessions.find(s => s.session_id === activeSessionId)?.messages || [];
  }, [activeSessionId, chatSessions]);

  const activeChatSession = useMemo(() => {
    return chatSessions.find(session => session.session_id === activeSessionId) || null
  }, [activeSessionId, chatSessions])

  const activeLibrary = useMemo(() => {
    return libraries.find(lib => lib.slug === activeLibrarySlug) || null;
  }, [activeLibrarySlug, libraries]);

  const chatLibrarySlug = activeSessionId ? (chatLibraryBySession[activeSessionId] || null) : null

  const chatLibrary = useMemo(() => {
    return libraries.find(lib => lib.slug === chatLibrarySlug) || null;
  }, [chatLibrarySlug, libraries]);

  const chatLibraryHasActiveJob = useMemo(() => {
    if (!chatLibrarySlug) return false
    return libraryJobs.some(job => job.slug === chatLibrarySlug && (job.status === 'queued' || job.status === 'running'))
  }, [chatLibrarySlug, libraryJobs])

  const chatLibraryStatusSuffix = useMemo(() => {
    if (!chatLibrary) return ''
    if (!chatLibrary.files?.length) return ' (empty)'
    if (chatLibrary.states?.is_indexed) return ''
    return chatLibraryHasActiveJob ? ' (syncing)' : ' (needs sync)'
  }, [chatLibrary, chatLibraryHasActiveJob])

  const chatModelPickerOptions = useMemo(() => {
    return buildModelPickerOptions(availableChatModels, model, 'saved model unavailable')
  }, [availableChatModels, model])

  function getChatLibrarySlugForSession(sessionId) {
    if (!sessionId) return null
    return chatLibraryBySession[sessionId] || null
  }

  function getChatLibraryForSession(sessionId) {
    const slug = getChatLibrarySlugForSession(sessionId)
    if (!slug) return null
    return libraries.find(lib => lib.slug === slug) || null
  }

  function isLibrarySyncing(slug) {
    if (!slug) return false
    return libraryJobs.some(job => job.slug === slug && (job.status === 'queued' || job.status === 'running'))
  }

  function setChatLibraryForSession(sessionId, slug) {
    if (!sessionId) return
    setChatLibraryBySession(prev => {
      const next = { ...(prev || {}) }
      if (slug) {
        next[sessionId] = slug
      } else {
        delete next[sessionId]
      }
      return next
    })
  }

  function removeLibraryFromChatSelections(slug) {
    if (!slug) return
    setChatLibraryBySession(prev => {
      let changed = false
      const next = {}
      for (const [sessionId, librarySlug] of Object.entries(prev || {})) {
        if (librarySlug === slug) {
          changed = true
          continue
        }
        next[sessionId] = librarySlug
      }
      return changed ? next : prev
    })
  }

  useEffect(() => {
    if (!isDbPickerOpen) return

    const onPointerDown = (event) => {
      if (!dbPickerRef.current?.contains(event.target)) {
        setIsDbPickerOpen(false)
      }
    }

    document.addEventListener('mousedown', onPointerDown)
    return () => document.removeEventListener('mousedown', onPointerDown)
  }, [isDbPickerOpen])

  useEffect(() => {
    setIsDbPickerOpen(false)
  }, [activeSessionId, activeSidebarMode])

  useEffect(() => {
    if (!isChatModelPickerOpen) return

    const onPointerDown = (event) => {
      if (!chatModelPickerRef.current?.contains(event.target)) {
        setIsChatModelPickerOpen(false)
      }
    }

    document.addEventListener('mousedown', onPointerDown)
    return () => document.removeEventListener('mousedown', onPointerDown)
  }, [isChatModelPickerOpen])

  useEffect(() => {
    setIsChatModelPickerOpen(false)
  }, [activeSessionId, activeSidebarMode])

  // Persist the scrollTop of the session we are LEAVING (on chat change or when leaving the chat view)
  useEffect(() => {
    const leavingSessionId = activeSessionId;
    const leavingMode = activeSidebarMode;

    return () => {
      if (leavingMode === 'chats' && leavingSessionId) {
        const top = typeof scrollTopsRef.current[leavingSessionId] === 'number'
          ? scrollTopsRef.current[leavingSessionId]
          : (chatRef.current ? chatRef.current.scrollTop : 0);

        setScrollPositions(prev => {
          const updated = { ...prev, [leavingSessionId]: top };
          window.electronAPI.updateSettings({ scrollPositions: updated });
          return updated;
        });
      }
    };
  }, [activeSessionId, activeSidebarMode]);

  // Track scroll + whether user left bottom
  useEffect(() => {
    const chatDiv = chatRef.current;
    if (!chatDiv) return;

    const handleScroll = () => {
      const { scrollTop, scrollHeight, clientHeight } = chatDiv;
      const isAtBottom = (scrollHeight - scrollTop - clientHeight) <= BOTTOM_EPSILON;

      if (activeSessionId) {
        const prevScrollTop = prevScrollTopsRef.current[activeSessionId];
        const scrolledUp = typeof prevScrollTop === 'number' && scrollTop < prevScrollTop;

        scrollTopsRef.current[activeSessionId] = scrollTop;

        if (isAtBottom) {
          setUserScrolledUp(activeSessionId, false); // User is at bottom, enable autoscroll
        } else if (scrolledUp) {
          setUserScrolledUp(activeSessionId, true); // User scrolled up, disable autoscroll
        }
        // If user scrolled down but not to bottom, maintain current userScrolledUp state
        prevScrollTopsRef.current[activeSessionId] = scrollTop;
      }
    };

    chatDiv.addEventListener('scroll', handleScroll);
    return () => chatDiv.removeEventListener('scroll', handleScroll);
  }, [activeSessionId, setUserScrolledUp]);

  // Auto-hide the tip if user returns to bottom in the active chat
  useEffect(() => {
    const sid = activeSessionId;
    if (!sid) return;
    if (userScrolledUpState[sid] === false) {
      setNewMsgTip(prev => {
        if (!(sid in prev)) return prev;
        const rest = { ...prev };
        delete rest[sid];
        return rest;
      });
    }
  }, [activeSessionId, userScrolledUpState]);

  // --- Robust restoration: do it before paint, exactly once per open ---
  useLayoutEffect(() => {
    if (activeSidebarMode !== 'chats' || !activeSessionId) return;

    const div = chatRef.current;
    if (!div) return;

    restoredForRef.current = null;

    const applyRestore = () => {
      if (restoredForRef.current === activeSessionId) return;

      const liveSaved = typeof scrollTopsRef.current[activeSessionId] === 'number'
        ? scrollTopsRef.current[activeSessionId]
        : undefined;
      const saved = typeof liveSaved === 'number'
        ? liveSaved
        : scrollPositions[activeSessionId];

      if (typeof saved === 'number') {
        div.scrollTop = saved;
        restoredForRef.current = activeSessionId;
        return;
      }
      if (messages.length > 0) {
        // default: bottom when no saved position
        div.scrollTop = div.scrollHeight;
        restoredForRef.current = activeSessionId;
      }
    };

    // Run immediately (pre-paint) and also schedule a fallback rAF
    applyRestore();
    const r0 = requestAnimationFrame(applyRestore);

    // If content size/DOM changes after first paint, apply once
    const onDomChange = () => {
      if (restoredForRef.current !== activeSessionId) {
        requestAnimationFrame(applyRestore);
      }
    };

    const mo = new MutationObserver(onDomChange);
    mo.observe(div, { childList: true, subtree: true });

    const ro = new ResizeObserver(onDomChange);
    ro.observe(div);

    return () => {
      cancelAnimationFrame(r0);
      mo.disconnect();
      ro.disconnect();
    };
  }, [activeSessionId, activeSidebarMode, messages.length, scrollPositions]);

  // If there is no saved scroll and content arrives later (e.g., on first app load),
  // default to bottom exactly once for this open chat.
  useEffect(() => {
    if (activeSidebarMode !== 'chats' || !activeSessionId) return;
    if (restoredForRef.current === activeSessionId) return; // already applied

    const liveSaved = typeof scrollTopsRef.current[activeSessionId] === 'number'
      ? scrollTopsRef.current[activeSessionId]
      : undefined;
    const savedScrollTop = typeof liveSaved === 'number'
      ? liveSaved
      : scrollPositions[activeSessionId];

    // Only when there is no saved position and we now have content
    if (typeof savedScrollTop !== 'number' && messages.length > 0) {
      requestAnimationFrame(() => {
        const div = chatRef.current;
        if (!div) return;
        div.scrollTop = div.scrollHeight;
        restoredForRef.current = activeSessionId;
      });
    }
  }, [messages.length, activeSessionId, activeSidebarMode, scrollPositions]);

  // Session-aware scroll helpers
  const scrollToBottom = (behavior = 'smooth', sessionId = null) => {
    const chatDiv = chatRef.current;
    if (!chatDiv) return;
    const target = sessionId ?? activeSessionIdRef.current;
    if (activeSessionIdRef.current !== target) return;
    chatDiv.scrollTo({ top: chatDiv.scrollHeight, behavior });
    setUserScrolledUp(target, false);
  };

  const scrollMessageToTop = (msgId, behavior = 'auto', sessionId = null) => {
    const chatDiv = chatRef.current;
    if (!chatDiv) return;
    const target = sessionId ?? activeSessionIdRef.current;
    if (activeSessionIdRef.current !== target) return;
    const el = document.getElementById(msgId);
    if (el) {
      const top = Math.max(0, el.offsetTop - TOP_ALIGN_OFFSET);
      chatDiv.scrollTo({ top, behavior });
    }
  };

  // Handler for new message tip click
  const handleNewMsgTipClick = () => {
    const sid = activeSessionIdRef.current;
    const msgId = newMsgTip[sid];
    if (msgId) {
      scrollMessageToTop(msgId, 'smooth', sid);
      setNewMsgTip(prev => {
        const { [sid]: _omit, ...rest } = prev;
        return rest;
      });
    }
  };

  const handleChatDragEnter = (event) => {
    if (activeSidebarMode !== 'chats' || !hasFilePayload(event)) return
    event.preventDefault()
    imageDragDepthRef.current += 1
    if (selectedVisionModelSupportsVision && eventHasImageFiles(event)) {
      setIsChatDragActive(true)
    }
  }

  const handleChatDragOver = (event) => {
    if (activeSidebarMode !== 'chats' || !hasFilePayload(event)) return
    event.preventDefault()
    event.dataTransfer.dropEffect = selectedVisionModelSupportsVision ? 'copy' : 'none'
    if (selectedVisionModelSupportsVision && eventHasImageFiles(event) && !isChatDragActive) {
      setIsChatDragActive(true)
    }
  }

  const handleChatDragLeave = (event) => {
    if (activeSidebarMode !== 'chats' || !hasFilePayload(event)) return
    imageDragDepthRef.current = Math.max(0, imageDragDepthRef.current - 1)
    if (imageDragDepthRef.current === 0) {
      setIsChatDragActive(false)
    }
  }

  const handleChatDrop = async (event) => {
    if (activeSidebarMode !== 'chats' || !hasFilePayload(event)) return
    event.preventDefault()
    imageDragDepthRef.current = 0
    setIsChatDragActive(false)
    if (!selectedVisionModelSupportsVision) {
      return
    }
    await appendComposerImageFiles(event.dataTransfer?.files)
    textareaRef.current?.focus()
  }


async function sendMessage() {
  const trimmedInput = input.trim()
  if (isSending || (!trimmedInput && composerAttachments.length === 0) || !model) return
  if (composerAttachments.length > 0 && !selectedVisionModelSupportsVision) {
    window.alert('The selected vision model does not support image inputs.')
    return
  }

  let targetSessionId = activeSessionId
  let isNewChat = false
  if (!targetSessionId) {
    const newSession = await createNewChat()
    await new Promise(resolve => setTimeout(resolve, 200))
    targetSessionId = newSession.session_id
    isNewChat = true
  } else {
    const currentSession = chatSessions.find(s => s.session_id === targetSessionId)
    isNewChat = currentSession && currentSession.name === "New Chat" && currentSession.messages.length === 0
  }

  const outgoingAttachments = composerAttachments.map(({ id, ...attachment }) => ({ ...attachment }))
  const requestModel = resolveChatRequestModel(outgoingAttachments)
  const userMsg = {
    role: 'user',
    content: trimmedInput,
    attachments: outgoingAttachments,
    id: `msg-${Date.now()}-${Math.random()}`
  }
  justSentMessage.current = true
  lastSentSessionRef.current = targetSessionId
  setUserScrolledUp(targetSessionId, false)

  if (activeSessionIdRef.current === targetSessionId) {
    restoredForRef.current = activeSessionIdRef.current
  }

  flushSync(() => {
    setChatSessions(prevSessions =>
      prevSessions.map(session =>
        session.session_id === targetSessionId
          ? { ...session, messages: [...(session.messages || []), userMsg] }
          : session
      )
    )
    setInput('')
    setComposerAttachments([])
  })
  requestAnimationFrame(() => scrollToBottom('auto', targetSessionId))

  const requestController = beginCancelableRequest(targetSessionId)
  try {
    let historyForSearch = []
    if (userMsg.content) try {
      const existing = (chatSessions.find(s => s.session_id === targetSessionId)?.messages) || []
      const lastFew = existing.slice(-8).map(m => ({ role: m.role, content: m.content || '' }))
      historyForSearch = [...lastFew, { role: 'user', content: userMsg.content }]
    } catch {}

    let enrichedPrompt = userMsg.content || null
    let citationSources = []
    const contextBlocks = []

    const selectedLibrary = getChatLibraryForSession(targetSessionId)

    if (userMsg.content && selectedLibrary?.states?.is_indexed) {
      try {
        const localContext = await fetchLocalLibraryContext(selectedLibrary.slug, userMsg.content, requestController.signal)
        if (localContext.contextBlock) {
          contextBlocks.push(localContext.contextBlock)
        }
        if (Array.isArray(localContext.sources)) {
          citationSources.push(...localContext.sources)
        }
      } catch (error) {
        if (isAbortError(error)) throw error
        console.warn('local library enrichment failed', error)
      }
    }

    if (userMsg.content && webSearchEnabled) {
      try {
        const resp = await fetch(`${backendApiUrl}/websearch`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          signal: requestController.signal,
          body: JSON.stringify({
            prompt: userMsg.content,
            model,
            messages: historyForSearch,
            history_limit: 8,
            searx_url: searxUrl || null,
            engines: Array.isArray(searxEngines) ? searxEngines : null,
          })
        })
        const data = await resp.json()
        if (data && typeof data.context_block === 'string' && data.context_block.trim()) {
          contextBlocks.push(data.context_block.trim())
        }
        if (Array.isArray(data?.sources)) {
          citationSources.push(...data.sources)
        }
      } catch (error) {
        if (isAbortError(error)) throw error
        console.warn('web search enrichment failed', error)
      }
    }

    citationSources = [...new Set(citationSources)]
    if (userMsg.content && contextBlocks.length > 0) {
      enrichedPrompt = `${userMsg.content}\n\n${contextBlocks.join('\n\n')}`
    }

    if (streamOutput) {
      const assistantMsgId = `msg-${Date.now()}-${Math.random()}`
      let fullReply = ''
      const assistantMsg = { role: 'assistant', content: '', id: assistantMsgId, sources: citationSources }
      setChatSessions(prevSessions =>
        prevSessions.map(session =>
          session.session_id === targetSessionId
            ? { ...session, messages: [...(session.messages || []), assistantMsg] }
            : session
        )
      )

      try {
        const res = await fetch(`${backendApiUrl}/chat`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          signal: requestController.signal,
          body: JSON.stringify({
            session_id: targetSessionId,
            model: requestModel,
            message: userMsg.content,
            enriched_message: userMsg.content && contextBlocks.length > 0 ? enrichedPrompt : null,
            stream: true,
            sources: citationSources || [],
            attachments: outgoingAttachments,
          })
        })
        if (!res.ok) throw new Error(await readBackendErrorText(res))

        const reader = res.body?.getReader()
        if (!reader) throw new Error('Missing response body')

        const decoder = new TextDecoder()
        let pendingMarked = false

        while (true) {
          const { value, done } = await reader.read()
          if (done) {
            setAssistantMessageContent(targetSessionId, assistantMsgId, fullReply)

            if (activeSessionIdRef.current === targetSessionId) {
              if (!userScrolledUpRef.current[targetSessionId]) {
                requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', targetSessionId))
              } else {
                setNewMsgTip(prev => ({ ...prev, [targetSessionId]: assistantMsgId }))
              }
            } else {
              setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId }))
              setUnreadSessions(prev => [...new Set([...prev, targetSessionId])])
            }

            break
          }

          const chunk = decoder.decode(value, { stream: true })
          fullReply += chunk
          setAssistantMessageContent(targetSessionId, assistantMsgId, fullReply)

          if (activeSessionIdRef.current === targetSessionId && !userScrolledUpRef.current[targetSessionId]) {
            scrollToBottom('auto', targetSessionId)
          }
          if (activeSessionIdRef.current !== targetSessionId && !pendingMarked) {
            setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId }))
            pendingMarked = true
          }
        }
      } catch (error) {
        if (isAbortError(error)) {
          setAssistantMessageContent(targetSessionId, assistantMsgId, fullReply, { removeIfEmpty: true })
          return
        }

        console.error('Failed to send message:', error)
        setAssistantMessageContent(targetSessionId, assistantMsgId, 'Error: ' + getErrorText(error), { removeIfEmpty: true })
        return
      }
    } else {
      const res = await fetch(`${backendApiUrl}/chat`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        signal: requestController.signal,
        body: JSON.stringify({
          session_id: targetSessionId,
          model: requestModel,
          message: userMsg.content,
          enriched_message: userMsg.content && contextBlocks.length > 0 ? enrichedPrompt : null,
          stream: false,
          sources: citationSources || [],
          attachments: outgoingAttachments,
        })
      })
      if (!res.ok) throw new Error(await readBackendErrorText(res))

      const data = await res.json()
      const assistantMsgId = `msg-${Date.now()}`
      const assistantMsg = {
        role: 'assistant',
        content: data.reply,
        id: assistantMsgId,
        sources: citationSources
      }

      setChatSessions(prevSessions =>
        prevSessions.map(session =>
          session.session_id === targetSessionId
            ? { ...session, messages: [...(session.messages || []), assistantMsg] }
            : session
        )
      )

      if (assistantMsgId) {
        if (activeSessionIdRef.current === targetSessionId) {
          if (!userScrolledUpRef.current[targetSessionId]) {
            requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', targetSessionId))
          } else {
            setNewMsgTip(prev => ({ ...prev, [targetSessionId]: assistantMsgId }))
          }
        } else {
          setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId }))
        }
      }
    }

    if (activeSessionIdRef.current !== targetSessionId) {
      setUnreadSessions(prev => [...new Set([...prev, targetSessionId])])
    }

    if (isNewChat) {
      fetch(`${backendApiUrl}/generate-title`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          session_id: targetSessionId,
          message: userMsg.content || (outgoingAttachments.length > 0 ? 'Image attachment' : userMsg.content),
          model
        })
      })
      .then(r => r.json())
      .then(data => {
        const sanitizedTitle = sanitizeChatTitle(data.title)
        setChatSessions(prevSessions =>
          prevSessions.map(session =>
            session.session_id === targetSessionId ? { ...session, name: sanitizedTitle } : session
          )
        )
      })
    }
  } catch (error) {
    if (isAbortError(error)) {
      finishCancelableRequest(requestController)
      return
    }

    console.error('Failed to send message:', error)
    const errorMsg = { role: 'assistant', content: 'Error: ' + getErrorText(error), id: `msg-${Date.now()}-${Math.random()}` }
    setChatSessions(prevSessions =>
      prevSessions.map(session =>
        session.session_id === targetSessionId
          ? { ...session, messages: [...session.messages, errorMsg] }
          : session
      )
    )
  } finally {
    finishCancelableRequest(requestController)
  }
}

  

function toggleWebSearch() {
  setWebSearchEnabled(prev => !prev);
}

async function createNewChat() {
    const newSessionId = 'sess-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
    const res = await fetch(`${backendApiUrl}/sessions`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ session_id: newSessionId })
    });
    const newSession = await res.json();
    const sessionWithMessages = { ...newSession, name: sanitizeChatTitle(newSession.name), messages: [] };
    setChatSessions(prevSessions => [sessionWithMessages, ...prevSessions]);
    setActiveSessionId(newSession.session_id);
    textareaRef.current?.focus();
    return newSession;
  }

  function selectChat(sessionId) {
    setActiveSessionId(sessionId);
    // Clear unread dot immediately for this chat
    setUnreadSessions(prev => prev.filter(id => id !== sessionId));

    // If we had queued a guided scroll for this chat (from background replies), run it now, smoothly
    const pendingId = pendingScrollToLastUser[sessionId];
    if (pendingId) {
      // Defer until the chat content renders; restoration is gated by restoredForRef, so won't fight
      requestAnimationFrame(() => {
        let tries = 12; // ~200ms @ 60fps
        const attempt = () => {
          const chatDiv = chatRef.current;
          if (!chatDiv) return;

          let el = document.getElementById(pendingId);
          if (!el) {
            const sess = chatSessions.find(s => s.session_id === sessionId);
            if (sess && Array.isArray(sess.messages)) {
              for (let i = sess.messages.length - 1; i >= 0; i--) {
                const m = sess.messages[i];
                if (m.role === 'assistant' && m.id) { el = document.getElementById(m.id); break; }
              }
            }
          }

          if (el) {
            scrollMessageToTop(el.id, 'smooth', sessionId);
            setPendingScrollToLastUser(prev => {
              const { [sessionId]: _omit, ...rest } = prev;
              return rest;
            });
          } else if (tries-- > 0) {
            requestAnimationFrame(attempt);
          }
        };
        requestAnimationFrame(attempt);
      });
    }
  }

  function handleRename(sessionId, newName) {
    const sanitizedName = sanitizeChatTitle(newName)
    fetch(`${backendApiUrl}/sessions/${sessionId}/rename`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: sanitizedName })
    })
    .then(() => {
      setChatSessions(prevSessions =>
        prevSessions.map(session =>
          session.session_id === sessionId ? { ...session, name: sanitizedName } : session
        )
      );
      setEditingSessionId(null);
    });
  }

  function handleLibraryRename(slug, newName) {
    const name = (newName || '').trim()
    const library = libraries.find(item => item.slug === slug)
    if (!library) {
      setEditingLibrarySlug(null)
      return
    }
    if (!name || name === library.name) {
      setEditingLibrarySlug(null)
      return
    }

    fetch(`${backendApiUrl}/libraries/${slug}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name })
    })
    .then(() => {
      setLibraries(prevLibraries =>
        prevLibraries.map(item =>
          item.slug === slug ? { ...item, name } : item
        )
      )
      setEditingLibrarySlug(null)
    })
  }

  function handleDelete(sessionId) {
    fetch(`${backendApiUrl}/sessions/${sessionId}`, { method: 'DELETE' })
    .then(() => {
      const newSessions = chatSessions.filter(s => s.session_id !== sessionId);
      setChatSessions(newSessions);
      setChatLibraryBySession(prev => {
        const next = { ...(prev || {}) }
        delete next[sessionId]
        return next
      })
      if (activeSessionId === sessionId) {
        setActiveSessionId(newSessions.length > 0 ? newSessions[0].session_id : null);
      }
    });
  }

  function handleLibraryDelete(slug) {
    fetch(`${backendApiUrl}/libraries/${slug}`, { method: 'DELETE' })
    .then(async (response) => {
      if (!response.ok) {
        const detail = await response.text()
        throw new Error(detail || `HTTP ${response.status}`)
      }

      const nextLibraries = libraries.filter(library => library.slug !== slug)
      setLibraries(nextLibraries)
      setLibraryJobs(prevJobs => prevJobs.filter(job => job.slug !== slug))
      setEditingLibrarySlug(current => current === slug ? null : current)
      if (activeLibrarySlug === slug) {
        setActiveLibrarySlug(nextLibraries[0]?.slug || null)
      }
      removeLibraryFromChatSelections(slug)
    })
    .catch((error) => {
      console.error('Failed to delete library', error)
    })
  }

  // Auto-delete empty "New Chat" sessions
  useEffect(() => {
    const emptyNewChats = chatSessions.filter(
      s => s.name === "New Chat" && s.session_id !== activeSessionId && s.messages.length === 0
    );
    if (emptyNewChats.length > 0) {
      emptyNewChats.forEach(chat => {
        handleDelete(chat.session_id);
      });
    }
  }, [activeSessionId, chatSessions, backendApiUrl]);

  const handleChatFrameClick = (e) => {
    const selection = window.getSelection();
    if (selection.toString().length > 0) {
      return;
    }

    if (document.activeElement === textareaRef.current) {
      return;
    }

    if (e.target.closest('.msg')) {
      return;
    }

    textareaRef.current?.focus();
  };

  return (
    <div className="app" style={{ gridTemplateColumns: `${sidebarWidth}px 1fr` }}>
      <div className="sidebar">
        <div className="sidebar-header">
          <div
            className={`sidebar-tab ${activeSidebarMode === 'chats' ? 'active' : ''}`}
            onClick={() => handleSidebarClick('chats')}
          >
            Chats
          </div>
          <div
            className={`sidebar-tab ${activeSidebarMode === 'dbs' ? 'active' : ''}`}
            onClick={() => handleSidebarClick('dbs')}
          >
            DBs
          </div>
          <div
            className={`sidebar-tab ${activeSidebarMode === 'settings' ? 'active' : ''}`}
            onClick={() => handleSidebarClick('settings')}
          >
            Settings
          </div>
        </div>
        <div className="sidebar-content">
          {activeSidebarMode === 'chats' && (
            <div className="chat-list">
              {chatSessions.map(session => (
                <div
                  key={session.session_id}
                  className={`chat-item ${session.session_id === activeSessionId ? 'active' : ''}`}
                  onClick={() => handleSelectChat(session.session_id)}
                >
                  {editingSessionId === session.session_id ? (
                    <input
                      type="text"
                      className="rename-input"
                      defaultValue={session.name}
                      onBlur={() => setEditingSessionId(null)}
                      onKeyDown={(e) => {
                        if (e.key === 'Enter') {
                          handleRename(session.session_id, e.target.value);
                        } else if (e.key === 'Escape') {
                          setEditingSessionId(null);
                        }
                      }}
                      autoFocus
                    />
                  ) : (
                    <>
                      <span>{session.name}</span>
                      <div className="chat-item-buttons">
                        {unreadSessions.includes(session.session_id) && <div className="unread-dot"></div>}
                        <button className="icon-button" onClick={(e) => { e.stopPropagation(); setEditingSessionId(session.session_id); }}>
                          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-edit-2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg>
                        </button>
                        <button className="icon-button" onClick={(e) => { e.stopPropagation(); handleDelete(session.session_id); }}>
                          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
                        </button>
                      </div>
                    </>
                  )}
                </div>
              ))}
            </div>
          )}
          {activeSidebarMode === 'dbs' && (
            <div className="db-list">
              {libraries.length === 0 ? (
                <div className="empty-list-message">No databases yet.</div>
              ) : (
                libraries.map(library => (
                  <div
                    key={library.slug}
                    className={`chat-item ${library.slug === activeLibrarySlug ? 'active' : ''}`}
                    onClick={() => setActiveLibrarySlug(library.slug)}
                  >
                    {editingLibrarySlug === library.slug ? (
                      <input
                        type="text"
                        className="rename-input"
                        defaultValue={library.name}
                        onBlur={() => setEditingLibrarySlug(null)}
                        onKeyDown={(e) => {
                          if (e.key === 'Enter') {
                            handleLibraryRename(library.slug, e.target.value)
                          } else if (e.key === 'Escape') {
                            setEditingLibrarySlug(null)
                          }
                        }}
                        autoFocus
                      />
                    ) : (
                      <>
                        <span>{library.name}</span>
                        <div className="chat-item-buttons">
                          {chatLibrarySlug === library.slug && <div className="db-active-badge">Chat</div>}
                          {isLibrarySyncing(library.slug) && <div className="db-active-badge">Syncing</div>}
                          <button className="icon-button" onClick={(e) => { e.stopPropagation(); setEditingLibrarySlug(library.slug) }}>
                            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-edit-2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg>
                          </button>
                          <button className="icon-button" onClick={(e) => { e.stopPropagation(); handleLibraryDelete(library.slug) }}>
                            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="feather feather-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
                          </button>
                        </div>
                      </>
                    )}
                  </div>
                ))
              )}
            </div>
          )}
          {activeSidebarMode === 'settings' && (
            <div className="settings-list">
              <div
                className={`settings-item ${activeSettingsSubmenu === 'General' ? 'active' : ''}`}
                onClick={() => setActiveSettingsSubmenu('General')}
              >
                General
              </div>
              <div
                className={`settings-item ${activeSettingsSubmenu === 'AI Models' ? 'active' : ''}`}
                onClick={() => setActiveSettingsSubmenu('AI Models')}
              >
                AI Models
              </div>
              <div
                className={`settings-item ${activeSettingsSubmenu === 'Interface' ? 'active' : ''}`}
                onClick={() => setActiveSettingsSubmenu('Interface')}
              >
                Interface
              </div>
              <div
                className={`settings-item ${activeSettingsSubmenu === 'Backend' ? 'active' : ''}`}
                onClick={() => setActiveSettingsSubmenu('Backend')}
              >
                Backend
              </div>
              <div
                className={`settings-item ${activeSettingsSubmenu === 'Updates' ? 'active' : ''}`}
                onClick={() => setActiveSettingsSubmenu('Updates')}
              >
                Updates
              </div>
              <div
                className={`settings-item ${activeSettingsSubmenu === 'Advanced' ? 'active' : ''}`}
                onClick={() => setActiveSettingsSubmenu('Advanced')}
              >
                Advanced
              </div>
            </div>
          )}
        </div>
        {activeSidebarMode !== 'settings' && (
          <div className="sidebar-footer">
            {activeSidebarMode === 'chats' && (
              <button className="button new-chat-button" onClick={createNewChat}>New Chat</button>
            )}
            {activeSidebarMode === 'dbs' && (
              isCreatingLibrary ? (
                <div className="new-db-form">
                  <input
                    type="text"
                    className="rename-input"
                    value={newLibraryName}
                    onChange={(e) => setNewLibraryName(e.target.value)}
                    onKeyDown={(e) => {
                      if (e.key === 'Enter') {
                        createLibrary()
                      } else if (e.key === 'Escape') {
                        setIsCreatingLibrary(false)
                        setNewLibraryName('')
                        setLibraryCreateError('')
                      }
                    }}
                    placeholder="Database name"
                    autoFocus
                  />
                  {libraryCreateError && <div className="form-error">{libraryCreateError}</div>}
                  <div className="new-db-actions">
                    <button className="button new-db-button" onClick={() => createLibrary()}>Create</button>
                    <button
                      className="button ghost"
                      onClick={() => {
                        setIsCreatingLibrary(false)
                        setNewLibraryName('')
                        setLibraryCreateError('')
                      }}
                    >
                      Cancel
                    </button>
                  </div>
                </div>
              ) : (
                <button
                  className="button new-db-button"
                  onClick={() => {
                    setIsCreatingLibrary(true)
                    setLibraryCreateError('')
                  }}
                >
                  New Database
                </button>
              )
            )}
          </div>
        )}
        <div className="resizer" onMouseDown={startResizing}></div>
      </div>
      <div
        className={`main-content${activeSidebarMode === 'chats' && isChatDragActive ? ' main-content--drag-active' : ''}`}
        onDragEnter={handleChatDragEnter}
        onDragOver={handleChatDragOver}
        onDragLeave={handleChatDragLeave}
        onDrop={handleChatDrop}
      >
        {startupTaskMessage && (
          <div className="startup-task-banner" role="status" aria-live="polite">
            {startupTaskBusy && <div className="spinner startup-task-banner__spinner"></div>}
            <div className="startup-task-banner__text">{startupTaskMessage}</div>
          </div>
        )}
        {activeSidebarMode === 'chats' && (
          <>
            <div className="header header--chat">
              <div className="header-main">
                <strong className="header-title">
                  Chat - {activeChatSession?.name || 'New Chat'}
                </strong>
                {chatLibrary && (
                  <span className="header-subtle">
                    {`DB: ${chatLibrary.name}${chatLibraryStatusSuffix}`}
                  </span>
                )}
              </div>
              <div className="header-actions">
                <div className="model-picker" ref={chatModelPickerRef}>
                  <button
                    type="button"
                    className="model-picker-toggle"
                    onClick={() => setIsChatModelPickerOpen(prev => !prev)}
                    aria-haspopup="menu"
                    aria-expanded={isChatModelPickerOpen}
                    title={model ? `Current chat model: ${model}` : 'Select chat model'}
                    disabled={!model && chatModelPickerOptions.length === 0}
                  >
                    <span className="model-picker-label">
                      {model || (isLoadingModelCatalog ? 'Loading models…' : 'Select model')}
                    </span>
                    <span className="model-picker-caret" aria-hidden="true">
                      {isChatModelPickerOpen ? '▴' : '▾'}
                    </span>
                  </button>
                  {isChatModelPickerOpen && (
                    <div className="model-picker-menu" role="menu">
                      {chatModelPickerOptions.length === 0 ? (
                        <div className="model-picker-empty">
                          {isLoadingModelCatalog ? 'Loading models…' : 'No chat models available.'}
                        </div>
                      ) : (
                        chatModelPickerOptions.map(option => {
                          const selected = option.value === model
                          return (
                            <button
                              key={option.value}
                              type="button"
                              className={`model-picker-option${selected ? ' selected' : ''}`}
                              onClick={() => handleChatModelSelect(option.value)}
                            >
                              <span className="model-picker-option-label">{option.label}</span>
                              {selected && <span className="model-picker-status">Selected</span>}
                            </button>
                          )
                        })
                      )}
                    </div>
                  )}
                </div>
              </div>
            </div>

            <div
              key={activeSessionId}
              className={`chat${isChatDragActive ? ' chat--drag-active' : ''}`}
              ref={chatRef}
              onClick={handleChatFrameClick}
            >
              {messages.map((m, i) => {
                const isEditingThis = m.role === 'user' && editingMessageIndex === i;
                return (
                  <div
                    key={m.id || i}
                    id={m.id}
                    className={
                      'msg ' +
                      (m.role === 'user' ? 'user' : 'assistant') +
                      (isEditingThis ? ' editing' : '')
                    }
                  >
                    {m.role === 'assistant' ? (
                      <div className="assistant-message-wrapper">
                        <AssistantMessageContent content={m.content} streamOutput={streamOutput} sources={m.sources} />
                        {!isSending && (
                          <div className="message-options-bar assistant-options">
                            <button className="icon-button" title="Copy message" onClick={() => handleCopyMessage(m)}>
                              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
                            </button>
                            <button className="icon-button" title="Regenerate response" onClick={() => regenerateFromIndex(i)}>
                              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.3"></path></svg>
                            </button>
                          </div>
                        )}
                      </div>
                    ) : (
                      <div className="user-message-wrapper">
                        {isEditingThis ? (
                          <>
                            <ImageAttachmentStrip attachments={m.attachments} className="message-attachment-strip" />
                            <div className="msg-content msg-content--user editing">
                              <div className="user-edit-shadow" aria-hidden="true">
                                {editText}
                              </div>

                              <TextareaAutosize
                                className="edit-message-input edit-overlay"
                                value={editText}
                                onChange={(e) => setEditText(e.target.value)}
                                onBlur={cancelEditMessage}
                                onKeyDown={(e) => {
                                  if (e.key === 'Escape') { e.preventDefault(); cancelEditMessage(); }
                                  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitEditMessage(i); }
                                }}
                                autoFocus
                                minRows={1}
                              />
                            </div>
                          </>
                        ) : (
                          (() => {
                            const raw = m.content || '';
                            const attachments = Array.isArray(m.attachments) ? m.attachments : [];
                            const lines = raw.split(/\r\n|\r|\n/);
                            const needsCollapse = lines.length > 30;
                            const key = collapseKeyFor(m, i, activeSessionId);
                            const isCollapsed = needsCollapse ? (collapsedUserMsgs[key] ?? true) : false;
                            const displayText = isCollapsed ? lines.slice(0, 30).join('\n') + '\n…' : raw;
                            const hasText = Boolean(raw.trim());

                            return (
                              <>
                                <ImageAttachmentStrip attachments={attachments} className="message-attachment-strip" />
                                {hasText && <div className="msg-content msg-content--user">{displayText}</div>}
                                {hasText && needsCollapse && (
                                  <button
                                    className="user-msg-expand"
                                    onClick={() => toggleUserMsgCollapse(key)}
                                    aria-expanded={isCollapsed ? 'false' : 'true'}
                                  >
                                    {isCollapsed ? 'Show entire message' : 'Collapse'}
                                  </button>
                                )}
                              </>
                            );
                          })()
                        )}
                        {!isSending && !isEditingThis && (
                          <div className="message-options-bar user-options">
                            <button className="icon-button" title="Edit message" onClick={() => startEditMessage(i, m.content)}>
                              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
                            </button>
                            <button className="icon-button" title="Copy message" onClick={() => handleCopyMessage(m)}>
                              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
                            </button>
                          </div>
                        )}
                      </div>
                    )}
                  </div>
                );
              })}
            </div>

            {/* New message tip (active chat only) */}
            {newMsgTip[activeSessionId] && (
              <button
                className="new-msg-tip"
                onClick={handleNewMsgTipClick}
                title="Jump to the new message"
                aria-label="Jump to the new message"
              >
                New message<span style={{ marginLeft: 6 }}>↓</span>
              </button>
            )}

            <div className="footer">
              <div className={`footer-inner${isChatDragActive ? ' footer-inner--drag-active' : ''}`}>
                <input
                  ref={imageInputRef}
                  type="file"
                  accept="image/*"
                  multiple
                  className="composer-image-input"
                  onChange={handleComposerImageSelection}
                  tabIndex={-1}
                />
                <ImageAttachmentStrip
                  attachments={composerAttachments}
                  className="composer-attachment-strip"
                  removable
                  onRemove={removeComposerAttachment}
                />
                {(isRecordingAudio || isTranscribingAudio) && (
                  <div
                    className={
                      'composer-audio-status' +
                      (isRecordingAudio ? ' composer-audio-status--recording' : ' composer-audio-status--transcribing')
                    }
                    role="status"
                    aria-live="polite"
                  >
                    {isRecordingAudio ? (
                      <span className="composer-audio-status__dot" aria-hidden="true"></span>
                    ) : (
                      <div className="spinner composer-audio-status__spinner" aria-hidden="true"></div>
                    )}
                    <span>
                      {isRecordingAudio
                        ? `Listening ${formatRecordingDuration(audioRecordingMs)}`
                        : 'Transcribing audio…'}
                    </span>
                  </div>
                )}
                <div className="footer-content-wrapper">
                <TextareaAutosize
                  ref={textareaRef}
                  className="input"
                  value={input}
                  onChange={e => setInput(e.target.value)}
                  onKeyDown={e => {
                    if (e.key === 'Enter' && !e.shiftKey && !isRecordingAudio && !isTranscribingAudio) {
                      e.preventDefault();
                      sendMessage();
                    }
                  }}
                  placeholder="Ask any question..."
                  maxRows={13}
                />
                <div className="footer-tool-group" ref={dbPickerRef}>
                  <button
                    type="button"
                    className={"db-picker-toggle" + (chatLibrary ? " active" : "")}
                    onClick={() => {
                      if (!activeSessionId) return
                      setIsDbPickerOpen(prev => !prev)
                    }}
                    title={chatLibrary ? `Database: ${chatLibrary.name}${chatLibraryStatusSuffix}` : 'Select database for this chat'}
                    aria-haspopup="menu"
                    aria-expanded={isDbPickerOpen}
                    disabled={!activeSessionId}
                  >
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
                         stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
                         aria-hidden="true">
                      <ellipse cx="12" cy="5" rx="8" ry="3"/>
                      <path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/>
                      <path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>
                    </svg>
                  </button>
                  {isDbPickerOpen && (
                    <div className="db-picker-menu" role="menu">
                      <button
                        type="button"
                        className={"db-picker-option" + (!chatLibrarySlug ? " selected" : "")}
                        onClick={() => {
                          setChatLibraryForSession(activeSessionId, null)
                          setIsDbPickerOpen(false)
                        }}
                      >
                        <span>No database</span>
                        {!chatLibrarySlug && <span className="db-picker-status">Selected</span>}
                      </button>
                      {libraries.length === 0 ? (
                        <div className="db-picker-empty">No databases yet.</div>
                      ) : (
                        libraries.map(library => {
                          const selected = chatLibrarySlug === library.slug
                          const syncing = isLibrarySyncing(library.slug)
                          const status = !library.files?.length
                            ? 'Empty'
                            : library.states?.is_indexed
                              ? 'Ready'
                              : syncing
                                ? 'Syncing'
                                : 'Needs sync'

                          return (
                            <button
                              key={library.slug}
                              type="button"
                              className={"db-picker-option" + (selected ? " selected" : "")}
                              disabled={!library.files?.length}
                              onClick={() => {
                                setChatLibraryForSession(activeSessionId, library.slug)
                                setIsDbPickerOpen(false)
                              }}
                            >
                              <span>{library.name}</span>
                              <span className="db-picker-status">{selected ? 'Selected' : status}</span>
                            </button>
                          )
                        })
                      )}
                    </div>
                  )}
                </div>
                  {selectedVisionModelSupportsVision && (
                    <button
                      type="button"
                      className={"image-attach-toggle" + (composerAttachments.length > 0 ? " active" : "")}
                      onClick={openImagePicker}
                      title="Attach images"
                      aria-label="Attach images"
                    >
                      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
                           stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
                           aria-hidden="true">
                        <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
                        <circle cx="8.5" cy="8.5" r="1.5"/>
                        <path d="M21 15l-5-5L5 21"/>
                      </svg>
                    </button>
                  )}
                  {audioInputEnabled && (
                    <button
                      type="button"
                      className={
                        'audio-input-toggle' +
                        (isRecordingAudio || isTranscribingAudio ? ' active' : '') +
                        (isRecordingAudio ? ' recording' : '') +
                        (isTranscribingAudio ? ' transcribing' : '')
                      }
                      onClick={toggleAudioRecording}
                      title={
                        !audioInputRuntimeReady
                          ? (audioInputRuntimeMessage || 'Whisper is not available for audio input.')
                          : isRecordingAudio
                          ? 'Stop voice input'
                          : (isTranscribingAudio ? 'Transcribing audio' : 'Start voice input')
                      }
                      aria-label={
                        !audioInputRuntimeReady
                          ? (audioInputRuntimeMessage || 'Whisper is not available for audio input.')
                          : isRecordingAudio
                          ? 'Stop voice input'
                          : (isTranscribingAudio ? 'Transcribing audio' : 'Start voice input')
                      }
                      aria-pressed={isRecordingAudio}
                      disabled={!audioInputRuntimeReady || isTranscribingAudio || isSending}
                    >
                      {isTranscribingAudio ? (
                        <div className="spinner composer-audio-icon-spinner" aria-hidden="true"></div>
                      ) : (
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
                             stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
                             aria-hidden="true">
                          <path d="M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3z"/>
                          <path d="M19 10a7 7 0 0 1-14 0"/>
                          <line x1="12" y1="19" x2="12" y2="22"/>
                          <line x1="8" y1="22" x2="16" y2="22"/>
                        </svg>
                      )}
                    </button>
                  )}
                  <button
                    type="button"
                    className={"websearch-toggle" + (webSearchEnabled ? " active" : "")}
                    onClick={toggleWebSearch}
                    title="Toggle web search"
                    aria-pressed={webSearchEnabled}
                  >
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
                         stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
                         aria-hidden="true">
                      <circle cx="12" cy="12" r="10"/>
                      <line x1="2" y1="12" x2="22" y2="12"/>
                      <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
                    </svg>
                  </button>
                <button
                  className="button"
                  onClick={isSending ? cancelActiveRequest : sendMessage}
                  title={isSending ? 'Cancel generation' : 'Send'}
                  aria-label={isSending ? 'Cancel generation' : 'Send'}
                  disabled={!isSending && (isRecordingAudio || isTranscribingAudio)}
                >
                  {isSending ? <div className="spinner"></div> : 'Send'}
                </button>
                </div>
              </div>
            </div>
          </>
        )}
        {activeSidebarMode === 'dbs' && (
          <>
            <div className="header">
              <strong>{activeLibrary?.name || 'Databases'}</strong>
              {chatLibrary && (
                <span className="header-subtle">
                  {`Current chat DB: ${chatLibrary.name}${chatLibraryStatusSuffix}`}
                </span>
              )}
            </div>
            <LibraryManager
              apiBase={backendApiUrl}
              library={activeLibrary}
              jobs={libraryJobs}
              onRefresh={async () => {
                await refreshLibraries();
                await refreshLibraryJobs();
              }}
            />
          </>
        )}
        {activeSidebarMode === 'settings' && (
          <>
            <div className="header">
              <strong>{activeSettingsSubmenu} Settings</strong>
            </div>
            {activeSettingsSubmenu === 'General' && (
              <GeneralSettings
                panel="General"
                onAudioInputDeviceChange={setAudioInputDeviceId}
                onAudioInputLanguageChange={setAudioInputLanguage}
              />
            )}
            {activeSettingsSubmenu === 'AI Models' && (
              <GeneralSettings
                panel="AI Models"
                onModelChange={setModel}
                onVisionModelChange={setVisionModel}
                onTranscriptionModelChange={setTranscriptionModel}
                onLibrariesPurged={handleLibrariesPurged}
              />
            )}
            {activeSettingsSubmenu === 'Interface' && (
              <InterfaceSettings
                streamOutput={streamOutput}
                onStreamOutputChange={setStreamOutput}
              />
            )}
            {activeSettingsSubmenu === 'Backend' && (
              <WebsearchSettings
                onBackendApiUrlChange={setBackendApiUrl}
                searxUrl={searxUrl}
                setSearxUrl={setSearxUrl}
                engines={searxEngines}
                setEngines={(next) => setSearxEngines(normalizeWebsearchEngines(next))}
              />
            )}
            {activeSettingsSubmenu === 'Updates' && (
              <GeneralSettings panel="Updates" />
            )}
            {activeSettingsSubmenu === 'Advanced' && (
              <GeneralSettings panel="Advanced" onLibrariesPurged={handleLibrariesPurged} />
            )}
          </>
        )}
      </div>
    </div>
  )
}
