import { useEffect, useRef, useState, useMemo, useCallback, type ClipboardEvent as ReactClipboardEvent } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { m3Tween } from "@/lib/motion";
import { Square, ChevronDown, Mic, MicOff, ArrowUp, X, RotateCcw, AudioLines, Volume2, VolumeX, Activity, Plus } from "lucide-react";
import { SignalsHistoryDrawer } from "@/components/assistant/SignalsHistoryDrawer";
import { VoiceModeOverlay } from "@/components/assistant/VoiceModeOverlay";
import { Button } from "@/components/ui/button";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/contexts/AuthContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { useTradingAssistant } from "@/hooks/useTradingAssistant";
import { ChatErrorBoundary } from "@/components/ChatErrorBoundary";
import { AnimatedAssistantIcon } from "@/components/AnimatedAssistantIcon";
import { useAssistantLoading } from "@/contexts/AssistantLoadingContext";
import { AgentSuggestionChips } from "@/components/AgentSuggestionChips";
import { AdvancedVoiceWaveform } from "@/components/VoiceWaveform";

import { haptics } from "@/utils/haptics";
import { cn } from "@/lib/utils";
import { useVoiceSession } from "@/hooks/useVoiceSession";
import { useTextToSpeech } from "@/hooks/useTextToSpeech";
import { useElevenLabsTTS } from "@/hooks/useElevenLabsTTS";
import { useStickyAutoScroll } from "@/hooks/useStickyAutoScroll";
import { estimateNarrationDurationMs, getToolNarration, getPhaseNarration, looksLikeIntentionalSpeech, isGhostTranscript, isSystemNarration } from "@/utils/voiceMode";
import { toSpeechLocale, toVoiceLangPrefix, detectAgentLanguageFromText } from "@/utils/languageUtils";
import { getVoiceIdForPersona, getVoiceSettingsForPersona, getPersonaToneForBackend, type VoicePersona } from "@/utils/voicePreference";
import { useVoicePersonaSync } from "@/hooks/useVoicePersonaSync";
import type { AgentLanguage } from "@/contexts/LanguageContext";
import { toast } from "sonner";
import { playListeningChime, playThinkingTone, playResponsePing } from "@/utils/voiceSounds";
import { shouldDispatchTranscript, confirmDispatch, releaseDispatch, isEchoOfRecentDispatch, resetTranscriptDedup } from "@/lib/transcriptDedup";

// Extracted sub-components
import { WelcomeScreen } from "@/components/assistant/WelcomeScreen";
import { MessageBubble } from "@/components/assistant/MessageBubble";
import { SynxAuroraBackground } from "@/components/assistant/SynxAuroraBackground";

import { AgentTaskTimeline } from "@/components/assistant/AgentTaskTimeline";
import { RevealModeProvider } from "@/lib/revealMode";
import { AttachDialog } from "@/components/assistant/AttachDialog";
import { FileChip } from "@/components/assistant/FileChip";
import { useFileIngestion } from "@/hooks/useFileIngestion";
// AssistantHeader removed — its content is now portalled into the shell TopBar
import { MobileBrandCluster } from "@/components/assistant/MobileBrandCluster";
import { AgentSkeleton } from "@/components/AgentSkeleton";
import { BuildWorkspace } from "@/components/appbuilder/BuildWorkspace";
import { useAppBuilder } from "@/lib/appbuilder/useAppBuilder";
import { useAssistantMode } from "@/lib/appbuilder/useAssistantMode";
import { useAdminStatus } from "@/hooks/useAdminStatus";
import { createPortal } from "react-dom";
import { useIsMobile } from "@/hooks/use-mobile";
import { JumpToBottomPill } from "@/components/assistant/JumpToBottomPill";
import { InputModePills, type ChatMode } from "@/components/assistant/InputModePills";
import { ModelRosterPopover, VoiceLaunchButton } from "@/components/assistant/ModelRosterPopover";

import { CommandPalette, CommandIcons, type CommandPaletteAction } from "@/components/assistant/CommandPalette";
import type { ToolResult } from "@/hooks/useTradingAssistant";
import { buildHandoffBus } from "@/lib/appbuilder/handoffBus";
import { Ghost } from "lucide-react";
import { LoadingAnimation } from "@/components/LoadingAnimation";
import { useSwarmFocusRun } from "@/components/assistant/swarm/useSwarmFocusRun";
import { SwarmFocusPanel } from "@/components/assistant/swarm/SwarmFocusPanel";

const SWARM_TIER_KEY = "synx.swarm.tier.v1";
const FOCUS_CONCURRENCY_KEY = "synx.swarm.focus.concurrency.v1";
function readSwarmTier(): "off" | "swarm" | "super" {
  if (typeof window === "undefined") return "off";
  try {
    const v = window.localStorage.getItem(SWARM_TIER_KEY);
    if (v === "off" || v === "swarm" || v === "super") return v;
  } catch { /* ignore */ }
  return "off";
}
function readFocusConcurrency(): number {
  if (typeof window === "undefined") return 6;
  try {
    const v = Number(window.localStorage.getItem(FOCUS_CONCURRENCY_KEY) ?? "6");
    if (Number.isFinite(v) && v >= 3 && v <= 10) return Math.round(v);
  } catch { /* ignore */ }
  return 6;
}


