import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { Globe, ListPlus, Paperclip, Send, Square, SquareSlash } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import {
  flattenToMessage,
  inferLanguage,
  looksBinary,
  MAX_FILE_SIZE_BYTES,
  PASTE_INLINE_MAX_LINES,
  type Attachment,
} from '@/lib/attachments';
import { AttachmentChip } from '@/components/AttachmentChip';
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker';
import { ContextBar } from '@/components/ContextBar';
import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker';
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
import { api } from '@/api/client';
import type { Message } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { chatInputsRegistry, sendToChat } from '@/lib/events';
import { useSkills } from '@/hooks/useSkills';
import { useViewport } from '@/hooks/useViewport';

const MAX_ATTACHMENTS = 10;

interface Props {
  disabled?: boolean;
  projectId: string;
  // Batch 9: optional so callers that pre-date the agent picker still compile.
  // When omitted, the toolbar row is hidden entirely.
  agentId?: string | null;
  onAgentChange?: (agentId: string | null) => void | Promise<void>;
  // v1.9: when sessionId + webSearchEnabled are both provided, the + menu
  // renders next to the AgentPicker with a single "Web search" toggle item.
  // The check reflects the *stored* session value (not the effective one):
  // null counts as unchecked. Clicking PATCHes session.web_search_enabled
  // with the inverted boolean (null → true, true → false, false → true).
  sessionId?: string;
  webSearchEnabled?: boolean | null;
  onSend: (content: string) => void | Promise<void>;
  onForceSend?: (content: string) => void | Promise<void>;
  // When the assistant/agent is generating, the send button morphs: empty draft
  // → Stop (calls onStop); non-empty draft → Queue (submits, which the caller
  // queues while busy). Omitting onStop falls back to a (disabled) Send button.
  generating?: boolean;
  onStop?: () => void | Promise<void>;
  // Batch 9.6: slash-command dispatch. When the input parses to a known skill,
  // ChatInput calls this with the skill name + the post-name args (possibly
  // empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
  // disables slash-command dispatch (input is sent as literal text).
  onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
  // v2.5.9: segmented slash-command DISPLAY source for the picker + hint. When
  // provided (e.g. CoderPane passing [agent commands, skills]), these labeled
  // groups are shown instead of the BooChat skills. Invocation routing still
  // uses the skills lookup — names not in skills (opencode's /help etc.) fall
  // through and are sent to the agent as literal text. Omitted → BooChat skills
  // (flat, unchanged — parity).
  slashGroups?: SlashCommandGroup[];
  // v1.10.4: send-to-chat reverse path. When chatId is provided, this input
  // registers in chatInputsRegistry so the terminal floating menu can list
  // it, and subscribes to sendToChat events scoped to this chatId. Receiving
  // an event appends the text to the current draft (with a newline separator
  // when non-empty) and focuses — no auto-send.
  chatId?: string;
  chatLabel?: string;
  // v1.11.5: context-bar inputs. messages drives the latest-pair walk;
  // modelContextLimit is the zero-state fallback (and powers the
  // auto-compaction-threshold tooltip when no assistant message has run
  // yet). Both are optional so older call sites still compile.
  messages?: Message[];
  modelContextLimit?: number | null;
}