export default function TradingAssistant() {
  const navigate = useNavigate();
  const isMobile = useIsMobile();
  const [topbarSlot, setTopbarSlot] = useState<HTMLElement | null>(null);
  const [topbarRightSlot, setTopbarRightSlot] = useState<HTMLElement | null>(null);
  useEffect(() => {
    const tick = () => {
      const c = document.getElementById("topbar-center-slot");
      const r = document.getElementById("topbar-right-slot");
      setTopbarSlot(c);
      setTopbarRightSlot(r);
      return !!c && !!r;
    };
    if (tick()) return;
    const id = window.setInterval(() => { if (tick()) window.clearInterval(id); }, 50);
    return () => window.clearInterval(id);
  }, []);
  const [signalsHistoryOpen, setSignalsHistoryOpen] = useState(false);
  const [searchParams] = useSearchParams();
  const [input, setInput] = useState("");
  const { session, isLoading: authLoadingState } = useAuth();
  const { language, setLanguage, t } = useLanguage();
  const [loading, setLoading] = useState(true);
  // 120 ms grace window: warm chunk + warm session mounts under this
  // threshold so the skeleton never flashes. Cold loads still see it.
  const [showLoadingSkeleton, setShowLoadingSkeleton] = useState(false);
  useEffect(() => {
    if (!loading && session) {
      setShowLoadingSkeleton(false);
      return;
    }
    const t = window.setTimeout(() => setShowLoadingSkeleton(true), 120);
    return () => window.clearTimeout(t);
  }, [loading, session]);
  // Credits removed — NIM is free for us; no per-message metering. Premium gate stays on the server.
  
  const [swarmTier, setSwarmTier] = useState<"off" | "swarm" | "super">(() => readSwarmTier());
  const swarmMode = swarmTier !== "off";
  // Inline toggle never auto-jumps to the 300-agent super tier — that's
  // destructive and must be an explicit click in the AssistantModePicker.
  // Cycles: off ↔ swarm (30-agent deep). Super requires explicit picker.
  const cycleSwarmTier = useCallback(() => {
    setSwarmTier((prev) => {
      const next: "off" | "swarm" | "super" = prev === "off" ? "swarm" : "off";
      try { window.localStorage.setItem(SWARM_TIER_KEY, next); } catch { /* ignore */ }
      return next;
    });
  }, []);

  // Bounded focus swarm (3–10 agents, server-side fan-out, separate from
  // the legacy 30/300 tiers). Independent toggle so existing flows are
  // untouched.
  const [focusConcurrency, setFocusConcurrencyState] = useState<number>(() => readFocusConcurrency());
  const setFocusConcurrency = useCallback((n: number) => {
    const clamped = Math.max(3, Math.min(10, Math.round(n)));
    setFocusConcurrencyState(clamped);
    try { window.localStorage.setItem(FOCUS_CONCURRENCY_KEY, String(clamped)); } catch { /* ignore */ }
  }, []);
  const focusCtl = useSwarmFocusRun();
  const focusActive = focusCtl.status !== "idle" || !!focusCtl.synthesis;
  const [voiceMode, setVoiceMode] = useState(false);
  // Auto-engage voice when the page is reached via /voice route (?voice=1)
  useEffect(() => {
    try {
      if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("voice") === "1") {
        setVoiceMode(true);
      }
    } catch { /* ignore */ }
    // run once on mount
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const { mode: rawAssistantMode, setMode: setAssistantMode } = useAssistantMode();
  const { isAdmin: isAdminUser } = useAdminStatus();
  // The AssistantModeProvider already coerces non-admins back to chat once
  // the admin status resolves. Trust that single source of truth so the
  // TopBar (which reads the same context) and the page body never disagree.
  const assistantMode = rawAssistantMode;
  const appBuilder = useAppBuilder();
  const [chatMode, setChatMode] = useState<ChatMode>("auto");
  // Artifacts are rendered inline inside MessageBubble — no side panel.
  // ⌘K command palette
  const [paletteOpen, setPaletteOpen] = useState(false);
  // File upload states
  const [showAttachDialog, setShowAttachDialog] = useState(false);
  const [attachedImages, setAttachedImages] = useState<string[]>([]);
  const { files: ingestedFiles, ingest: ingestFiles, remove: removeIngestedFile, clear: clearIngestedFiles, buildPromptBlock: buildFilePromptBlock, isIngesting: isIngestingFiles, statuses: ingestStatuses } = useFileIngestion();
  const [videoUrl, setVideoUrl] = useState("");
  const [isExtractingMedia, setIsExtractingMedia] = useState(false);
  
  const inputRef = useRef<HTMLTextAreaElement>(null);
  const { setIsAssistantLoading } = useAssistantLoading();
  
  const { messages, isLoading, loadingState, error, pendingToolResults, executingTools, swarmPhase, intentRoute, selectedModel, modelTimeline, conversationId, userId, authReady, sendMessage, cancelStream, clearMessages, newConversation, loadConversation, setUserDraft, setSwarmTier: syncSwarmTier, privateMode, setPrivateMode } = useTradingAssistant();
  const {
    containerRef: messagesContainerRef,
    bind: scrollBind,
    showJumpButton: showScrollButton,
    scrollToBottom,
    followToBottom,
    repin,
    unpin,
    cancelPendingScroll,
  } = useStickyAutoScroll({ bottomThreshold: 96, jumpThreshold: 128 });

  // Mirror the UI swarm tier into the assistant hook so every sendMessage
  // includes the current tier in its request body. Without this, toggling
  // Super Swarm in the header would change the icon but the chat backend
  // would never know the user wanted the 300-agent path.
  useEffect(() => {
    syncSwarmTier(swarmTier);
  }, [swarmTier, syncSwarmTier]);

  const handlePaste = useCallback(async (e: ReactClipboardEvent<HTMLTextAreaElement>) => {
    const files = Array.from(e.clipboardData?.files ?? []);
    if (!files.length) return;
    e.preventDefault();
    haptics.light();
    const pastedImages = files.filter((f) => f.type.startsWith("image/"));
    if (pastedImages.length) {
      const dataUrls = await Promise.all(pastedImages.map((file) => new Promise<string>((resolve) => {
        const reader = new FileReader();
        reader.onload = () => resolve(String(reader.result || ""));
        reader.onerror = () => resolve("");
        reader.readAsDataURL(file);
      })));
      setAttachedImages((prev) => [...prev, ...dataUrls.filter(Boolean)].slice(0, 5));
    }
    await ingestFiles(files);
  }, [ingestFiles]);

  // Build-mode handoff: watch new assistant tool results for `start_app_build`
  // / `open_build_workspace` envelopes. When the agent decides to build,
  // switch the workspace into Build mode + persist the prompt + navigate.
  const lastHandoffKeyRef = useRef<string | null>(null);
  useEffect(() => {
    const lastMsg = [...messages].reverse().find(m => m.role === "assistant" && m.toolResults?.length);
    const tr = lastMsg?.toolResults ?? [];
    for (const t of tr) {
      const r = t?.result?.result as { handoff?: string; action?: string; project_id?: string | null; prompt?: string; app_name?: string | null; template?: string | null; connectors?: string[]; native?: boolean; job_id?: string; title?: string; tracking_path?: string } | undefined;
      if (!r || r.handoff !== "build") continue;
      const key = `${lastMsg?.id ?? ""}::${t.name}::${r.prompt ?? r.project_id ?? r.action ?? r.job_id ?? ""}`;
      if (lastHandoffKeyRef.current === key) continue;
      lastHandoffKeyRef.current = key;

      if (t.name === "open_build_workspace") {
        setAssistantMode("build");
        if (r.project_id) navigate(`/builder/apps/${r.project_id}`);
        else navigate("/");
        toast.success("Switching to Build mode");
        break;
      }
      if (t.name === "synx_swe" && r.job_id) {
        setAssistantMode("build");
        // tracking_path already encodes ?project=…&panel=async — fall back
        // gracefully if missing.
        const path = r.tracking_path ?? (r.project_id ? `/builder/apps/${r.project_id}?panel=async` : "/");
        navigate(path);
        toast.success("Devin-grade SWE run queued", { description: r.title ?? "Open AsyncRuns to watch progress" });
        break;
      }
      if (t.name === "start_app_build" && r.prompt) {
        buildHandoffBus.set({
          prompt: r.prompt,
          app_name: r.app_name ?? undefined,
          template: (r.template as "blank" | "saas" | "landing" | "mobile" | "dashboard" | null) ?? undefined,
          connectors: r.connectors ?? [],
          native: !!r.native,
        });
        setAssistantMode("build");
        navigate("/");
        toast.success("Switching to Build mode — kicking off your app", { description: r.app_name ?? r.prompt.slice(0, 60) });
        break;
      }
    }
  }, [messages, navigate, setAssistantMode]);

  // Idle-preload the dashboard chunk — most users hit /dashboard right after
  // the chat. This runs once when the browser is idle, so it never competes
  // with the chat's first paint.
  useEffect(() => {
    const w = window as unknown as { requestIdleCallback?: (cb: () => void) => number };
    const ric = w.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1500));
    const id = ric(() => { void import("./Index"); });
    return () => {
      if (typeof id === "number" && "cancelIdleCallback" in window) {
        (window as unknown as { cancelIdleCallback: (id: number) => void }).cancelIdleCallback(id);
      }
    };
  }, []);
  const activeExecutingTool = executingTools?.[executingTools.length - 1] ?? null;

  // Handle ?new=<token> query param. The TopBar "+ New" button navigates
  // to `/?new=${Date.now()}` so each press is a fresh token — that way the
  // effect fires every time, even when the user is already on `/`.
  const lastHandledNewTokenRef = useRef<string | null>(null);
  useEffect(() => {
    const token = searchParams.get('new');
    if (!token || !authReady) return;
    if (lastHandledNewTokenRef.current === token) return;
    lastHandledNewTokenRef.current = token;
    newConversation();
    // Focus the composer after the fade-in starts so iOS keeps the user
    // gesture chain intact (no keyboard pop unless the user typed).
    requestAnimationFrame(() => { inputRef.current?.focus({ preventScroll: true }); });
    const url = new URL(window.location.href);
    url.searchParams.delete('new');
    window.history.replaceState(null, '', url.pathname + url.search);
  }, [searchParams, authReady, newConversation]);

  // Handle ?conversation=<id> deep link — opened from sidebar history list.
  // Loads the requested conversation's messages and strips the param so
  // subsequent "+ New" presses don't accidentally re-open this thread.
  const lastHandledConversationIdRef = useRef<string | null>(null);
  useEffect(() => {
    const id = searchParams.get('conversation');
    if (!id || !authReady) return;
    if (lastHandledConversationIdRef.current === id) return;
    lastHandledConversationIdRef.current = id;
    if (id !== conversationId) {
      loadConversation(id);
    }
    const url = new URL(window.location.href);
    url.searchParams.delete('conversation');
    window.history.replaceState(null, '', url.pathname + url.search);
  }, [searchParams, authReady, conversationId, loadConversation]);

  // Handle ?q=<prompt> deep-link from Pro tool detail "Use example" buttons.
  // Prefills the chat input so the user can review/edit before sending.
  const handledQueryParamRef = useRef(false);
  useEffect(() => {
    if (handledQueryParamRef.current) return;
    const q = searchParams.get('q');
    if (q && authReady) {
      handledQueryParamRef.current = true;
      setInput(q);
      const url = new URL(window.location.href);
      url.searchParams.delete('q');
      window.history.replaceState(null, '', url.pathname + url.search);
    }
  }, [searchParams, authReady]);

  // Determine the last tool used for contextual suggestions
  const lastToolUsed = useMemo(() => {
    for (let i = messages.length - 1; i >= 0; i--) {
      const msg = messages[i];
      if (msg.toolResults && msg.toolResults.length > 0) {
        return msg.toolResults[msg.toolResults.length - 1].name;
      }
    }
    return undefined;
  }, [messages]);

  // Composer/welcome dock width — ChatGPT/Claude-style centered column. Stays
  // readable on tablets and large desktops instead of stretching edge-to-edge
  // and leaving the send button hugging the viewport border.
  const shellWidthClass =
    "mx-auto w-full max-w-3xl md:max-w-2xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-4xl";


  const handleSuggestionSelect = useCallback((prompt: string, label: string) => {
    haptics.light();
    sendMessage(prompt, undefined, label);
  }, [sendMessage]);

  // Listen for image-gen retry events from ImageGenCard error state.
  useEffect(() => {
    const handler = (e: Event) => {
      const ce = e as CustomEvent<{ prompt?: string; rephrase?: boolean }>;
      const prompt = ce.detail?.prompt?.trim();
      if (!prompt) return;
      const text = ce.detail?.rephrase
        ? `Generate an image of: ${prompt} (avoid copyrighted artworks, brands, or real people — describe the visual style and subject in plain language)`
        : `Generate an image of: ${prompt}`;
      sendMessage(text);
    };
    window.addEventListener("synx:image-gen-retry", handler as EventListener);
    return () => window.removeEventListener("synx:image-gen-retry", handler as EventListener);
  }, [sendMessage]);

  // Track last heard transcript for voice mode feedback
  const [lastHeardText, setLastHeardText] = useState('');
  const lastAckTimeRef = useRef(0);
  const [recognitionSuppressedUntil, setRecognitionSuppressedUntil] = useState(0);
  const suppressRecognitionDuringNarration = useCallback((text: string) => {
    setRecognitionSuppressedUntil((current) => Math.max(current, Date.now() + estimateNarrationDurationMs(text) + 300));
  }, []);

  // Voice persona (male/female) — extracted to a dedicated hook
  const { voicePersona, handleVoicePersonaChange } = useVoicePersonaSync();

  // ElevenLabs TTS for voice mode (premium voice) — declared BEFORE useVoiceSession so callbacks can reference it
  const {
    isSpeaking: isELSpeaking, speak: elSpeak, stop: elStop, currentText: elCurrentText, muted: ttsMuted, setMuted: setTTSMuted, primeAudio, prewarm: elPrewarm,
    beginStream: elBeginStream, pushStream: elPushStream, endStream: elEndStream,
  } = useElevenLabsTTS({
    language,
    voiceId: getVoiceIdForPersona(voicePersona),
    voiceSettings: getVoiceSettingsForPersona(voicePersona),
    onStart: () => {
      console.log('[Voice] ElevenLabs TTS started playing');
      playResponsePing();
    },
    onEnd: () => {
      console.log('[Voice] ElevenLabs TTS finished, resuming listening');
      // Voice mode is owned by useVoiceSession; do not re-open the legacy
      // Web Speech mic here or it races the Scribe stream and steals audio.
    },
  });

  // Browser TTS fallback (non-voice-mode, or if ElevenLabs fails)
  const { 
    isSpeaking: isBrowserSpeaking, speak: browserSpeak, stop: browserStop, currentSentence
  } = useTextToSpeech({ rate: 1.05, voiceLang: toVoiceLangPrefix(language) });

  // Unified speaking state
  const isSpeaking = isELSpeaking || isBrowserSpeaking;
  const stopSpeaking = useCallback(() => { elStop(); browserStop(); window.speechSynthesis?.cancel(); }, [elStop, browserStop]);

  // Use refs for values needed in callbacks to avoid stale closures
  const ttsMutedRef = useRef(ttsMuted);
  const isSpeakingRef = useRef(isSpeaking);
  const stopSpeakingRef = useRef(stopSpeaking);
  const isLoadingRef = useRef(isLoading);
  const cancelStreamRef = useRef(cancelStream);
  useEffect(() => { ttsMutedRef.current = ttsMuted; }, [ttsMuted]);
  useEffect(() => { isSpeakingRef.current = isSpeaking; }, [isSpeaking]);
  useEffect(() => { stopSpeakingRef.current = stopSpeaking; }, [stopSpeaking]);
  useEffect(() => { isLoadingRef.current = isLoading; }, [isLoading]);
  useEffect(() => { cancelStreamRef.current = cancelStream; }, [cancelStream]);

  // ===== Manual chat-mode TTS + barge-in =====
  // When the user taps the 🔊 button on an assistant bubble (outside of
  // voiceMode), we speak the message via ElevenLabs AND open the mic in
  // the background so they can interrupt by talking — full-duplex, just
  // like voiceMode but scoped to a single playback. The captured speech
  // is sent as the next user message automatically.
  const [manualSpeakingMessageId, setManualSpeakingMessageId] = useState<string | null>(null);
  const [manualPreparingMessageId, setManualPreparingMessageId] = useState<string | null>(null);
  const manualSpeakingMessageIdRef = useRef<string | null>(null);
  useEffect(() => { manualSpeakingMessageIdRef.current = manualSpeakingMessageId; }, [manualSpeakingMessageId]);

  // Track ELSpeaking in a ref so long-lived effects/timers don't capture stale state.
  // Declared up here (instead of further down) because the manual-TTS cleanup
  // effect below references it — `const` doesn't hoist, so a later declaration
  // would throw a ReferenceError when this effect runs.
  const isELSpeakingRef = useRef(isELSpeaking);
  useEffect(() => { isELSpeakingRef.current = isELSpeaking; }, [isELSpeaking]);


  const handleSpeakMessage = useCallback((msg: { id?: string; content: string }) => {
    if (!msg?.content) return;
    // Streaming/replayed assistant messages may not have a server-assigned id
    // yet. Fall back to a content-derived key so the speaker button still
    // works (and the per-message active/stop state still tracks correctly).
    const trackingId = msg.id || `__ephemeral__:${msg.content.slice(0, 64)}:${msg.content.length}`;
    // If the SAME message is already speaking, the action becomes "stop".
    if (manualSpeakingMessageIdRef.current === trackingId) {
      try { elStop(); } catch {}
      setManualSpeakingMessageId(null);
      setManualPreparingMessageId(null);
      return;
    }
    // If a different message is speaking, swap.
    try { elStop(); } catch {}
    primeAudio();
    setManualPreparingMessageId(trackingId);
    setManualSpeakingMessageId(trackingId);
    elSpeak(msg.content);
  }, [elSpeak, elStop, primeAudio]);

  const handleStopManualSpeak = useCallback(() => {
    try { elStop(); } catch {}
    setManualSpeakingMessageId(null);
    setManualPreparingMessageId(null);
  }, [elStop]);

  // When ElevenLabs finishes speaking naturally (or is stopped from
  // anywhere), clear the per-message highlight + tear down barge-in.
  useEffect(() => {
    if (manualSpeakingMessageId && !isELSpeaking) {
      const t = setTimeout(() => {
        if (!isELSpeakingRef.current) {
          setManualSpeakingMessageId(null);
          setManualPreparingMessageId(null);
        }
      }, 120);
      return () => clearTimeout(t);
    }
    if (manualPreparingMessageId && isELSpeaking) {
      // Audio has begun — drop the "preparing" flag so the button shows
      // the active Stop state instead of the disabled spinner state.
      setManualPreparingMessageId(null);
    }
  }, [isELSpeaking, manualSpeakingMessageId, manualPreparingMessageId]);

  // Note: chat-mode TTS (per-bubble 🔊 button) no longer opens its own mic.
  // Voice-mode is the only place full-duplex barge-in lives now (via
  // `useVoiceSession`). Users can still tap the 🔊 button again to stop
  // playback, but mid-sentence interrupting requires entering voice mode.

  // ===== Single source of truth for "user interrupted the assistant" =====
  //
  // PRINCIPLE: in voice mode, only `useVoiceSession` decides when a barge-in
  // happens. Its VAD watches the SAME analyser that drives the orb, so it
  // sees the user's voice the instant TTS gets clipped. When that fires,
  // the session calls `tts.stop()` ITSELF and then invokes `onBargeIn`,
  // which is the ONLY place that triggers `cancelAndNotify` from the page.
  //
  // Page-level handlers (`handleVoiceFinal`, `handleVoicePartial`, mic-press,
  // PTT, Esc, the stop button) MUST NOT call `stopSpeakingRef.current()` or
  // `cancelAndNotify()` directly while in voice mode — doing so:
  //   1) clips TTS that the session would have allowed to continue, and
  //   2) racing with the session's own stop can re-cancel a *different*
  //      utterance if the user's chunk-streamer started a new sentence
  //      between the events.
  //
  // For explicit user gestures (mic tap, Esc, PTT) we go through
  // `voice.interrupt()` so the session is the single owner of the cut.
  //
  // Debounced "interrupted" toast — fired by `cancelAndNotify` only.
  // The window matches the barge-debounce so a single utterance can never
  // emit two toasts even if `onBargeIn` somehow fires twice.
  const lastInterruptToastRef = useRef(0);
  const TOAST_DEBOUNCE_MS = 1500;
  const notifyInterrupt = useCallback(() => {
    const now = Date.now();
    if (now - lastInterruptToastRef.current < TOAST_DEBOUNCE_MS) return;
    lastInterruptToastRef.current = now;
    toast("Interrupted", {
      description: "Stream cancelled — go ahead.",
      duration: 1800,
    });
  }, []);

  // ===== Barge-in debouncer =====
  // Rapid voiced frames (energy detector + STT partials + STT finals) can all fire
  // within a few hundred ms of each other. Without coalescing, each one would:
  //   1) call cancelStream() again (no-op but noisy), and
  //   2) call sendMessage() — spawning OVERLAPPING replies for what the user
  //      perceives as a single utterance.
  // We use a 300ms window: the FIRST barge-in cancels the active stream, and any
  // subsequent sendMessage within that window is coalesced into a single trailing
  // dispatch using the LATEST transcript (so we still get the most complete text).
  const BARGE_DEBOUNCE_MS = 300;
  const CANCELLING_INDICATOR_MS = 600;
  const lastBargeCancelAtRef = useRef(0);
  const pendingSendTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const pendingSendPayloadRef = useRef<{ text: string; isVoiceMode: boolean; voicePersonaTone: string } | null>(null);
  const cancellingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  // ===== Voice reply watchdog =====
  // After STT commits a final transcript we expect ONE of these to happen
  // quickly: either `isLoading` flips true (request reached the assistant) or,
  // shortly after, an assistant token actually streams in. If neither happens
  // within VOICE_REPLY_TIMEOUT_MS, the request was lost (cold-start drop, WS
  // tear-down, edge 5xx that never reached the retry loop, etc.) — so we do a
  // single bounded recovery: stop+restart Scribe (one reconnect), reset the
  // draft/in-flight state, and replay the last committed transcript exactly
  // once. `replayedRef` guarantees replay-safety: each commit can be replayed
  // at most once, no infinite loops on a truly broken backend.
  const VOICE_REPLY_TIMEOUT_MS = 15000;       // commit → must see isLoading=true
  const VOICE_FIRST_TOKEN_TIMEOUT_MS = 22000; // isLoading=true → must see tokens
  const voiceWatchdogRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const lastCommittedTextRef = useRef<string | null>(null);
  const lastCommittedAtRef = useRef<number>(0);
  const watchdogPhaseRef = useRef<'await-loading' | 'await-tokens' | 'idle'>('idle');
  const replayedRef = useRef<Set<string>>(new Set());
  // One-shot reconnect lock per turn: even if both watchdog phases fire, the
  // STT WebSocket is torn down + restored at most ONCE per committed transcript.
  const reconnectInFlightRef = useRef<string | null>(null);

  // Cross-reload replay marker. Persisting the "already replayed once" flag in
  // sessionStorage means a hard refresh during the retry window still prevents
  // a second reconnect/replay for the same transcript.
  const REPLAY_MARK_PREFIX = 'voice-replayed:';
  const REPLAY_MARK_TTL_MS = 90_000;
  const replayMarkKey = useCallback((text: string) => {
    // Include conversation id so different conversations don't collide.
    const cid = conversationId || 'guest';
    let h = 2166136261 >>> 0;
    for (let i = 0; i < text.length; i++) {
      h ^= text.charCodeAt(i);
      h = Math.imul(h, 16777619) >>> 0;
    }
    return `${REPLAY_MARK_PREFIX}${cid}:${h.toString(36)}`;
  }, [conversationId]);
  const isAlreadyReplayed = useCallback((text: string): boolean => {
    if (replayedRef.current.has(text)) return true;
    try {
      const raw = sessionStorage.getItem(replayMarkKey(text));
      if (!raw) return false;
      const ts = Number(raw);
      if (!Number.isFinite(ts) || Date.now() - ts > REPLAY_MARK_TTL_MS) {
        sessionStorage.removeItem(replayMarkKey(text));
        return false;
      }
      // Re-hydrate the in-memory set so later checks short-circuit fast.
      replayedRef.current.add(text);
      return true;
    } catch {
      return false;
    }
  }, [replayMarkKey]);
  const markReplayed = useCallback((text: string) => {
    replayedRef.current.add(text);
    if (replayedRef.current.size > 50) {
      const first = replayedRef.current.values().next().value;
      if (first) replayedRef.current.delete(first);
    }
    try { sessionStorage.setItem(replayMarkKey(text), String(Date.now())); } catch { /* quota */ }
  }, [replayMarkKey]);

  const clearVoiceWatchdog = useCallback(() => {
    if (voiceWatchdogRef.current) {
      clearTimeout(voiceWatchdogRef.current);
      voiceWatchdogRef.current = null;
    }
    watchdogPhaseRef.current = 'idle';
  }, []);
  // Forward-declared ref so `handleVoiceFinal` (defined before `elStt`) can
  // call into the recovery routine wired up in a useEffect after `elStt` is
  // initialised. Filled in by the effect below.
  const triggerVoiceRecoveryRef = useRef<((reason: 'no-loading' | 'no-tokens') => void) | null>(null);

  // Visible "Cancelling…" indicator — shown briefly after a barge-in so the user
  // sees the assistant is switching to their new question immediately.
  const [cancelling, setCancelling] = useState(false);

  const cancelAndNotify = useCallback(() => {
    const now = Date.now();
    // Within debounce window? Already cancelled — skip duplicate cancel + toast.
    if (now - lastBargeCancelAtRef.current < BARGE_DEBOUNCE_MS) return;
    if (!isLoadingRef.current) return;
    lastBargeCancelAtRef.current = now;
    cancelStreamRef.current();
    notifyInterrupt();
    // Show the "Cancelling…" pill for ~600ms (auto-clears).
    setCancelling(true);
    if (cancellingTimerRef.current) clearTimeout(cancellingTimerRef.current);
    cancellingTimerRef.current = setTimeout(() => {
      setCancelling(false);
      cancellingTimerRef.current = null;
    }, CANCELLING_INDICATOR_MS);
  }, [notifyInterrupt]);

  // Tracks the most recent text we actually dispatched to the LLM (via partial
  // pause-detector OR final). Used by the partial-driven barge-in guard so we
  // don't abort the in-flight stream just because Scribe keeps refining the
  // SAME utterance that triggered it.
  const lastDispatchedTextRef = useRef<string>("");
  const lastDispatchedAtRef = useRef<number>(0);
  // Last accepted camera captureId — drops duplicate fires from the snap callback.
  const lastCameraCaptureIdRef = useRef<string | null>(null);
  // Holds the most recent CapturedFrame metadata so the *next* spoken utterance
  // in the same voice turn can be linked back to the exact frame the user was
  // looking at. Cleared once consumed or when stale (>30s old).
  const pendingCaptureRef = useRef<{
    captureId: string;
    capturedAt: number;
    turnId: number;
    sequenceInTurn: number;
    width?: number;
    height?: number;
  } | null>(null);
  const PENDING_CAPTURE_TTL_MS = 30_000;

  // Reads + clears the pending capture if it's still fresh enough to be
  // considered part of the current voice turn. Returns undefined otherwise.
  const consumePendingCapture = useCallback(() => {
    const c = pendingCaptureRef.current;
    if (!c) return undefined;
    if (Date.now() - c.capturedAt > PENDING_CAPTURE_TTL_MS) {
      pendingCaptureRef.current = null;
      return undefined;
    }
    pendingCaptureRef.current = null;
    return c;
  }, []);

  // Coalesces sendMessage calls within BARGE_DEBOUNCE_MS into a single trailing
  // dispatch. Ensures one user utterance → one assistant reply.
  //
  // STRICT DE-DUP: every call passes through `shouldDispatchTranscript()` —
  // the single canonical gate shared by ALL transcript sources (Scribe final,
  // Web Speech final, watchdog replay, mic-button submit). Duplicate or
  // near-duplicate transcripts (refinements, echoes, late finals from the
  // other source) are dropped here so two sources can NEVER produce two
  // overlapping replies for the same utterance.
  const pendingSendTokenRef = useRef<string | null>(null);
  const queueDebouncedSend = useCallback((text: string, opts: { isVoiceMode: boolean; voicePersonaTone: string; source?: string }) => {
    const decision = shouldDispatchTranscript(text, opts.source ?? (opts.isVoiceMode ? "voice-final" : "text-submit"));
    if (!decision.ok) {
      // Already dispatched (or in-flight) — drop silently.
      return;
    }
    // A newer accepted text supersedes the still-pending one: release the
    // old token so its dedup slot doesn't block legitimate retries.
    if (pendingSendTokenRef.current && pendingSendTokenRef.current !== decision.token) {
      releaseDispatch(pendingSendTokenRef.current);
    }
    pendingSendTokenRef.current = decision.token ?? null;
    pendingSendPayloadRef.current = { text, ...opts };
    if (pendingSendTimerRef.current) clearTimeout(pendingSendTimerRef.current);
    pendingSendTimerRef.current = setTimeout(() => {
      const payload = pendingSendPayloadRef.current;
      const token = pendingSendTokenRef.current;
      pendingSendPayloadRef.current = null;
      pendingSendTimerRef.current = null;
      pendingSendTokenRef.current = null;
      if (!payload) {
        // Payload was cleared (e.g. by a barge-in cancel) — release the dedup
        // slot so the user can re-utter the same text.
        releaseDispatch(token ?? undefined);
        return;
      }
      lastDispatchedTextRef.current = payload.text;
      lastDispatchedAtRef.current = Date.now();
      const capture = consumePendingCapture();
      // Anchor the dedup window to the actual dispatch moment so late echoes
      // from the second STT source are still suppressed for the full window.
      confirmDispatch(token ?? undefined);
      sendMessage(payload.text, undefined, undefined, {
        isVoiceMode: payload.isVoiceMode,
        voicePersonaTone: payload.voicePersonaTone,
        // Bind this voice turn to the most recent captured frame (if any) so
        // the agent knows the user's transcript refers to *that exact image*.
        transcriptText: payload.text,
        capture,
      });
    }, BARGE_DEBOUNCE_MS);
  }, [sendMessage, consumePendingCapture]);

  // Flush any pending send when component unmounts to avoid lost messages.
  useEffect(() => () => {
    if (pendingSendTimerRef.current) clearTimeout(pendingSendTimerRef.current);
    if (cancellingTimerRef.current) clearTimeout(cancellingTimerRef.current);
  }, []);

  // Reset the strict transcript-dedup state whenever the user switches
  // conversation. The dedup is a tab-singleton (since it must span two STT
  // sources that don't share React state), so we drop its memory on
  // conversation change to avoid blocking a legitimate identical first
  // message in the new conversation.
  useEffect(() => {
    resetTranscriptDedup();
  }, [conversationId]);

  // Shared voice-result handlers — used by BOTH the Web Speech path and the
  // ElevenLabs realtime STT path. Stable refs so we can pass them to either hook.
  //
  // ----- Live-update contract (this is what stops the LLM from restarting) -----
  // • PARTIAL transcripts → update the in-flight user-message bubble in real
  //   time via `setUserDraft(text)`. NO `sendMessage` is called. The LLM is
  //   never started or re-started for partials.
  // • FINAL / committed transcripts → clear the draft and call
  //   `queueDebouncedSend(text)` exactly ONCE with the latest stable text.
  //   This is the single moment the LLM run is kicked off.
  // • Barge-in cancel still happens on the very first partial that proves the
  //   user is talking, so the assistant stops speaking immediately — but no
  //   new LLM run is queued until the final fires.
  const handleVoiceFinal = useCallback((text: string) => {
    if (voiceMode) {
      // NOTE: we deliberately do NOT call `stopSpeakingRef.current()` or
      // `cancelAndNotify()` here. By the time a final transcript arrives the
      // voice session has ALREADY done both via its VAD `onBargeIn` path
      // (the same VAD that fed Scribe to produce this final). Calling them
      // again would clip a new TTS chunk that started for the next reply,
      // and would risk a duplicate "Interrupted" toast within the window.
      setLastHeardText(text);
      haptics.success();
      if (!ttsMutedRef.current) playListeningChime();

      const detected = detectAgentLanguageFromText(text);
      if (detected && detected !== language) {
        console.log('[Voice] Language switch detected:', language, '->', detected);
        setLanguage(detected);
      }

      // Commit point: drop the live draft (real user bubble takes over) and
      // dispatch the LLM run exactly once with the final transcript.
      setUserDraft(null);
      queueDebouncedSend(text, { isVoiceMode: true, voicePersonaTone: getPersonaToneForBackend(voicePersona), source: "voice-final" });

      // Arm the voice reply watchdog. We're now in 'await-loading' phase: if
      // `isLoading` doesn't flip true within VOICE_REPLY_TIMEOUT_MS the
      // request was lost before reaching the assistant — trigger one bounded
      // recovery (single STT reconnect + replay of this exact transcript).
      //
      // SKIP the watchdog for short/filler/ghost utterances ("Okay", "GM",
      // TTS bleed): those are intentionally dropped upstream by the dedup +
      // ghost guards, so `isLoading` legitimately never flips. Arming would
      // cause a spurious "Reconnecting…" toast on benign silence.
      const trimmedFinal = text.trim();
      const wordCount = trimmedFinal ? trimmedFinal.split(/\s+/).length : 0;
      const isSubstantive =
        trimmedFinal.length >= 12 &&
        wordCount >= 3 &&
        !isGhostTranscript(trimmedFinal) &&
        !isSystemNarration(trimmedFinal) &&
        looksLikeIntentionalSpeech(trimmedFinal, 0.7);
      lastCommittedTextRef.current = text;
      lastCommittedAtRef.current = Date.now();
      // Fresh utterance → release any stale reconnect lock from a previous turn.
      reconnectInFlightRef.current = null;
      if (voiceWatchdogRef.current) clearTimeout(voiceWatchdogRef.current);
      if (isSubstantive) {
        watchdogPhaseRef.current = 'await-loading';
        voiceWatchdogRef.current = setTimeout(() => {
          if (watchdogPhaseRef.current === 'await-loading') {
            triggerVoiceRecoveryRef.current?.('no-loading');
          }
        }, VOICE_REPLY_TIMEOUT_MS);
      } else {
        watchdogPhaseRef.current = 'idle';
      }
    } else {
      setInput(prev => prev ? `${prev} ${text}` : text);
      haptics.success();
    }
  }, [voiceMode, language, setLanguage, voicePersona, cancelAndNotify, queueDebouncedSend, setUserDraft]);

  // Helper: returns true only when this partial transcript looks like CLEARLY
  // NEW speech — not a refinement/extension of whatever utterance we already
  // dispatched to the LLM. Used to gate transcript-driven barge-in so we don't
  // abort the in-flight stream just because Scribe keeps polishing the same
  // sentence the user already finished saying.
  const NEW_SPEECH_GRACE_MS = 1500; // within this window, refinements are NOT new
  const isClearlyNewSpeech = useCallback((text: string): boolean => {
    const trimmed = text.trim();
    if (trimmed.length < 10) return false;
    if (isGhostTranscript(trimmed) || isSystemNarration(trimmed)) return false;
    if (!looksLikeIntentionalSpeech(trimmed, 0.85)) return false;

    const last = lastDispatchedTextRef.current.trim();
    if (!last) return true; // nothing in-flight from a prior partial → treat as new
    const sinceLast = Date.now() - lastDispatchedAtRef.current;
    if (sinceLast > NEW_SPEECH_GRACE_MS) return true; // long enough → could be new

    // Normalise both sides and compare. If one is a prefix/superset of the
    // other (typical Scribe refinement of the same utterance), it's NOT new.
    const norm = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, "").replace(/\s+/g, " ").trim();
    const a = norm(trimmed);
    const b = norm(last);
    if (!a || !b) return true;
    if (a === b || a.startsWith(b) || b.startsWith(a) || a.includes(b) || b.includes(a)) {
      return false;
    }
    return true;
  }, []);

  const handleVoicePartial = useCallback((text: string) => {
    if (!voiceMode) return;

    // STRICT DE-DUP: if this partial is just a refinement/echo of something
    // we ALREADY dispatched within the dedup window, drop it entirely. No
    // draft flicker, no barge-in, no re-trigger of the same utterance from
    // the second STT source landing late.
    if (isEchoOfRecentDispatch(text)) return;

    // Transcript-driven barge-in is owned by `useVoiceSession`'s VAD: it
    // already calls `tts.stop()` and fires `onBargeIn` (→ `cancelAndNotify`)
    // the instant the user's voice trips the energy threshold. We do NOT
    // re-trigger either from the partial path here — doing so would clip
    // a freshly-started reply or fire a duplicate "Interrupted" toast.
    // The `clearlyNew` flag is still used below for the listening chime &
    // haptic so the user gets feedback that fresh speech was detected.
    const clearlyNew = isClearlyNewSpeech(text);

    setLastHeardText(text);
    if (clearlyNew) haptics.success();
    if (clearlyNew && !ttsMutedRef.current) playListeningChime();

    const detected = detectAgentLanguageFromText(text);
    if (detected && detected !== language) {
      setLanguage(detected);
    }

    // LIVE UPDATE ONLY — push the latest stable partial into the user bubble.
    // The LLM is NOT (re)started here; the commit/final path owns dispatch.
    setUserDraft(text);
  }, [voiceMode, language, setLanguage, cancelAndNotify, setUserDraft, isClearlyNewSpeech]);

  // ===== Text-mode dictation state =====
  // The input-bar mic button now drives the SAME `useVoiceSession` FSM that
  // powers full voice mode — there is no second STT pipeline (Web Speech is
  // gone). When `isDictating` is true and `voiceMode` is false, the session's
  // commits are routed to the text input instead of the LLM, and we auto-stop
  // after the first commit (mirroring the old toggle-dictation UX).
  const [isDictating, setIsDictating] = useState(false);
  const isDictatingRef = useRef(false);
  useEffect(() => { isDictatingRef.current = isDictating; }, [isDictating]);
  // Snapshot of the textarea content at the moment dictation starts. Live
  // partial transcripts render as `dictationBase + " " + partial` so the
  // user SEES words in the textarea while they speak. On commit, base is
  // promoted to include committed text and we keep listening for more
  // utterances until the user taps the mic again.
  const dictationBaseRef = useRef<string>("");

  const toggleListening = useCallback(() => {
    setIsDictating((prev) => {
      const next = !prev;
      if (next) dictationBaseRef.current = (inputRef.current?.value ?? "").trimEnd();
      return next;
    });
  }, []);
  const stopListening = useCallback(() => {
    setIsDictating(false);
  }, []);
  // No-ops kept for narration call sites — there is no Web Speech to suppress
  // anymore. The session ignores its own TTS bleed via its echo gate.
  const suppressRecognition = useCallback((_ms: number) => {}, []);

  // ===== Voice mode + dictation pipeline (single source of truth) =====
  const NOOP_VOICE_STOP = useRef(() => {}).current;
  const voiceTts = useMemo(
    () => voiceMode
      ? { isSpeaking: isELSpeaking, stop: elStop }
      : { isSpeaking: false, stop: NOOP_VOICE_STOP },
    [voiceMode, isELSpeaking, elStop, NOOP_VOICE_STOP]
  );

  const voice = useVoiceSession({
    enabled: voiceMode || isDictating,
    language,
    isThinking: voiceMode ? isLoading : false,
    tts: voiceTts,
    onCommit: (text) => {
      if (voiceMode) {
        handleVoiceFinal(text);
      } else if (isDictatingRef.current) {
        const base = dictationBaseRef.current;
        const merged = base ? `${base} ${text}` : text;
        dictationBaseRef.current = merged;
        setInput(merged);
        haptics.success();
        // Multi-utterance: stay listening until user explicitly stops.
      }
    },
    onInterim: (text) => {
      if (voiceMode) {
        handleVoicePartial(text);
        return;
      }
      if (isDictatingRef.current) {
        const base = dictationBaseRef.current;
        const preview = base ? `${base} ${text}` : text;
        setInput(preview);
      }
    },
    onBargeIn: () => {
      if (!voiceMode) return;
      cancelAndNotify();
      lastDispatchedTextRef.current = "";
      lastDispatchedAtRef.current = 0;
      haptics.light();
    },
    onUserSpeakingChange: (speaking) => {
      void speaking;
    },
  });

  // Web Speech is gone — Scribe (via the session) is universally available.
  const isVoiceSupported = true;
  const transcript = voice.transcript;
  const volume = voice.volume;
  const voiceError = voice.error;
  const needsGestureRestart = false;
  const isListening = isDictating;



  // ===== Voice reply watchdog wiring =====
  // Wire up `triggerVoiceRecoveryRef` once `elStt` exists. Fires at most ONCE
  // per committed transcript (replayedRef gate). Single STT reconnect +
  // replay-safe state reset before re-dispatching.
  useEffect(() => {
    triggerVoiceRecoveryRef.current = (reason) => {
      const text = lastCommittedTextRef.current;
      if (!voiceMode || !text) {
        clearVoiceWatchdog();
        return;
      }
      // Replay-safe: only one retry per committed utterance — survives a
      // page reload via sessionStorage marker.
      if (isAlreadyReplayed(text)) {
        clearVoiceWatchdog();
        return;
      }
      // Reconnect lock: if another phase already started recovery for the
      // same text, do nothing. Guarantees a single STT teardown per turn.
      if (reconnectInFlightRef.current === text) {
        clearVoiceWatchdog();
        return;
      }
      reconnectInFlightRef.current = text;
      markReplayed(text);

      console.warn(`[Voice watchdog] No assistant reply (${reason}) ${Date.now() - lastCommittedAtRef.current}ms after commit — reconnecting STT and replaying.`);

      // 1) Cancel any half-started in-flight stream so the replay produces
      //    one clean reply (no overlapping turns).
      try { cancelStreamRef.current?.(); } catch {}

      // 2) Drop any stale pending send / draft so replay state is clean.
      if (pendingSendTimerRef.current) {
        clearTimeout(pendingSendTimerRef.current);
        pendingSendTimerRef.current = null;
      }
      pendingSendPayloadRef.current = null;
      // Release the dedup slot held by the (now-cancelled) pending send so
      // the replay below can reserve a fresh one cleanly.
      releaseDispatch(pendingSendTokenRef.current ?? undefined);
      pendingSendTokenRef.current = null;
      setUserDraft(null);
      // Reset dispatch trackers so the replay isn't mistaken for a duplicate
      // of the original utterance by the new-speech detector.
      lastDispatchedTextRef.current = '';
      lastDispatchedAtRef.current = 0;

      // 3) Do NOT tear down the STT WebSocket here. `useVoiceSession` has its
      //    own watchdog (silent-wedge + status-not-connected) that reconnects
      //    only when the socket is actually gone. Stopping + restarting a
      //    healthy WS just to retry an LLM call flickers the Listening UI
      //    and was the visible "Reconnecting…" the user reported.
      reconnectInFlightRef.current = null;

      // 4) Replay the last committed transcript exactly once via the same
      //    debounced path so all bookkeeping (lastDispatched* refs, capture)
      //    stays consistent. The hook-level replay-token registry will
      //    additionally dedupe if a parallel send is somehow in flight.
      toast("Retrying…", { description: "Re-sending your last message.", duration: 1800 });
      // Watchdog replay is an INTENTIONAL re-send of the same text — clear
      // the dedup state so it isn't blocked by its own original dispatch.
      // The `replayedRef` / sessionStorage marker above already prevents
      // infinite replay loops, so this can't cause runaway sends.
      resetTranscriptDedup();
      queueDebouncedSend(text, {
        isVoiceMode: true,
        voicePersonaTone: getPersonaToneForBackend(voicePersona),
        source: "watchdog-replay",
      });

      // 5) Re-arm the watchdog for the replay attempt — but in 'await-loading'
      //    again. If the replay also fails, the persisted marker blocks a 2nd retry.
      lastCommittedAtRef.current = Date.now();
      watchdogPhaseRef.current = 'await-loading';
      if (voiceWatchdogRef.current) clearTimeout(voiceWatchdogRef.current);
      voiceWatchdogRef.current = setTimeout(() => {
        if (watchdogPhaseRef.current === 'await-loading') {
          // Already replayed once for this text — don't loop. Just clear and
          // let the user notice / re-speak.
          clearVoiceWatchdog();
          toast.error("Couldn't get a reply — try again.");
        }
      }, VOICE_REPLY_TIMEOUT_MS);
    };
  }, [voiceMode, voicePersona, voice, queueDebouncedSend, clearVoiceWatchdog, setUserDraft, isAlreadyReplayed, markReplayed]);

  // Watch the loading + last-assistant-content lifecycle and advance/clear
  // the watchdog phases:
  //  • commit (await-loading) → isLoading flips true → switch to await-tokens
  //  • await-tokens → first non-empty assistant delta arrives → clear watchdog
  //  • await-tokens → timeout → trigger recovery (one reconnect + replay)
  const lastAssistantLenRef = useRef(0);
  useEffect(() => {
    if (!voiceMode) {
      clearVoiceWatchdog();
      reconnectInFlightRef.current = null;
      return;
    }
    const phase = watchdogPhaseRef.current;

    // Phase: await-loading → await-tokens
    if (phase === 'await-loading' && isLoading) {
      if (voiceWatchdogRef.current) clearTimeout(voiceWatchdogRef.current);
      watchdogPhaseRef.current = 'await-tokens';
      lastAssistantLenRef.current = 0;
      voiceWatchdogRef.current = setTimeout(() => {
        if (watchdogPhaseRef.current === 'await-tokens' && lastAssistantLenRef.current === 0) {
          triggerVoiceRecoveryRef.current?.('no-tokens');
        }
      }, VOICE_FIRST_TOKEN_TIMEOUT_MS);
      return;
    }

    // Phase: await-tokens → got tokens → clear
    if (phase === 'await-tokens') {
      const last = messages[messages.length - 1];
      if (last?.role === 'assistant' && (last.content?.length ?? 0) > 0) {
        lastAssistantLenRef.current = last.content!.length;
        clearVoiceWatchdog();
        return;
      }
    }

    // Stream ended naturally — clear any lingering watchdog and release the
    // reconnect lock so a future legitimate retry isn't blocked.
    if (phase !== 'idle' && !isLoading) {
      const last = messages[messages.length - 1];
      if (last?.role === 'assistant' && (last.content?.length ?? 0) > 0) {
        clearVoiceWatchdog();
        reconnectInFlightRef.current = null;
      }
    }
  }, [voiceMode, isLoading, messages, clearVoiceWatchdog]);

  // Clean up watchdog on unmount.
  useEffect(() => () => clearVoiceWatchdog(), [clearVoiceWatchdog]);


  // Stream assistant tokens into TTS sentence-by-sentence so River starts
  // speaking before the LLM has finished generating. Cuts perceived latency.
  // SAFETY NET: if no chunks were flushed during streaming (e.g. no sentence
  // terminators arrived, or the response landed in a single tick), we fall
  // back to a full elSpeak() call once isLoading flips false so the user
  // ALWAYS hears the reply.
  const streamingMsgIdxRef = useRef(-1);
  const streamingSpokenLenRef = useRef(0);
  const lastSpokenIndexRef = useRef(-1);
  const streamFlushedAnyRef = useRef(false);
  useEffect(() => {
    if (!voiceMode || ttsMuted) return;
    const lastIdx = messages.length - 1;
    if (lastIdx < 0) return;
    const lastMsg = messages[lastIdx];
    if (lastMsg.role !== 'assistant' || !lastMsg.content) return;

    // New assistant message — open a fresh stream
    if (streamingMsgIdxRef.current !== lastIdx) {
      streamingMsgIdxRef.current = lastIdx;
      streamingSpokenLenRef.current = 0;
      streamFlushedAnyRef.current = false;
      if ('speechSynthesis' in window) window.speechSynthesis.cancel();
      browserStop();
      elBeginStream();
    }

    // Push only the newly-arrived delta
    const delta = lastMsg.content.slice(streamingSpokenLenRef.current);
    if (delta) {
      streamingSpokenLenRef.current = lastMsg.content.length;
      // Track if we've ever pushed meaningful content into the stream
      if (delta.trim().length > 0) streamFlushedAnyRef.current = true;
      elPushStream(delta);
    }

    // Stream complete — flush any trailing text. If streaming never produced
    // any audible chunk (rare edge cases), force a full speak() of the final
    // content so the user always gets a spoken reply.
    if (!isLoading && lastSpokenIndexRef.current !== lastIdx) {
      lastSpokenIndexRef.current = lastIdx;
      elEndStream();
      // Safety net: after one tick, if we haven't started speaking, force it.
      const finalContent = lastMsg.content;
      setTimeout(() => {
        if (!isELSpeakingRef.current && !ttsMutedRef.current && finalContent.trim()) {
          console.log('[Voice] Stream produced no audio — fallback elSpeak()');
          elSpeak(finalContent);
        }
      }, 250);
    }
  }, [messages, voiceMode, isLoading, ttsMuted, browserStop, elBeginStream, elPushStream, elEndStream, elSpeak]);

  // (isELSpeakingRef is declared above near the manual-TTS state — moved up
  // because the cleanup effect for handleSpeakMessage needs to reference it.)


  // Voice narration during tool execution — gives conversational feedback
  const lastNarratedToolRef = useRef<string | null>(null);
  useEffect(() => {
    if (!voiceMode || ttsMuted || !activeExecutingTool) return;
    const toolName = activeExecutingTool.name;
    if (toolName === lastNarratedToolRef.current) return;
    // Don't clip a live reply: if TTS is already mid-utterance, skip narration.
    if (isELSpeakingRef.current) return;
    lastNarratedToolRef.current = toolName;

    const narration = getToolNarration(toolName, language as AgentLanguage);
    console.log('[Voice] Tool narration:', toolName, '->', narration);
    
    // Short thinking tone before narration
    if (!ttsMuted) playThinkingTone();

    // Route narration through ElevenLabs persona voice (single voice consistency)
    suppressRecognition(estimateNarrationDurationMs(narration) + 300);
    suppressRecognitionDuringNarration(narration);
    if ('speechSynthesis' in window) window.speechSynthesis.cancel();
    elSpeak(narration);
  }, [voiceMode, ttsMuted, activeExecutingTool, suppressRecognitionDuringNarration, suppressRecognition, elSpeak, language]);

  // Clear narration tracker when loading finishes
  useEffect(() => {
    if (!isLoading) {
      lastNarratedToolRef.current = null;
    }
  }, [isLoading]);

  // Swarm phase narration — announce debate/scenario phases
  const lastNarratedPhaseRef = useRef<string | null>(null);
  useEffect(() => {
    if (!voiceMode || ttsMuted || !swarmPhase) return;
    const phase = swarmPhase.phase;
    if (phase === lastNarratedPhaseRef.current) return;
    // Don't clip a live reply.
    if (isELSpeakingRef.current) return;
    lastNarratedPhaseRef.current = phase;

    const narration = getPhaseNarration(phase, language as AgentLanguage);
    if (narration) {
      suppressRecognition(estimateNarrationDurationMs(narration) + 300);
      suppressRecognitionDuringNarration(narration);
      // Route through ElevenLabs persona voice (no browser voice mixing)
      elSpeak(narration);
    }
  }, [voiceMode, ttsMuted, swarmPhase, suppressRecognitionDuringNarration, suppressRecognition, elSpeak, language]);

  // Clear phase tracker when loading finishes
  useEffect(() => {
    if (!isLoading) {
      lastNarratedPhaseRef.current = null;
    }
  }, [isLoading]);

  // (Removed: redundant full-duplex `startListening()` re-arm effect and the
  // energy-based barge-in detector. Both are now owned by `useVoiceSession`,
  // which uses the same analyser node it feeds to Scribe — no second mic, no
  // second VAD loop, no inter-pipeline races.)

  // ===== Push-to-talk =====
  // Holding the mic button (or Space when voice mode is open) instantly cuts
  // TTS, opens the floor for the user, and on release lets the voice-session
  // VAD commit whatever was captured. All routing is via the FSM.
  const pttActiveRef = useRef(false);
  const startPushToTalk = useCallback(() => {
    if (!voiceMode || pttActiveRef.current) return;
    pttActiveRef.current = true;
    haptics.medium();
    // Route the interrupt through the voice session — it owns the only
    // `tts.stop()` call site in voice mode and emits `onBargeIn` so
    // `cancelAndNotify` runs exactly once. We deliberately do NOT call
    // `stopSpeakingRef.current()` here — that would clip TTS twice and
    // race the session's own stop.
    if (isSpeakingRef.current) {
      voice.interrupt();
    } else if (isLoadingRef.current) {
      // No TTS to cut, but a stream is in-flight — still notify+cancel once.
      cancelAndNotify();
    }
    // Reset dispatch memory so the next utterance is always treated as new.
    lastDispatchedTextRef.current = "";
    lastDispatchedAtRef.current = 0;
    voice.pushToTalkStart();
  }, [voiceMode, voice, cancelAndNotify]);

  const endPushToTalk = useCallback(() => {
    if (!pttActiveRef.current) return;
    pttActiveRef.current = false;
    haptics.light();
    voice.pushToTalkEnd();
  }, [voice]);

  // Spacebar = push-to-talk while voice overlay is open. Esc = instant interrupt.
  useEffect(() => {
    if (!voiceMode) return;
    const isTextField = (el: EventTarget | null) => {
      if (!(el instanceof HTMLElement)) return false;
      const tag = el.tagName;
      return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
    };
    const onKeyDown = (e: KeyboardEvent) => {
      if (isTextField(e.target)) return;
      if (e.code === 'Space' && !e.repeat) {
        e.preventDefault();
        startPushToTalk();
      } else if (e.key === 'Escape' && isSpeakingRef.current) {
        e.preventDefault();
        console.log('[Voice] Esc → voice.interrupt()');
        // Same rule as PTT/mic-press: the session owns the cut.
        voice.interrupt();
      }
    };
    const onKeyUp = (e: KeyboardEvent) => {
      if (isTextField(e.target)) return;
      if (e.code === 'Space') {
        e.preventDefault();
        endPushToTalk();
      }
    };
    window.addEventListener('keydown', onKeyDown);
    window.addEventListener('keyup', onKeyUp);
    return () => {
      window.removeEventListener('keydown', onKeyDown);
      window.removeEventListener('keyup', onKeyUp);
    };
  }, [voiceMode, startPushToTalk, endPushToTalk, voice]);

  // Voice state for the overlay = the raw voice-session FSM, untouched.
  // The overlay is now responsible for projecting this onto its UI buckets
  // (`projectFsm` inside VoiceModeOverlay) and for recovering from any
  // stalled state via its internal watchdog. Squashing the FSM here is
  // exactly how the overlay used to drift, so we deliberately don't.
  const voiceState = voiceMode
    ? voice.state
    : (isSpeaking ? "speaking" as const
        : isLoading ? "thinking" as const
        : "idle" as const);

  // When voice mode is toggled off, stop TTS + Web Speech. The voice session
  // tears itself down via its `enabled` prop.
  useEffect(() => {
    if (!voiceMode) {
      stopSpeaking();
      if (isListening) stopListening();
    }
  }, [voiceMode]); // eslint-disable-line react-hooks/exhaustive-deps

  const toggleVoiceMode = useCallback(() => {
    console.log(`[VoiceGesture] toggleVoiceMode → ${voiceMode ? "OFF" : "ON"}`);
    haptics.medium();
    const next = !voiceMode;
    setVoiceMode(next);
    if (next) {
      toast.success(t("voice.modeEnabled"));
      // Run inside the user gesture so iOS/Safari grants audio playback +
      // mic permission on this exact call. `useVoiceSession` will see
      // `enabled` flip true; explicitly start now so mic permission is tied
      // to the tap instead of a later effect tick.
      try { primeAudio(); } catch (e) { console.warn("[VoiceGesture] primeAudio failed", e); }
      try { elPrewarm(); } catch (e) { console.warn("[VoiceGesture] prewarm failed", e); }
      // Fire-and-forget. `voice.start()` is idempotent (guarded by
      // `isStartingRef`) so a duplicate call from the `enabled` effect is
      // harmless.
      void voice.start().catch((err) => {
        console.error("[VoiceGesture] voice.start() rejected", err);
      });
    } else {
      toast(t("voice.modeDisabled"));
    }
  }, [voiceMode, primeAudio, elPrewarm, voice, t]);

  // ⌘K / Ctrl+K command palette shortcut
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) {
        e.preventDefault();
        haptics.light();
        setPaletteOpen((open) => !open);
      }
    };
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, []);

  // Removed useMotionValue/useSpring — negligible visual impact, saves animation overhead

  // Sync loading state
  useEffect(() => { setIsAssistantLoading(isLoading); }, [isLoading, setIsAssistantLoading]);
  useEffect(() => { if (!authLoadingState) setLoading(false); }, [authLoadingState]);
  useEffect(() => { if (!loading && !session) navigate("/landing"); }, [session, loading, navigate]);

  // Credit fetching removed — NIM is free; premium gate is enforced server-side.

  // === Scroll management ===
  const lastMessageLengthRef = useRef(0);
  const prevMessageCountRef = useRef(0);

  useEffect(() => {
    repin();
    followToBottom("auto");
  }, [conversationId, followToBottom, repin]);

  // ── Single-writer scroll (no jitter) ─────────────────────────────────
  // The reveal bus inside useStickyAutoScroll already snaps on every
  // committed layout growth (typewriter + ResizeObserver). Re-firing
  // followToBottom() on five aggregate deps was racing the reveal bus
  // and causing the "screen dancing" effect on mobile (each dep change
  // = its own RAF snap, while framer-motion entrance was still translating
  // the card's transform). We now only follow on message-count changes
  // (= a brand-new turn just appeared) and let the reveal bus handle
  // streaming token growth + late tool-card expansion.
  useEffect(() => {
    const messageCountChanged = messages.length !== prevMessageCountRef.current;
    const lastMessage = messages[messages.length - 1];
    lastMessageLengthRef.current = lastMessage?.content?.length || 0;
    prevMessageCountRef.current = messages.length;
    if (messages.length === 0) return;
    if (messageCountChanged) followToBottom("auto");
  }, [messages.length, followToBottom]);

  // (Removed: 100ms setInterval heartbeat. The new RAF glide in
  // useStickyAutoScroll keeps a 1.5s idle tail and the shallow ResizeObserver
  // on the content wrapper catches every layout change natively. The
  // heartbeat was fighting the RAF and producing visible jitter.)

  // Composer is fixed-height — long messages scroll inside the textarea.

  // === Handlers ===
  const handleSend = (overrideMessage?: string) => {
    const messageText = overrideMessage || input.trim() || (ingestedFiles.length > 0 ? "Read this attached file" : "Analyze this chart");
    if (isIngestingFiles) return;
    if ((!messageText && attachedImages.length === 0 && ingestedFiles.length === 0) || isLoading) return;
    haptics.light();
    // Prepend ingested file content as <attached_files>…</attached_files>
    // so the model sees the full text/markdown without us touching the backend.
    const filePrefix = buildFilePromptBlock();
    const finalText = filePrefix ? `${filePrefix}${messageText}` : messageText;
    // Capture chip metadata so the user bubble can show which files were attached.
    const fileMeta = ingestedFiles.length > 0
      ? ingestedFiles.map(f => ({ name: f.name, type: f.type, size: f.size, truncated: f.truncated, pages: f.pages, sheets: f.sheets }))
      : undefined;
    // Display label keeps the user's typed text only; file chips are shown above.
    sendMessage(
      finalText,
      attachedImages.length > 0 ? attachedImages : undefined,
      filePrefix ? messageText : undefined,
      undefined,
      fileMeta,
    );
    if (!overrideMessage) {
      setInput("");
      setAttachedImages([]);
      clearIngestedFiles();
    }
  };

  const handleRetry = useCallback(() => {
    const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
    if (lastUserMsg) {
      haptics.medium();
      // Use the real backend prompt if available (chip labels differ from the actual prompt)
      const realPrompt = ('backendContent' in lastUserMsg && lastUserMsg.backendContent) 
        ? String(lastUserMsg.backendContent) 
        : lastUserMsg.content;
      sendMessage(realPrompt);
    }
  }, [messages, sendMessage]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

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

  // Video extraction
  const detectVideoPlatform = (url: string): 'youtube' | 'tiktok' | null => {
    if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
    if (url.includes('tiktok.com') || url.includes('vm.tiktok.com')) return 'tiktok';
    return null;
  };

  const extractFromVideo = async () => {
    if (!videoUrl.trim()) { toast.error(t("assistant.video.enterUrl")); return; }
    const platform = detectVideoPlatform(videoUrl.trim());
    if (!platform) { toast.error(t("assistant.video.invalidUrl")); return; }
    setIsExtractingMedia(true);
    setShowAttachDialog(false);
    haptics.medium();
    try {
      const { data, error } = await supabase.functions.invoke('extract-strategy-from-media', {
        body: { type: 'video', videoUrl: videoUrl.trim() },
      });
      if (error) throw error;
      if (!data?.success) throw new Error(data?.error || t("assistant.video.extractFailed"));
      const platformName = platform === 'tiktok' ? 'TikTok' : 'YouTube';
      const strategyText = formatExtractedStrategy(data.strategy);
      sendMessage(t("assistant.video.analysisPrompt", {
        platform: platformName,
        url: videoUrl.trim(),
        strategy: strategyText,
      }));
      setVideoUrl('');
      toast.success(t("assistant.video.extracted", { platform: platformName }));
      haptics.success();
    } catch (error) {
      console.error('Video extraction error:', error);
      toast.error(error instanceof Error ? error.message : t("assistant.video.extractFailed"));
    } finally {
      setIsExtractingMedia(false);
    }
  };

  const formatExtractedStrategy = (strategy: any): string => {
    if (!strategy) return t("assistant.video.noStrategy");
    const parts: string[] = [];
    if (strategy.name) parts.push(`**${t("assistant.video.strategyLabel")}:** ${strategy.name}`);
    if (strategy.description) parts.push(strategy.description);
    if (strategy.entryConditions?.length) {
      parts.push(`\n**${t("assistant.video.entryConditions")}:**\n${strategy.entryConditions.map((c: string) => `• ${c}`).join('\n')}`);
    }
    if (strategy.exitConditions?.length) {
      parts.push(`\n**${t("assistant.video.exitConditions")}:**\n${strategy.exitConditions.map((c: string) => `• ${c}`).join('\n')}`);
    }
    return parts.join('\n') || t("assistant.video.noStrategy");
  };

  // Loading/auth state — render an in-flow skeleton so the shell TopBar,
  // Sidebar, and BottomTabBar keep painting. A `fixed inset-0 z-50` sheet
  // here would cover the real header chrome and read as "the page broke /
  // empty header strip". A 120ms grace window also lets warm cached opens
  // skip the skeleton entirely (Gemini-style instant re-entry).
  if (loading || !session) {
    return showLoadingSkeleton ? <AgentSkeleton /> : null;
  }

  return (
    <RevealModeProvider>
    <div 
      className="shell-fit flex flex-col bg-transparent overflow-hidden safe-area-x no-safe-bottom min-w-0"
    >
      {/* Voice Mode Fullscreen Overlay */}
      <VoiceModeOverlay
        isOpen={voiceMode}
        onClose={() => {
          // Authoritative cancel: stop in-flight stream + all TTS + watchdogs
          // BEFORE toggling voice mode off, so the "thinking" animation and
          // any in-progress audio actually stop when the user presses X.
          try { cancelStreamRef.current?.(); } catch {}
          try { elStop(); } catch {}
          try { browserStop(); } catch {}
          try { window.speechSynthesis?.cancel(); } catch {}
          if (voiceWatchdogRef.current) {
            clearTimeout(voiceWatchdogRef.current);
            voiceWatchdogRef.current = null;
          }
          if (pendingSendTimerRef.current) {
            clearTimeout(pendingSendTimerRef.current);
            pendingSendTimerRef.current = null;
          }
          pendingSendPayloadRef.current = null;
          setUserDraft(null);
          toggleVoiceMode();
        }}
        voiceState={voiceState}
        transcript={voiceMode ? voice.transcript : transcript}
        lastHeardText={lastHeardText}
        spokenText={elCurrentText || currentSentence}
        volume={voiceMode ? voice.volume : volume}
        needsGestureRestart={voiceMode ? false : needsGestureRestart}
        error={voiceMode ? voice.error : voiceError}
        onMute={() => setTTSMuted(!ttsMuted)}
        isMuted={ttsMuted}
        toolResults={messages.length > 0 ? [...messages].reverse().find(m => m.role === 'assistant')?.toolResults : undefined}
        executingTools={executingTools}
        swarmPhase={swarmPhase ? { phase: swarmPhase.phase as "research" | "debate" | "scenarios" | "orchestrate", detail: swarmPhase.detail } : null}
        isAgentLoading={isLoading && !!(executingTools && executingTools.length > 0)}
        onMicPress={() => {
          // Tap during TTS = hard interrupt. The session's `interrupt()`
          // call cuts TTS AND emits `onBargeIn`, which is the single page
          // path that calls `cancelAndNotify`. Calling it ourselves would
          // double-fire the toast and clip the next reply's first chunk.
          if (isSpeakingRef.current) {
            voice.interrupt();
            return;
          }
          // Recovery path: voice mode is open but the session is idle or
          // errored (e.g. user denied mic on first try, or Safari dropped
          // the gesture chain). Tapping the orb retries inside a fresh
          // user gesture.
          if (voice.state === "idle" || voice.error) {
            console.log("[VoiceGesture] orb tap — retrying voice.start()");
            try { primeAudio(); } catch {}
            void voice.start().catch((err) => {
              console.error("[VoiceGesture] retry voice.start() rejected", err);
            });
          }
        }}
        onPushToTalkStart={startPushToTalk}
        onPushToTalkEnd={endPushToTalk}
        voicePersona={voicePersona}
        onVoicePersonaChange={handleVoicePersonaChange}
        onCameraSnap={(frame) => {
          // Guard against rapid duplicate fires: dedupe by captureId, and refuse
          // any frame older than 5s (treat as stale — the active voice turn has
          // almost certainly moved on by then).
          if (lastCameraCaptureIdRef.current === frame.captureId) return;
          if (Date.now() - frame.capturedAt > 5000) {
            console.warn("[VoiceCamera] dropping stale frame", frame.captureId, "age", Date.now() - frame.capturedAt, "ms");
            return;
          }
          lastCameraCaptureIdRef.current = frame.captureId;

          // Stash this capture so the *next* spoken utterance in the same voice
          // turn can be linked to this exact frame on the backend.
          pendingCaptureRef.current = {
            captureId: frame.captureId,
            capturedAt: frame.capturedAt,
            turnId: frame.turnId,
            sequenceInTurn: frame.sequenceInTurn,
            width: frame.width,
            height: frame.height,
          };

          const transcriptText =
            "I'm pointing my camera at a chart — analyze what you see: pattern, key levels, momentum, and what you'd do.";
          const captionParts = [
            "📷 Live chart capture",
            `Turn ${frame.turnId} · #${frame.sequenceInTurn}`,
            new Date(frame.capturedAt).toLocaleTimeString(),
          ];
          sendMessage(
            `${transcriptText}\n\n[capture id: ${frame.captureId} · turn ${frame.turnId} · snap #${frame.sequenceInTurn} · ${new Date(frame.capturedAt).toISOString()}]`,
            [frame.dataUrl],
            captionParts.join(" · "),
            {
              isVoiceMode: true,
              voicePersonaTone: getPersonaToneForBackend(voicePersona),
              transcriptText,
              capture: {
                captureId: frame.captureId,
                capturedAt: frame.capturedAt,
                turnId: frame.turnId,
                sequenceInTurn: frame.sequenceInTurn,
                width: frame.width,
                height: frame.height,
              },
            }
          );
        }}
        cancelling={cancelling}
        onQuickCommand={(text) => {
          // Tap-to-test the intent gate without speaking. Routes through the same
          // path as a final voice transcript so debounce + draft cleanup behave
          // identically to a spoken utterance.
          handleVoiceFinal(text);
        }}
      />
      {/* Conversation history is provided by the main app sidebar — no
          in-chat slide-from-right drawer here. */}

      {/* Artifacts side panel removed — tool results render inline in each MessageBubble. */}

      {/* Command palette — ⌘K */}
      <CommandPalette
        open={paletteOpen}
        onOpenChange={setPaletteOpen}
        actions={[
          { id: "new-conversation", group: "agent", label: "New conversation", hint: "Start a fresh chat", Icon: CommandIcons.Plus, shortcut: "⌘N", run: () => newConversation() },
          
          { id: "clear-messages", group: "agent", label: "Clear current conversation", hint: "Wipe message history", Icon: CommandIcons.Trash2, run: () => clearMessages() },
          { id: "swarm", group: "tools", label: swarmTier === "off" ? "Enable Deep Swarm (30 agents)" : "Disable Deep Swarm", hint: swarmTier === "super" ? "300-agent super swarm active — use picker to change" : "Adversarial reasoning · explicit picker for Super (300)", Icon: CommandIcons.Brain, run: cycleSwarmTier },
          { id: "focus-swarm", group: "tools", label: focusActive ? "Cancel focus swarm" : `Run Focus Swarm (${focusConcurrency} agents)`, hint: focusActive ? "Stop the active multi-model run" : "Bounded multi-model debate on current input", Icon: CommandIcons.Sparkles, run: () => {
            if (focusActive) { void focusCtl.cancel(); return; }
            const prompt = input.trim();
            if (!prompt) { toast.error("Type a prompt first"); return; }
            void focusCtl.start({ prompt, concurrency: focusConcurrency });
          } },
          { id: "voice", group: "tools", label: voiceMode ? "Exit voice mode" : "Enter voice mode", hint: "Hands-free conversation", Icon: CommandIcons.Mic, run: () => toggleVoiceMode() },
          { id: "mode-analyze", group: "tools", label: "Mode: Analyze", hint: "Deep technical read", Icon: CommandIcons.Brain, run: () => setChatMode("analyze") },
          { id: "mode-search", group: "tools", label: "Mode: Search", hint: "Live web + news", Icon: CommandIcons.Search, run: () => setChatMode("search") },
          { id: "mode-vision", group: "tools", label: "Mode: Vision", hint: "Read a chart image", Icon: CommandIcons.Eye, run: () => setChatMode("vision") },
          { id: "mode-auto", group: "tools", label: "Mode: Auto", hint: "Let Synx decide", Icon: CommandIcons.Sparkles, run: () => setChatMode("auto") },
          // Artifacts are inline now; no command-palette entry needed.
          { id: "go-dashboard", group: "navigate", label: "Go to Dashboard", hint: "Trading workspace", Icon: CommandIcons.LayoutDashboard, run: () => navigate("/dashboard") },
        ] satisfies CommandPaletteAction[]}
      />


      {/* Brand cluster — chat-mode only. In Build mode the shell renders
          BuildModeActions (Chat-switch + Preview + Deploy) instead. */}
      {assistantMode === "chat" && topbarSlot && createPortal(
        <MobileBrandCluster
          isLoading={isLoading}
          loadingState={loadingState}
          inputLength={input.length}
          swarmTier={swarmTier}
          onCycleSwarmTier={cycleSwarmTier}
          onNavigateDashboard={() => navigate('/dashboard')}
        />,
        topbarSlot
      )}

      {/* Right-side actions — chat-mode only (Signals + New conversation). */}
      {assistantMode === "chat" && topbarRightSlot && createPortal(
        <>
          <button
            onClick={() => { haptics.medium(); setSignalsHistoryOpen(true); }}
            title={t("assistant.signalsHistoryTooltip")}
            aria-label={t("assistant.signals")}
            className="hidden sm:inline-flex h-8 px-2 items-center gap-1 text-[11px] font-semibold tracking-wide text-foreground/80 hover:text-foreground transition-colors active:scale-95"
          >
            <Activity className="w-3.5 h-3.5" />
            <span className="leading-none">{t("assistant.signals")}</span>
          </button>
          {messages.length > 0 && (
            <Button
              variant="ghost"
              size="icon"
              onClick={() => { haptics.light(); newConversation(); }}
              className="hidden md:inline-flex h-8 w-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-foreground/5 active:scale-95"
              aria-label="New conversation"
            >
              <Plus className="w-[17px] h-[17px]" />
            </Button>
          )}
        </>,
        topbarRightSlot
      )}

      <SignalsHistoryDrawer open={signalsHistoryOpen} onOpenChange={setSignalsHistoryOpen} />

      {/* Build mode owns the page column — its own canvas+composer split,
          rendered OUTSIDE the chat scroll container so the composer stays
          pinned and nothing scroll-jumps. */}
      {assistantMode === "build" && (
        <BuildWorkspace appBuilder={appBuilder} onSendChat={undefined} />
      )}

      {/* Messages area — only scrolls when there are messages; the welcome
          screen sizes itself to fit the available space exactly. Hidden in
          Build mode (the BuildWorkspace above takes over). */}
      {assistantMode !== "build" && (
      <SynxAuroraBackground active={isLoading || loadingState === 'streaming' || isSpeaking}>
      <div 
        {...scrollBind}
        className={cn(
          "flex-1 min-h-0 min-w-0 chat-scroller",
          messages.length === 0 ? "overflow-hidden" : "overflow-y-auto"
        )}
      >

        {focusActive && messages.length === 0 && (
          <div className="mx-auto w-full max-w-3xl px-3 sm:px-4 pt-3">
            <ChatErrorBoundary surface="Focus swarm">
              <SwarmFocusPanel ctl={focusCtl} concurrency={focusConcurrency} />
            </ChatErrorBoundary>
          </div>
        )}

        <AnimatePresence mode="popLayout" initial={false}>
        {messages.length === 0 ? (
          <motion.div
            key="welcome"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={m3Tween.enter}
            className="h-full"
          >
            <WelcomeScreen swarmMode={swarmMode} swarmTier={swarmTier} />
          </motion.div>
        ) : (
          <div
            key="thread"
            className="chat-thread mx-auto w-full max-w-3xl md:max-w-2xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-4xl min-w-0 px-3 sm:px-4 pt-3 pb-2 space-y-3 animate-in fade-in duration-200"
          >


            {focusActive && (
              <ChatErrorBoundary surface="Focus swarm">
                <SwarmFocusPanel ctl={focusCtl} concurrency={focusConcurrency} />
              </ChatErrorBoundary>
            )}


            {messages.map((msg, idx) => {
              const stableKey = msg.clientKey || msg.id || `msg-${msg.role}-${idx}-${msg.content?.slice(0, 16) || 'empty'}`;
              const isLastAssistant = idx === messages.length - 1 && msg.role === 'assistant';
              // Show the Kimi-style task timeline as soon as the assistant
              // starts streaming so users see every action step appear
              // before/while the text reply lands — not after.
              const showTimeline =
                isLastAssistant &&
                ((msg as { isStreaming?: boolean }).isStreaming ||
                  modelTimeline.length > 0 ||
                  (executingTools?.length ?? 0) > 0 ||
                  (msg.toolResults?.length ?? 0) > 0);
              return (
              <div key={stableKey} className="chat-message min-w-0">
                <ChatErrorBoundary surface={showTimeline ? "Agent timeline" : "Message"}>
                {showTimeline && (
                  <AgentTaskTimeline
                    modelTimeline={modelTimeline}
                    executingTools={executingTools}
                    swarmPhase={swarmPhase ? { phase: swarmPhase.phase, detail: swarmPhase.detail } : null}
                    toolResults={msg.toolResults}
                    isStreaming={(msg as { isStreaming?: boolean }).isStreaming}
                    // Intentionally NOT passing streamingThought={msg.content}:
                    // it changes per rAF tick and would re-render the entire
                    // timeline subtree at streaming cadence. The timeline
                    // already reflects live state via isStreaming + tool data.
                    onAutoScrollChange={(next) => {
                      if (next) {
                        repin();
                        followToBottom("smooth");
                      } else {
                        unpin();
                      }
                    }}
                  />
                )}
                <MessageBubble
                  msg={msg}
                  idx={idx}
                  totalMessages={messages.length}
                  isLoading={isLoading}
                  activeExecutingTool={isLastAssistant ? activeExecutingTool : null}
                  swarmPhase={isLastAssistant && swarmPhase ? { phase: swarmPhase.phase as "research" | "debate" | "scenarios" | "orchestrate", detail: swarmPhase.detail } : null}
                  swarmMode={swarmMode}
                  swarmTier={swarmTier}
                  onRetry={handleRetry}
                  
                  prevUserCreatedAt={
                    msg.role === "assistant" && idx > 0 && messages[idx - 1]?.role === "user"
                      ? messages[idx - 1]?.createdAt
                      : undefined
                  }
                  onSpeak={msg.role === "assistant" && !voiceMode ? handleSpeakMessage : undefined}
                  onStopSpeak={handleStopManualSpeak}
                  isSpeakingThisMessage={manualSpeakingMessageId === (msg.id || `__ephemeral__:${(msg.content || '').slice(0, 64)}:${(msg.content || '').length}`)}
                  isPreparingSpeech={manualPreparingMessageId === (msg.id || `__ephemeral__:${(msg.content || '').slice(0, 64)}:${(msg.content || '').length}`)}
                />
                </ChatErrorBoundary>
              </div>
              );
            })}
          </div>
        )}
        </AnimatePresence>

        {/* Error message */}
        {error && (
          <div className="mx-4 mb-4 p-4 rounded-2xl bg-destructive/10 border border-destructive/30 text-destructive text-sm animate-fade-in">
            <p className="font-medium mb-1">{t("error.somethingWrong")}</p>
            <p className="text-xs opacity-80 mb-2">{error}</p>
            <button
              onClick={handleRetry}
              className="flex items-center gap-1.5 text-xs font-medium text-destructive hover:text-destructive/80 transition-colors"
            >
              <RotateCcw className="w-3 h-3" />
              {t("error.retryLastMessage")}
            </button>
          </div>
        )}
      </div>





      {/* Credits exhausted overlay removed — NIM is free; no per-message metering. */}

      {/* Input area — iOS glass shell. Hidden in Build mode so the Build
          hero is the only input on screen (no double-composer).

          Bottom padding ALWAYS honors the iPhone home-indicator safe-area
          AND iOS Safari's URL-bar collapse via the visualViewport-derived
          `--kb-inset` fallback. Using a single `paddingBottom` expression
          (instead of Tailwind `pb-*` + style override) prevents the
          composer pill from "jumping" between welcome and threaded states
          when the URL bar collapses or the keyboard opens/closes. */}
      <div
        className="relative px-1.5 sm:px-4 pt-3 shrink-0 w-full max-w-full overflow-x-hidden bg-gradient-to-t from-background via-background/95 to-background/0"
        style={{
          paddingLeft: 'max(env(safe-area-inset-left, 0px), 6px)',
          paddingRight: 'max(env(safe-area-inset-right, 0px), 6px)',
          paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 6px)',
        }}
      >
        {/* Scroll-to-bottom pill — always anchored just above the footer
            (mode pills + suggestions), regardless of content height. */}
        <AnimatePresence>
          {showScrollButton && (
            <div className="pointer-events-none absolute left-0 right-0 -top-10 z-10 flex justify-center px-4">
              <div className="pointer-events-auto">
                <JumpToBottomPill
                  onClick={scrollToBottom}
                  hasNew={isLoading || loadingState === 'streaming'}
                />
              </div>
            </div>
          )}
        </AnimatePresence>



        {/* Low credits warning removed — NIM is free. */}


        {/* Mode pills — desktop only. On mobile the model chip + voice
            launcher live INSIDE the unified input pill (Perplexity dock). */}
        <div className={cn(shellWidthClass, "hidden md:block mb-0")}>
          <InputModePills
            mode={chatMode}
            onModeChange={setChatMode}
            onVoiceClick={voiceMode ? undefined : toggleVoiceMode}
            swarmActive={swarmMode}
          />
        </div>

        {/* Unified suggestion strip — single Perplexity-style surface used in
            BOTH empty-welcome and in-chat states. The chip component itself
            picks contextual suggestions when there are messages, and shows
            category tabs as starters before the first message. No more
            tile-vs-chip duality. */}
        <div className={cn(shellWidthClass, "mb-1")}>
          <AgentSuggestionChips
            onSelect={handleSuggestionSelect}
            lastToolUsed={lastToolUsed}
            isLoading={isLoading}
            hasMessages={messages.length > 0}
            lastMessages={messages.slice(-6).map(m => m.content)}
            swarmMode={swarmMode}
            swarmTier={swarmTier}
          />
        </div>

        {/* Attached images preview — single location only */}
        {attachedImages.length > 0 && (
          <div className={cn(shellWidthClass, "mb-1")}>
            <div className="flex gap-2 overflow-x-auto pb-1.5">
              {attachedImages.map((img, idx) => (
                <div key={idx} className="relative flex-shrink-0">
                  <img
                    src={img}
                    alt={`Attached ${idx + 1}`}
                    className="w-14 h-14 object-cover rounded-xl border border-border/50"
                  />
                  <button
                    onClick={() => removeAttachedImage(idx)}
                    className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-destructive text-destructive-foreground flex items-center justify-center shadow-md"
                  >
                    <X className="w-2.5 h-2.5" />
                  </button>
                </div>
              ))}
            </div>
          </div>
        )}

        {/* Attached files preview (PDF/CSV/XLSX/DOCX/TXT) */}
        {ingestedFiles.length > 0 && (
          <div className={cn(shellWidthClass, "mb-1")}>
            <div className="flex gap-2 overflow-x-auto pb-1.5">
              {ingestedFiles.map((f) => (
                <FileChip
                  key={f.id}
                  name={f.name}
                  type={f.type}
                  size={f.size}
                  truncated={f.truncated}
                  onRemove={() => removeIngestedFile(f.id)}
                />
              ))}
            </div>
          </div>
        )}

        <div
          className={cn(
            shellWidthClass,
            // Unified Gemini-style pill: the entire bottom row is ONE rounded
            // surface containing buttons + textarea. Taller for breathing room.
            "flex items-center gap-1 sm:gap-1.5 w-full max-w-full min-w-0",
            "rounded-[28px] border border-foreground/10 bg-foreground/[0.06] px-2 py-2 sm:px-3 sm:py-2.5",
            "shadow-[0_8px_24px_-18px_hsl(var(--foreground)/0.45)] focus-within:border-foreground/20 transition-[border-color,box-shadow,background-color] duration-200",
            isLoading && "border-primary/35 bg-primary/[0.07] shadow-[0_0_34px_-10px_hsl(var(--primary)/0.85),0_8px_24px_-18px_hsl(var(--foreground)/0.45)] running-glow"
          )}
        >
          {/* Left action buttons — inline row */}
          <div className="flex flex-row items-center gap-0.5 sm:gap-1 shrink-0">
            {/* Attachment button */}
            <AttachDialog
              isOpen={showAttachDialog}
              onOpenChange={setShowAttachDialog}
              isLoading={isLoading}
              isExtractingMedia={isExtractingMedia}
              attachedImages={attachedImages}
              onAddImages={(imgs) => setAttachedImages(prev => [...prev, ...imgs].slice(0, 5))}
              onRemoveImage={removeAttachedImage}
              input={input}
              onInputChange={setInput}
              onSend={() => handleSend()}
              videoUrl={videoUrl}
              onVideoUrlChange={setVideoUrl}
              onExtractFromVideo={extractFromVideo}
              ingestedFiles={ingestedFiles}
              onIngestFiles={async (fl) => { await ingestFiles(fl); }}
              onRemoveIngestedFile={removeIngestedFile}
              isIngestingFiles={isIngestingFiles}
              ingestStatuses={ingestStatuses}
            />

            {/* Mic button — voice-to-text dictation. Fires on pointerdown to
                preserve the iOS Safari user-gesture chain for getUserMedia +
                AudioContext.resume. preventDefault + suppress onClick to
                avoid double-firing. */}
            {isVoiceSupported && (
              <Button
                onPointerDown={(e) => {
                  if (e.button !== 0 && e.pointerType !== "touch" && e.pointerType !== "pen") return;
                  e.preventDefault();
                  haptics.medium();
                  toggleListening();
                }}
                size="icon"
                className={cn(
                  "inline-flex h-9 w-9 rounded-full shrink-0 transition-[transform,background-color,opacity] duration-200 active:scale-95 touch-manipulation",
                  isListening
                    ? "bg-destructive/90 hover:bg-destructive"
                    : "bg-transparent hover:bg-foreground/[0.06]"
                )}
                title={isListening ? t("voice.stopDictation") : t("voice.startDictation")}
                aria-label={isListening ? t("voice.stopDictation") : t("voice.startDictation")}
              >
                {isListening ? (
                  <MicOff className="w-4 h-4 text-destructive-foreground" />
                ) : (
                  <Mic className="w-4 h-4 text-muted-foreground" />
                )}
              </Button>
            )}

            {/* Private Mode (Ghost) toggle — ephemeral chat: no DB persist,
                no long-term memory, no learning loops. Guardrails stay on. */}
            <Button
              type="button"
              onClick={() => {
                haptics.medium();
                const next = !privateMode;
                setPrivateMode(next);
                toast[next ? "success" : "message"](
                  next ? "Private mode ON — this chat won't be saved" : "Private mode OFF",
                  { description: next ? "No history, memory, or training. Guardrails stay active." : undefined }
                );
              }}
              size="icon"
              aria-pressed={privateMode}
              title={privateMode ? "Private mode is ON — turn off" : "Turn on private mode (no history)"}
              aria-label="Toggle private mode"
              className={cn(
                "inline-flex h-9 w-9 rounded-full shrink-0 transition-[background-color,opacity] duration-200 active:scale-95",
                privateMode
                  ? "bg-violet-500/20 hover:bg-violet-500/30 ring-1 ring-violet-400/50"
                  : "bg-transparent hover:bg-foreground/[0.06]"
              )}
            >
              <Ghost
                className={cn(
                  "h-[18px] w-[18px] transition-opacity",
                  privateMode ? "opacity-100 text-violet-200" : "opacity-70 text-foreground/80"
                )}
              />

            </Button>

            {/* Auto chip moved INSIDE the input pill (see below). */}

          </div>

          {/* Text input area — borderless; inherits the outer unified pill */}
          <div className="flex-1 min-w-0 relative">




            {/* Voice status indicator */}
            <AnimatePresence>
              {(isListening || (voiceMode && isSpeaking)) && !voiceMode && (
                <motion.div
                  initial={{ opacity: 0, y: 10, scale: 0.95 }}
                  animate={{ opacity: 1, y: 0, scale: 1 }}
                  exit={{ opacity: 0, y: -10, scale: 0.95 }}
                  className="absolute -top-10 left-0 right-0 flex items-center justify-center z-10"
                >
                  <div className={cn(
                    "flex items-center gap-2 px-3 py-1 rounded-full backdrop-blur-md shadow-lg text-[11px] border",
                    isSpeaking
                      ? "bg-primary/10 border-primary/20"
                      : "bg-destructive/10 border-destructive/20"
                  )}>
                    <div className={cn(
                      "w-1.5 h-1.5 rounded-full animate-pulse shrink-0",
                      isSpeaking ? "bg-primary" : "bg-destructive"
                    )} />
                    {isListening && <AdvancedVoiceWaveform volume={volume} isActive={isListening} />}
                    <span className={cn(
                      "font-medium truncate",
                      isSpeaking ? "text-primary" : "text-destructive"
                    )}>
                      {isSpeaking ? t("voice.speaking") : transcript ? `"${transcript}"` : t("voice.listening")}
                    </span>
                    {isSpeaking && (
                      <button onClick={stopSpeaking} className="text-muted-foreground hover:text-foreground shrink-0">
                        <VolumeX className="w-3 h-3" />
                      </button>
                    )}
                  </div>
                </motion.div>
              )}
            </AnimatePresence>

            {/* Cancelling indicator — shown briefly after barge-in / stop press in non-voice mode */}
            <AnimatePresence>
              {cancelling && !voiceMode && (
                <motion.div
                  initial={{ opacity: 0, y: 6, scale: 0.96 }}
                  animate={{ opacity: 1, y: 0, scale: 1 }}
                  exit={{ opacity: 0, y: -6, scale: 0.96 }}
                  transition={m3Tween.state}
                  className="absolute -top-10 left-0 right-0 flex items-center justify-center z-10 pointer-events-none"
                >
                  <div className="flex items-center gap-2 px-3 py-1 rounded-full backdrop-blur-md shadow-lg text-[11px] border bg-destructive/10 border-destructive/20">
                    <motion.div
                      className="w-1.5 h-1.5 rounded-full bg-destructive shrink-0"
                      animate={{ opacity: [1, 0.3, 1], scale: [1, 0.8, 1] }}
                      transition={{ duration: 0.6, repeat: Infinity, ease: [0.2, 0, 0, 1] }}
                    />
                    <span className="font-medium text-destructive">Cancelling…</span>
                  </div>
                </motion.div>
              )}
            </AnimatePresence>

            <textarea
              ref={inputRef}
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onPaste={handlePaste}
              onKeyDown={handleKeyDown}
              placeholder=""
              className={cn(
                "relative z-[1] block w-full min-h-[44px] sm:min-h-[52px] max-h-[140px] resize-none overflow-y-auto px-4 py-3 text-[16px] sm:text-[15px] leading-6 text-center",
                // Borderless on all breakpoints — inherits the text pill only.
                "bg-transparent border-0 focus:outline-none focus:ring-0",
                "placeholder:text-muted-foreground/55 transition-opacity duration-200"
              )}
              style={{ fontSize: '16px', textAlign: 'center' }}
              rows={1}
            />

            {!input && (
              <span className="pointer-events-none absolute inset-y-0 left-4 right-4 z-[2] flex min-w-0 items-center justify-center truncate whitespace-nowrap text-center text-[16px] leading-none text-muted-foreground/55 sm:text-[15px]">
                {isListening ? t("voice.speakNow") : attachedImages.length > 0 ? t("input.describeChart") : t("input.askAnything")}
              </span>
            )}


            {input.length > 100 && (
              <span className="absolute right-2 bottom-1.5 text-[9px] text-muted-foreground/70 tabular-nums pointer-events-none">
                {input.length}/2000
              </span>
            )}
          </div>

          {/* Right tray: voice launcher (mobile only) + send/stop */}
          <div className="flex items-center gap-1 shrink-0">
            {/* Mobile inline voice launcher — sits next to send like Perplexity */}
            {!voiceMode && !isLoading && (
              <div className="md:hidden">
                <VoiceLaunchButton
                  onActivate={toggleVoiceMode}
                  title={t("inputMode.voiceTooltip")}
                  iconOnly
                />
              </div>
            )}

            {/* Send/Stop button */}
            <AnimatePresence mode="popLayout" initial={false}>
              {isLoading ? (
                <motion.div key="stop" initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.9, opacity: 0 }} transition={m3Tween.state}>
                  <Button
                    onMouseDown={(e) => {
                      e.preventDefault();
                      haptics.medium();
                      lastBargeCancelAtRef.current = 0;
                      try { cancelStreamRef.current?.(); } catch {}
                    }}
                    onTouchStart={() => {
                      haptics.medium();
                      lastBargeCancelAtRef.current = 0;
                      try { cancelStreamRef.current?.(); } catch {}
                    }}
                    size="icon"
                    className="h-9 w-9 rounded-full bg-destructive hover:bg-destructive/90 shrink-0 active:scale-95"
                  >
                    <Square className="w-3.5 h-3.5" />
                  </Button>
                </motion.div>
              ) : (
                <motion.div key="send" initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.9, opacity: 0 }} transition={m3Tween.state}>
                  <Button
                    onClick={() => { if (isListening) stopListening(); handleSend(); }}
                    disabled={isIngestingFiles || (!input.trim() && attachedImages.length === 0 && ingestedFiles.length === 0)}
                    size="icon"
                    className={cn(
                      "h-9 w-9 rounded-full shrink-0 transition-[background-color,opacity] duration-200 active:scale-95",
                      (input.trim() || attachedImages.length > 0 || ingestedFiles.length > 0)
                        ? "bg-foreground text-background hover:bg-foreground/90"
                        : "bg-foreground/10 hover:bg-foreground/15"
                    )}
                  >
                    <ArrowUp className={cn("w-4 h-4 transition-colors", (input.trim() || attachedImages.length > 0 || ingestedFiles.length > 0) ? "text-background" : "text-muted-foreground/60")} />
                  </Button>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </div>

      </div>
      </SynxAuroraBackground>
      )}
    </div>
    </RevealModeProvider>
  );
}