export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
  const { isMobile } = useViewport();
  const [value, setValue] = useState('');
  const [busy, setBusy] = useState(false);
  const [attachments, setAttachments] = useState<Attachment[]>([]);
  const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
  const [isDraggingOver, setIsDraggingOver] = useState(false);
  const dropRootRef = useRef<HTMLDivElement | null>(null);
  const pasteCounterRef = useRef(0);
  const [mentionState, setMentionState] = useState<{
    open: boolean;
    query: string;
    atIdx: number;
    anchorRect: { top: number; left: number };
  } | null>(null);
  // Batch 9.6: slash-command dropdown. Opens when `/` is the first char of
  // the input and stays open while the input is `/<word>` with no whitespace.
  // Disabled entirely when the caller doesn't pass onSlashCommand.
  // SlashCommandPicker reads the live textarea rect via inputRef (textareaRef below)
  // so it can recompute on visualViewport changes (iOS keyboard open/close).
  const [slashState, setSlashState] = useState<{
    query: string;
  } | null>(null);
  const { skills } = useSkills();
  const skillsLookup = useMemo(() => {
    const m = new Map<string, true>();
    for (const s of skills) m.set(s.name, true);
    return m;
  }, [skills]);
  // Flat display source for the hint (and the picker's no-groups fallback):
  // caller-provided groups flattened, else the BooChat skills.
  const slashItems = useMemo(
    () =>
      slashGroups
        ? slashGroups.flatMap((g) => g.items)
        : skills.map((s) => ({ name: s.name, description: s.description })),
    [slashGroups, skills],
  );
  const [fileIndex, setFileIndex] = useState<string[] | null>(null);
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
  // Attach-file button → hidden native picker (same File→Attachment path as drop).
  const fileInputRef = useRef<HTMLInputElement | null>(null);
  // Slash-commands chip → click-to-open command menu, anchored to the chip.
  const cmdChipRef = useRef<HTMLButtonElement | null>(null);
  const [cmdMenuOpen, setCmdMenuOpen] = useState(false);

  function addAttachment(a: Attachment) {
    setAttachments(prev => {
      if (prev.length >= MAX_ATTACHMENTS) {
        toast.error(`Max ${MAX_ATTACHMENTS} attachments per message`);
        return prev;
      }
      return [...prev, a];
    });
  }

  const addAttachmentRef = useRef(addAttachment);
  addAttachmentRef.current = addAttachment;

  useEffect(() => {
    return sessionEvents.subscribe((event) => {
      if (event.type !== 'attach_chat_file') return;
      addAttachmentRef.current({
        id: crypto.randomUUID(),
        ...event.attachment,
      });
    });
  }, []);

  // v1.10.4: register this input in the chat-input registry so the terminal
  // pane's "Send to chat" menu can list it. Re-registers when chatLabel
  // changes (e.g. rename) so the menu reflects the current name.
  useEffect(() => {
    if (!chatId) return;
    return chatInputsRegistry.register(chatId, chatLabel ?? 'Chat', () => {
      textareaRef.current?.focus();
    });
  }, [chatId, chatLabel]);

  // v1.10.4: subscribe to send_to_chat events scoped by chatId. Appends the
  // payload text to the current draft (with a newline separator if the
  // draft is non-empty) and focuses the textarea. Does NOT auto-submit.
  useEffect(() => {
    if (!chatId) return;
    return sendToChat.subscribe(({ chat_id, text }) => {
      if (chat_id !== chatId) return;
      setValue((prev) => (prev.length === 0 ? text : `${prev}\n${text}`));
      requestAnimationFrame(() => {
        const ta = textareaRef.current;
        if (!ta) return;
        ta.focus();
        // Put caret at end so the user can keep typing immediately.
        const end = ta.value.length;
        ta.selectionStart = ta.selectionEnd = end;
      });
    });
  }, [chatId]);

  function removeAttachment(id: string) {
    setAttachments(prev => prev.filter(a => a.id !== id));
  }

  // Attach-file button: funnel picked files through the same size/binary gate +
  // chip pipeline as drag-drop. Reset value so re-picking the same file fires.
  async function onPickFiles(e: React.ChangeEvent<HTMLInputElement>) {
    const files = Array.from(e.target.files ?? []);
    e.target.value = '';
    if (files.length === 0) return;
    let remaining = MAX_ATTACHMENTS - attachments.length;
    for (const file of files) {
      if (remaining <= 0) {
        toast.error(`Attachment limit reached (${MAX_ATTACHMENTS}).`);
        break;
      }
      await processDroppedFile(file);
      remaining -= 1;
    }
  }

  async function submit() {
    const text = value.trim();
    if (!text && attachments.length === 0) return;
    if (disabled || busy) return;

    // Batch 9.6: slash-command dispatch. Only when no attachments and the
    // input parses to a known skill. Falls through to onSend for unknown
    // slash names (literal text) or when slash dispatch isn't wired.
    if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) {
      const parsed = parseSlashInput(text);
      if (parsed && skillsLookup.has(parsed.cmdName)) {
        setBusy(true);
        try {
          await onSlashCommand(parsed.cmdName, parsed.args);
          setValue('');
          setAttachments([]);
          setSlashState(null);
        } catch (err) {
          toast.error(err instanceof Error ? err.message : 'skill invocation failed');
        } finally {
          setBusy(false);
        }
        return;
      }
      // Unknown skill name — fall through and send as literal text.
    }

    setBusy(true);
    try {
      const body = flattenToMessage(attachments, text);
      await onSend(body);
      setValue('');
      setAttachments([]);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'failed to send');
    } finally {
      setBusy(false);
    }
  }

  function handleSlashSelect(skillName: string) {
    const next = `/${skillName} `;
    setValue(next);
    setSlashState(null);
    requestAnimationFrame(() => {
      const ta = textareaRef.current;
      if (ta) {
        ta.selectionStart = ta.selectionEnd = next.length;
        ta.focus();
      }
    });
  }

  function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
    const mirror = document.createElement('div');
    const style = window.getComputedStyle(textarea);

    const properties = [
      'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
      'letterSpacing', 'lineHeight', 'textTransform', 'wordSpacing',
      'textIndent', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
      'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
      'boxSizing', 'whiteSpace', 'overflowWrap',
    ] as const;

    mirror.style.position = 'absolute';
    mirror.style.visibility = 'hidden';
    mirror.style.overflow = 'hidden';
    mirror.style.width = style.width;
    for (const prop of properties) {
      mirror.style[prop] = style[prop];
    }
    mirror.style.whiteSpace = 'pre-wrap';
    mirror.style.overflowWrap = 'break-word';

    const textBefore = textarea.value.slice(0, textarea.selectionStart);
    mirror.textContent = textBefore;

    const span = document.createElement('span');
    span.textContent = '​'; // zero-width space
    mirror.appendChild(span);

    document.body.appendChild(mirror);

    const taRect = textarea.getBoundingClientRect();
    const spanRect = span.getBoundingClientRect();
    const mirrorRect = mirror.getBoundingClientRect();

    const top = taRect.top + (spanRect.top - mirrorRect.top) - textarea.scrollTop + span.offsetHeight;
    const left = taRect.left + (spanRect.left - mirrorRect.left);

    document.body.removeChild(mirror);

    return { top, left };
  }

  function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
    const newValue = e.target.value;
    setValue(newValue);

    const ta = e.target;
    const pos = ta.selectionStart;

    // Batch 9.6: slash-command trigger. Active while the input is a single
    // slash-prefixed token with no whitespace (i.e. user is still typing the
    // skill name). Hand off to args mode the moment a space appears or the
    // slash leaves position 0.
    if (onSlashCommand && isSlashCommandToken(newValue)) {
      const query = slashQuery(newValue);
      if (!slashState) {
        setSlashState({ query });
      } else if (slashState.query !== query) {
        setSlashState({ query });
      }
      if (mentionState?.open) setMentionState(null);
      return;
    }
    if (slashState) setSlashState(null);

    // Check for @ trigger
    if (pos > 0 && newValue[pos - 1] === '@') {
      const charBefore = pos >= 2 ? newValue[pos - 2] : null;
      if (charBefore === null || charBefore === ' ' || charBefore === '\n') {
        const coords = getCaretCoords(ta);
        setMentionState({ open: true, query: '', atIdx: pos - 1, anchorRect: coords });
        if (!fileIndex) {
          api.projects.files(projectId).then(r => setFileIndex(r.files)).catch(() => {});
        }
        return;
      }
    }

    // Update query if popover is open — use stored atIdx
    if (mentionState?.open) {
      const { atIdx } = mentionState;
      if (atIdx < pos && newValue[atIdx] === '@') {
        const query = newValue.slice(atIdx + 1, pos);
        setMentionState(prev => prev ? { ...prev, query } : null);
      } else {
        setMentionState(null);
      }
    }
  }

  async function handleMentionSelect(path: string) {
    const atIdx = mentionState?.atIdx ?? -1;
    const ta = textareaRef.current;
    const caretPos = ta?.selectionStart ?? value.length;
    setMentionState(null);

    try {
      const result = await api.projects.viewFile(projectId, path);
      if (atIdx >= 0) {
        const cleaned = value.slice(0, atIdx) + value.slice(caretPos);
        setValue(cleaned);
        if (ta) {
          requestAnimationFrame(() => {
            ta.selectionStart = ta.selectionEnd = atIdx;
            ta.focus();
          });
        }
      }
      addAttachment({
        id: crypto.randomUUID(),
        kind: 'file',
        filename: path,
        language: inferLanguage(path),
        content: result.content,
        source: '@',
      });
    } catch {
      toast.error('Failed to load file');
    }
  }

  const closeMention = useCallback(() => setMentionState(null), []);

  // ---- Drag & drop (F1 + F3 + F4) ----------------------------------------
  // The drop zone is the outer ChatInput container (ref'd as dropRootRef).
  // onDragLeave only clears the highlight when the cursor leaves the
  // container, not when it crosses into a child element.

  async function processDroppedFile(file: File) {
    // Size gate
    if (file.size > MAX_FILE_SIZE_BYTES) {
      const mb = (file.size / (1024 * 1024)).toFixed(1);
      toast.error(`File ${file.name} is too large (${mb} MB). Limit is 5 MB.`);
      return;
    }
    // Read once as ArrayBuffer so we can do byte-level binary detection
    // before deciding whether to decode as text.
    let buf: ArrayBuffer;
    try {
      buf = await file.arrayBuffer();
    } catch (err) {
      toast.error(`Failed to read ${file.name}: ${err instanceof Error ? err.message : String(err)}`);
      return;
    }
    if (looksBinary(buf)) {
      toast.error(`${file.name} appears to be binary.`);
      return;
    }
    const text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
    addAttachment({
      id: crypto.randomUUID(),
      kind: 'file',
      filename: file.name,
      language: inferLanguage(file.name),
      content: text,
      source: 'drop',
    });
  }

  function isFolderItem(item: DataTransferItem | undefined): boolean {
    if (!item) return false;
    // webkitGetAsEntry is non-standard but supported in Chromium + Safari.
    // If unavailable, we conservatively treat the entry as a file.
    const entry =
      typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
    if (entry && entry.isDirectory) return true;
    // Heuristic fallback: folders dragged from Finder have type === '' and
    // a 0-byte File. The empty-type alone isn't reliable for files (some
    // plaintext drops also lack a type), so we only flag when the entry
    // explicitly says directory.
    return false;
  }

  async function handleDroppedItems(dt: DataTransfer) {
    // Snapshot items first because reading files inside the loop can
    // detach the DataTransfer between awaits.
    const itemsArray: { file: File | null; isFolder: boolean }[] = [];
    if (dt.items && dt.items.length > 0) {
      for (let i = 0; i < dt.items.length; i++) {
        const it = dt.items[i];
        if (!it || it.kind !== 'file') continue;
        const folder = isFolderItem(it);
        const file = folder ? null : it.getAsFile();
        itemsArray.push({ file, isFolder: folder });
      }
    } else {
      for (let i = 0; i < dt.files.length; i++) {
        const f = dt.files[i];
        if (f) itemsArray.push({ file: f, isFolder: false });
      }
    }

    let remainingSlots = MAX_ATTACHMENTS - attachments.length;
    let folderRejected = false;
    for (const { file, isFolder } of itemsArray) {
      if (isFolder) {
        if (!folderRejected) {
          toast.error('Folders are not supported');
          folderRejected = true;
        }
        continue;
      }
      if (!file) continue;
      if (remainingSlots <= 0) {
        toast.error(`Attachment limit reached (${MAX_ATTACHMENTS}).`);
        return;
      }
      await processDroppedFile(file);
      remainingSlots -= 1;
    }
  }

  function onDragEnter(e: DragEvent<HTMLDivElement>) {
    if (disabled || busy) return;
    e.preventDefault();
    setIsDraggingOver(true);
  }

  function onDragOver(e: DragEvent<HTMLDivElement>) {
    if (disabled || busy) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = 'copy';
  }

  function onDragLeave(e: DragEvent<HTMLDivElement>) {
    // Only clear when the cursor actually leaves the root container.
    // relatedTarget is the element being entered; if it's inside the root,
    // ignore — we're just crossing into a child.
    const root = dropRootRef.current;
    if (!root) return;
    const related = e.relatedTarget as Node | null;
    if (related && root.contains(related)) return;
    setIsDraggingOver(false);
  }

  function onDrop(e: DragEvent<HTMLDivElement>) {
    e.preventDefault();
    setIsDraggingOver(false);
    if (disabled || busy) return;
    void handleDroppedItems(e.dataTransfer);
  }
  // ---- end Drag & drop -----------------------------------------------------

  // ---- Paste-as-attachment (F2) -------------------------------------------
  // Pasting >PASTE_INLINE_MAX_LINES lines of text becomes a chip rather than
  // inline content. Image pastes are rejected with a toast. If both text and
  // image are present (e.g. screenshot tool that sets both), prefer text.

  function onPaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
    const cd = e.clipboardData;
    if (!cd) return;
    const text = cd.getData('text/plain');
    const hasImage = Array.from(cd.items ?? []).some((it) =>
      it.type.startsWith('image/'),
    );
    if (text) {
      const lineCount = text.split('\n').length;
      if (lineCount > PASTE_INLINE_MAX_LINES) {
        e.preventDefault();
        pasteCounterRef.current += 1;
        addAttachment({
          id: crypto.randomUUID(),
          kind: 'paste',
          filename: `pasted-${pasteCounterRef.current}.txt`,
          language: 'plaintext',
          content: text,
          source: 'paste',
        });
      }
      // <= threshold: let default paste insert inline.
      return;
    }
    if (hasImage) {
      e.preventDefault();
      toast.error('Image paste is not supported. Drop a file or paste text.');
    }
  }
  // ---- end Paste-as-attachment --------------------------------------------

  function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
    if (mentionState?.open) return;
    // SlashCommandPicker owns Arrow/Enter/Tab/Esc via a document listener; let
    // it consume them so the textarea doesn't also submit on Enter.
    if (slashState) return;
    // IME safety: never act on Enter while an IME composition is in flight
    // (CJK input methods commit composition via Enter). Without this, the
    // first Enter of a Japanese/Chinese/Korean composition would submit
    // instead of finalizing the candidate.
    if (e.nativeEvent.isComposing) return;
    if (e.key === 'Enter' && e.shiftKey && (e.metaKey || e.ctrlKey) && onForceSend) {
      e.preventDefault();
      void forceSubmit();
      return;
    }
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault();
      void submit();
      return;
    }
    // Bare Enter: sends on desktop, inserts a newline on mobile (per spec —
    // send is via the dedicated button on touch devices).
    if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
      e.preventDefault();
      void submit();
    }
  }

  async function forceSubmit() {
    const text = value.trim();
    if (!text || !onForceSend) return;
    if (busy) return;
    setBusy(true);
    try {
      const body = flattenToMessage(attachments, text);
      await onForceSend(body);
      setValue('');
      setAttachments([]);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'force send failed');
    } finally {
      setBusy(false);
    }
  }

  return (
    <div
      ref={dropRootRef}
      className="border-t relative"
      style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
      onDragEnter={onDragEnter}
      onDragOver={onDragOver}
      onDragLeave={onDragLeave}
      onDrop={onDrop}
    >
      <DropOverlay visible={isDraggingOver} />
      <div className="max-w-[1000px] mx-auto w-full">
      {attachments.length > 0 && (
        <div className="flex flex-wrap gap-1.5 px-4 pt-3">
          {attachments.map(a => (
            <AttachmentChip
              key={a.id}
              attachment={a}
              onRemove={removeAttachment}
              onPreview={setPreviewAttachment}
            />
          ))}
        </div>
      )}
      {/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
          inlines ContextBar in the same row so the bar lives next to the
          picker rather than as a separate header above it. The row renders
          when ANY of {picker, quick-toggle, ContextBar} is wanted. */}
      {(onAgentChange || sessionId || messages !== undefined) && (
        <div className="px-4 pt-2 flex items-center gap-1.5">
          {onAgentChange && (
            <AgentPicker
              projectId={projectId}
              value={agentId ?? null}
              onChange={onAgentChange}
            />
          )}
          {/* BooCode 2.0: the web-search toggle moved out of this top toolbar
              into the composer box's bottom controls row (the Web pill below),
              leaving the top row as just the agent picker + context bar. */}
          {/* v1.11.5.1: ContextBar fills the remaining horizontal space.
              `flex-1 min-w-0` is set inside the component. Mounts only when
              the caller passes `messages` so older call sites (without the
              prop) keep their original layout. */}
          {messages !== undefined && (
            <ContextBar messages={messages} modelContextLimit={modelContextLimit} />
          )}
        </div>
      )}
      {/* BooCode 2.0 composer: textarea + a bottom controls row live INSIDE one
          bordered, focus-ringed message box (Refreshed direction). */}
      <div className="px-4 py-3">
        <div className="rounded-xl border bg-card transition-colors focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/15">
          <Textarea
            ref={textareaRef}
            value={value}
            onChange={handleChange}
            onKeyDown={onKeyDown}
            onPaste={onPaste}
            placeholder={
              isMobile
                ? 'Ask about this project. Tap send to submit.'
                : 'Ask about this project. Enter to send · Shift+Enter for newline.'
            }
            disabled={disabled || busy}
            rows={3}
            className="resize-none min-h-[56px] max-h-[240px] border-0 bg-transparent px-3 pt-2.5 shadow-none focus-visible:ring-0 dark:bg-transparent"
          />
          {/* bottom controls row: attach + slash chip + Web on the left, Send/Stop on the right */}
          <div className="flex items-center gap-1.5 px-2 pb-2 pt-0.5">
            <input ref={fileInputRef} type="file" multiple className="hidden" onChange={onPickFiles} />
            <button
              type="button"
              onClick={() => fileInputRef.current?.click()}
              disabled={disabled || busy || attachments.length >= MAX_ATTACHMENTS}
              aria-label="Attach file"
              title="Attach file"
              className="inline-flex items-center justify-center rounded-full border border-border px-2.5 py-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50 max-md:min-h-[36px] max-md:min-w-[36px]"
            >
              <Paperclip className="size-3.5" />
            </button>
            {slashItems.length > 0 && (
              <button
                ref={cmdChipRef}
                type="button"
                onMouseDown={(e) => e.stopPropagation()}
                onClick={() => setCmdMenuOpen((v) => !v)}
                aria-expanded={cmdMenuOpen}
                aria-label="Slash commands"
                title="Slash commands"
                className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
              >
                <SquareSlash className="size-3.5" />
                <span className="max-md:hidden">{slashItems.length}</span>
              </button>
            )}
            {sessionId && (
              <button
                type="button"
                onClick={async () => {
                  // v1.9 tri-state collapses to two on toggle; null (inherit) → on.
                  const next = webSearchEnabled === true ? false : true;
                  try {
                    await api.sessions.update(sessionId, { web_search_enabled: next });
                  } catch (err) {
                    toast.error(err instanceof Error ? err.message : 'failed to toggle web search');
                  }
                }}
                aria-pressed={webSearchEnabled === true}
                title="Web search & fetch"
                className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors max-md:min-h-[36px] ${
                  webSearchEnabled === true
                    ? 'border-primary/40 bg-primary/10 text-primary'
                    : 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
                }`}
              >
                <Globe className="size-3.5" />
                Web
              </button>
            )}
            <div className="flex-1" />
            {(() => {
              const hasContent = value.trim().length > 0 || attachments.length > 0;
              // While generating with an empty draft, the button stops generation.
              if (generating && onStop && !hasContent) {
                return (
                  <Button
                    onClick={() => void onStop()}
                    size="icon"
                    variant="outline"
                    aria-label="Stop generating"
                    title="Stop generating"
                  >
                    <Square className="fill-current size-3.5" />
                  </Button>
                );
              }
              // With a draft, submit. While generating the caller queues it, so the
              // button reads as Queue; otherwise it's a normal Send.
              const queueing = !!generating && hasContent;
              return (
                <Button
                  onClick={() => void submit()}
                  disabled={disabled || busy || !hasContent}
                  size="icon"
                  variant={queueing ? 'secondary' : 'default'}
                  aria-label={queueing ? 'Queue message' : 'Send'}
                  title={queueing ? 'Queue message' : 'Send'}
                >
                  {queueing ? <ListPlus /> : <Send />}
                </Button>
              );
            })()}
          </div>
        </div>
      </div>
      </div>
      <AttachmentPreviewModal
        attachment={previewAttachment}
        onClose={() => setPreviewAttachment(null)}
      />
      {mentionState?.open && (
        <FileMentionPopover
          query={mentionState.query}
          files={fileIndex ?? []}
          anchorRect={mentionState.anchorRect}
          onSelect={handleMentionSelect}
          onClose={closeMention}
        />
      )}
      {slashState && (
        <SlashCommandPicker
          query={slashState.query}
          items={slashItems}
          groups={slashGroups}
          inputRef={textareaRef}
          onSelect={handleSlashSelect}
          onClose={() => setSlashState(null)}
          emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
        />
      )}
      {/* Slash-commands chip menu (click-opened); anchored to the chip. */}
      {cmdMenuOpen && slashItems.length > 0 && (
        <SlashCommandPicker
          query=""
          items={slashItems}
          groups={slashGroups}
          inputRef={cmdChipRef}
          onSelect={(name) => {
            setCmdMenuOpen(false);
            handleSlashSelect(name);
          }}
          onClose={() => setCmdMenuOpen(false)}
          emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
        />
      )}
    </div>
  );
}
