// ABOUTME: Chat interface for agent mode, displaying agent messages, tool calls, and diffs.
// ABOUTME: Handles agent session lifecycle and message streaming.

import { confirm } from "@tauri-apps/plugin-dialog";
import type { Component } from "solid-js";
import {
  createEffect,
  createMemo,
  createSignal,
  For,
  on,
  onCleanup,
  onMount,
  Show,
  untrack,
} from "solid-js";
import { createStore } from "solid-js/store";
import { AgentPermissionDialog } from "@/components/agent/AgentPermissionDialog";
import { DiffProposalDialog } from "@/components/agent/DiffProposalDialog";
import { VoiceInputButton } from "@/components/chat/VoiceInputButton";
import { ResizableTextarea } from "@/components/common/ResizableTextarea";
import { extractAgentThinkingMarkup } from "@/lib/agent-thinking-markup";
import { isAuthError, isLikelyAuthError } from "@/lib/auth-errors";
import { collapseBuildOutput } from "@/lib/build-output";
import {
  formatChatHistoryMarkdown,
  hasExportableMessages,
} from "@/lib/chat-history-export";
import {
  getCompletions,
  isInvokableSkill,
  parseCommand,
  resolveSkillCommand,
} from "@/lib/commands/parser";
import type { CommandContext } from "@/lib/commands/types";
import { collapseDirectoryListings } from "@/lib/directory-listing";
import { createDragDrop } from "@/lib/drag-drop";
import { escapeHtml } from "@/lib/escape-html";
import { openExternalLink } from "@/lib/external-link";
import { openFileInTab } from "@/lib/files/service";
import { formatDurationWithVerb } from "@/lib/format-duration";
import {
  isDocreaderMime,
  pickAndReadAttachments,
} from "@/lib/images/attachments";
import type { Attachment } from "@/lib/providers/types";
import {
  getModelDisplayName,
  mapAgentModelToChat,
} from "@/lib/rate-limit-fallback";
import { escapeHtmlWithSkillsAndLinks } from "@/lib/render-markdown";
import { verboseRuntimeConsole } from "@/lib/runtime-console";
import { saveToSerenNotes } from "@/lib/save-to-notes";
import {
  canAcceptSkillDrop,
  draftSkillInvocationFromDrag,
  setCurrentSkillDragPayload,
  skillDragPayload,
} from "@/lib/skill-drag";
import { skillCommandAliases, skillMatchesCommandAlias } from "@/lib/skills";
import {
  buildSkillInvocationDirective,
  buildSkillInvocationDisplay,
  RUN_SKILL_EVENT,
  type RunSkillEventDetail,
} from "@/lib/skills/invoke";
import {
  appendInputHistory,
  getInputHistory,
  getThreadDraft,
  setThreadDraft,
} from "@/lib/tauri-bridge";
import { readDocument } from "@/services/docreader";
import {
  type AgentType,
  type DiffEvent,
  launchLogin,
  supportsConversationFork,
  type ToolCallEvent,
} from "@/services/providers";
import { skills } from "@/services/skills";
import { type AgentMessage, agentStore } from "@/stores/agent.store";
import { fileTreeState } from "@/stores/fileTree";
import { settingsStore } from "@/stores/settings.store";
import { skillsStore } from "@/stores/skills.store";
import { threadStore } from "@/stores/thread.store";
import { workspaceStore } from "@/stores/workspace.store";
import RenderMarkdownWorker from "@/workers/render-markdown.worker?worker";
import { AgentEffortSelector } from "./AgentEffortSelector";
import { AgentFastModeSelector } from "./AgentFastModeSelector";
import { AgentModelSelector } from "./AgentModelSelector";
import { AgentModeSelector } from "./AgentModeSelector";
import {
  COMPOSER_TOOLBAR_LEFT_GROUP_CLASSES,
  COMPOSER_TOOLBAR_RIGHT_GROUP_CLASSES,
  COMPOSER_TOOLBAR_ROOT_CLASSES,
} from "./composerToolbarClasses";
import { DiffCard } from "./DiffCard";
import { ImageAttachmentBar } from "./ImageAttachmentBar";
import { PairedEffortSelector } from "./PairedEffortSelector";
import { PairedModelSelector } from "./PairedModelSelector";
import { PlanHeader } from "./PlanHeader";
import { SkillsButton } from "./SkillsButton";
import { SlashCommandPopup } from "./SlashCommandPopup";
import { ThinkingBlock } from "./ThinkingBlock";
import { ThinkingStatus } from "./ThinkingStatus";
import { ThreadProviderSwitcher } from "./ThreadProviderSwitcher";
import { ToolCallCard } from "./ToolCallCard";
import { ToolCallGroup } from "./ToolCallGroup";

interface AgentChatProps {
  onViewDiff?: (diff: DiffEvent) => void;
  threadId?: string;
  active?: boolean;
}

// Draft persistence is through SQLite (`get_thread_draft` / `set_thread_draft`) —
// survives hard crashes, force-quit, and relaunch. Per #1631.
const DRAFT_DEBOUNCE_MS = 500;

// threadQueues removed — prompt queue now lives in agentStore.sessions[id].pendingPrompts
// so background threads drain automatically on promptComplete.

export const AgentChat: Component<AgentChatProps> = (props) => {
  const isPaneActive = () => props.active ?? true;
  const [input, setInput] = createSignal("");
  const [attachedImages, setAttachedImages] = createSignal<Attachment[]>([]);
  const { isDragging } = createDragDrop((files) => {
    if (!isPaneActive()) return;
    setAttachedImages((prev) => [...prev, ...files]);
  });
  const [commandStatus, setCommandStatus] = createSignal<string | null>(null);
  const [commandPopupIndex, setCommandPopupIndex] = createSignal(0);
  const [historyIndex, setHistoryIndex] = createSignal(-1);
  const [savedInput, setSavedInput] = createSignal("");
  const [isAttaching, setIsAttaching] = createSignal(false);
  const [isProcessingDocs, setIsProcessingDocs] = createSignal(false);
  const [awaitingLogin, setAwaitingLogin] = createSignal<AgentType | null>(
    null,
  );
  let inputRef: HTMLTextAreaElement | undefined;
  let messagesRef: HTMLDivElement | undefined;
  let userHasScrolledUp = false;
  let prevThreadId: string | null = null;

  // Off-thread markdown rendering: finalized assistant messages are sent to a
  // Web Worker so renderMarkdown (marked + hljs) never blocks the main thread.
  const markdownWorker = new RenderMarkdownWorker();
  const [htmlCache, setHtmlCache] = createStore<Record<string, string>>({});
  // Slugs of currently-installed skills. Used by the transcript renderer to
  // decide which leading `/token` user messages should chip; random `/words`
  // in prose stay as plain text.
  const installedSkillSlugs = createMemo(
    () =>
      new Set(
        skillsStore.installed
          .filter(isInvokableSkill)
          .flatMap((skill) => skillCommandAliases(skill)),
      ),
  );

  const handleSkillChipClick = (event: MouseEvent) => {
    const target = event.target as HTMLElement | null;
    const chip = target?.closest<HTMLElement>("[data-skill-slug]");
    if (!chip) return;
    event.preventDefault();
    const slug = chip.dataset.skillSlug ?? "";
    if (!slug) return;
    const args = chip.dataset.skillArgs ?? "";
    setInput(buildSkillInvocationDisplay(slug, args || undefined));
    inputRef?.focus();
    const len = inputRef?.value.length ?? 0;
    inputRef?.setSelectionRange(len, len);
  };
  markdownWorker.onmessage = (
    e: MessageEvent<{ id: string; html: string; error?: boolean }>,
  ) => {
    if (e.data.error) {
      console.warn(
        `[AgentChat] Worker markdown render failed for message ${e.data.id}, using fallback`,
      );
    }
    setHtmlCache(e.data.id, e.data.html);
  };
  markdownWorker.onerror = (err) => {
    console.error("[AgentChat] Markdown worker error:", err.message);
  };
  onCleanup(() => markdownWorker.terminate());

  // Get the active agent thread (must be defined before thread-specific memos)
  const activeAgentThread = createMemo(() => {
    const thread = props.threadId
      ? threadStore.threads.find((candidate) => candidate.id === props.threadId)
      : threadStore.activeThread;
    if (thread?.kind !== "agent") return null;
    return thread;
  });

  // Per-thread composer draft (#1631) — persisted to SQLite so an app crash
  // before submit doesn't lose the user's typed text. Write is debounced to
  // 500ms so we don't hammer the DB on every keystroke.
  let draftWriteTimer: ReturnType<typeof setTimeout> | null = null;
  const schedulePersistDraft = (threadId: string, text: string) => {
    if (draftWriteTimer) clearTimeout(draftWriteTimer);
    draftWriteTimer = setTimeout(() => {
      void setThreadDraft(threadId, text);
      draftWriteTimer = null;
    }, DRAFT_DEBOUNCE_MS);
  };
  const flushDraft = async (threadId: string, text: string) => {
    if (draftWriteTimer) {
      clearTimeout(draftWriteTimer);
      draftWriteTimer = null;
    }
    await setThreadDraft(threadId, text);
  };

  createEffect(() => {
    const currentId = activeAgentThread()?.id ?? null;
    if (currentId !== prevThreadId) {
      if (prevThreadId) {
        const currentInput = untrack(input);
        // Flush pending debounce so nothing is lost across thread switches.
        void flushDraft(prevThreadId, currentInput);
      }
      // Read the persisted draft for the newly-selected thread.
      if (currentId) {
        setInput(""); // placeholder while async load resolves
        void getThreadDraft(currentId).then((draft) => {
          // Only hydrate if the thread is still the active one — racing
          // thread switches must not overwrite the user's live edits.
          if (activeAgentThread()?.id === currentId) setInput(draft);
        });
      } else {
        setInput("");
      }
      prevThreadId = currentId;
    }
  });

  // Debounced write on every input change while the thread stays active.
  createEffect(() => {
    const id = activeAgentThread()?.id;
    const text = input();
    if (id && id === prevThreadId) {
      schedulePersistDraft(id, text);
    }
  });

  onCleanup(() => {
    if (draftWriteTimer) clearTimeout(draftWriteTimer);
  });

  // Get messages for THIS thread's conversation ID, not the active session
  const threadMessages = createMemo(() => {
    const thread = activeAgentThread();
    if (!thread) return [];
    return agentStore.getMessagesForConversation(thread.id);
  });

  // Resolve the skill the composer's recent-skill button should label —
  // strictly "last actually invoked in this thread," derived from the
  // transcript. We deliberately do NOT track the composer's current draft:
  // the button body click recalls the labeled skill, and a drift between the
  // label and the click action makes an accidental click wipe whatever the
  // user had typed. The composer itself is the canonical surface for "what's
  // about to run if I hit Enter."
  const recentSkill = createMemo(() => {
    const messages = threadMessages();
    const slugs = installedSkillSlugs();
    if (slugs.size === 0) return null;
    for (let i = messages.length - 1; i >= 0; i -= 1) {
      const message = messages[i];
      if (message?.type !== "user") continue;
      const match = message.content.match(/^\/([a-zA-Z][a-zA-Z0-9:_-]*)/);
      if (!match) continue;
      const slug = match[1];
      if (!slug || !slugs.has(slug)) continue;
      const installed = skillsStore.installed.find(
        (s) => isInvokableSkill(s) && skillMatchesCommandAlias(s, slug),
      );
      if (installed) return installed;
    }
    return null;
  });

  // Get streaming content for THIS thread's conversation ID
  const threadStreamingContent = createMemo(() => {
    const thread = activeAgentThread();
    if (!thread) return "";
    return agentStore.getStreamingContentForConversation(thread.id);
  });

  // Enqueue finalized assistant messages to the render worker.
  // The worker returns HTML via onmessage → setHtmlCache → reactive DOM update.
  createEffect(() => {
    for (const msg of threadMessages()) {
      const visibleAssistantContent =
        msg.type === "assistant"
          ? extractAgentThinkingMarkup(msg.content).content
          : "";
      if (
        msg.type === "assistant" &&
        visibleAssistantContent &&
        untrack(() => htmlCache[msg.id]) === undefined
      ) {
        markdownWorker.postMessage({
          id: msg.id,
          markdown: visibleAssistantContent,
        });
      }
    }
  });

  // Get streaming thinking for THIS thread's conversation ID
  const threadStreamingThinking = createMemo(() => {
    const thread = activeAgentThread();
    if (!thread) return "";
    return agentStore.getStreamingThinkingForConversation(thread.id);
  });

  // Persisted per-conversation input history (oldest first, capped at 200).
  // Survives thread switches, compaction, and app restarts — decoupled from
  // session.messages which compaction drops.
  const [persistedInputs, setPersistedInputs] = createSignal<string[]>([]);

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

  // Build reversed list of user prompts for Up/Down arrow navigation.
  // Reads from persisted history so entries survive compaction + restart.
  const userMessageHistory = createMemo(() => [...persistedInputs()].reverse());

  const onPickImages = () => {
    if (!isPaneActive()) return;
    handleAttachImages();
  };

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

  const handleSkillDrop = async (event: DragEvent) => {
    const payload = skillDragPayload(event);
    if (!payload) return;
    // preventDefault unconditionally once we recognise a skill drop so the
    // textarea never falls back to inserting the raw `seren-skill:{...}` text.
    event.preventDefault();
    setCurrentSkillDragPayload(null);
    const threadId = activeAgentThread()?.id ?? null;
    if (!threadId) return;
    try {
      await draftSkillInvocationFromDrag(payload, {
        kind: "agent",
        threadId,
      });
    } catch (err) {
      console.error("[AgentChat] Failed to draft skill invocation:", err);
    }
  };
  const onSetChatInput = (event: Event) => {
    if (!isPaneActive()) return;
    // Skip if another handler already processed this event (prevents
    // duplicate sends when both AgentChat and ChatContent are mounted).
    if ((event as CustomEvent & { _handled?: boolean })._handled) return;

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

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

      // Auto-send if requested (e.g., from skill click)
      if (detail.autoSend) {
        (event as CustomEvent & { _handled?: boolean })._handled = true;
        // Use setTimeout to ensure input is set before sending
        setTimeout(() => {
          sendMessage();
        }, 0);
      }
    }
  };
  onMount(() => {
    window.addEventListener("seren:pick-images", onPickImages);
    window.addEventListener("seren:set-chat-input", onSetChatInput);
    if (isPaneActive() && fileTreeState.rootPath) {
      void agentStore.refreshRemoteSessions(
        fileTreeState.rootPath,
        agentStore.selectedAgentType,
      );
    }
  });
  onCleanup(() => {
    window.removeEventListener("seren:pick-images", onPickImages);
    window.removeEventListener("seren:set-chat-input", onSetChatInput);
  });

  // Resolve the session for the currently-viewed thread, not the global
  // active session. This prevents a running Claude session from bleeding into
  // the Codex thread's controls, ready-state, and send behaviour.
  const threadSession = createMemo(() => {
    const thread = activeAgentThread();
    if (!thread) return null;
    return agentStore.getSessionForConversation(thread.id);
  });
  const threadSessionId = createMemo(() => threadSession()?.info.id ?? null);

  createEffect(() => {
    const sessionId = threadSessionId();
    if (!isPaneActive()) return;
    if (sessionId && agentStore.activeSessionId !== sessionId) {
      agentStore.setActiveSession(sessionId);
    }
  });

  const forkSupported = createMemo(() => {
    const session = threadSession();
    if (!session) return false;
    return supportsConversationFork(session.info.agentType);
  });

  const hasSession = () => threadSession() !== null;
  const isPrompting = () => threadSession()?.info.status === "prompting";
  const messageQueue = () => {
    const id = threadSessionId();
    return id ? agentStore.getPendingPrompts(id) : [];
  };
  const promptStartTime = () => threadSession()?.promptStartTime;
  const sessionError = () => threadSession()?.error ?? agentStore.error;
  const lockedAgentType = createMemo<AgentType>(() => {
    // Thread's declared agent type takes priority so the controls always
    // reflect the selected thread, not a session from a different thread.
    const threadType = activeAgentThread()?.agentType;
    if (
      threadType === "codex" ||
      threadType === "claude-code" ||
      threadType === "gemini" ||
      threadType === "claude-codex"
    ) {
      return threadType;
    }
    const sessionAgent = threadSession()?.info.agentType;
    if (
      sessionAgent === "codex" ||
      sessionAgent === "claude-code" ||
      sessionAgent === "gemini" ||
      sessionAgent === "claude-codex"
    ) {
      return sessionAgent;
    }
    return agentStore.selectedAgentType;
  });
  const isPairedThread = createMemo(() => lockedAgentType() === "claude-codex");
  // Compact paired-workflow stage for the thread header (#2368).
  const pairedHeaderState = createMemo(() => {
    if (!isPairedThread()) return null;
    switch (threadSession()?.paired?.state) {
      case "planning":
        return "Claude planning";
      case "executing":
        return "Codex editing";
      case "reviewing":
        return "Claude reviewing";
      case "waiting-approval":
        return "Waiting for approval";
      default:
        return "Idle";
    }
  });
  const lockedAgentName = () => {
    switch (lockedAgentType()) {
      case "codex":
        return "Codex";
      case "gemini":
        return "Gemini";
      case "claude-codex":
        return "Claude + Codex";
      default:
        return "Claude Code";
    }
  };

  // Refresh project-scoped remote sessions (agent source-of-truth) and focus
  // any live session tied to the selected folder.
  // Skip refresh if a prompt is active to avoid backend rejection.
  createEffect(
    on(
      () =>
        [isPaneActive(), fileTreeState.rootPath, lockedAgentType()] as const,
      ([active, newPath, agentType]) => {
        if (!active) return;
        if (newPath && !isPrompting()) {
          void agentStore.refreshRemoteSessions(newPath, agentType);
          // Only auto-focus a project session when no thread is selected.
          // When the user already has an active thread, preserve it across
          // folder switches so agents persist regardless of file tree context.
          if (isPaneActive() && !threadStore.activeThreadId) {
            agentStore.focusProjectSession(newPath);
          }
        }
      },
      { defer: true },
    ),
  );

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

  const handleMessagesScroll = () => {
    if (!messagesRef) return;
    const { scrollTop, scrollHeight, clientHeight } = messagesRef;
    // Consider "near bottom" within 80px
    userHasScrolledUp = scrollHeight - scrollTop - clientHeight > 80;
  };

  // Reset scroll position when switching threads so the latest message is visible
  createEffect(() => {
    activeAgentThread();
    userHasScrolledUp = false;
    requestAnimationFrame(() => {
      if (messagesRef) messagesRef.scrollTop = messagesRef.scrollHeight;
    });
  });

  // Auto-scroll when messages change or permission dialogs appear
  createEffect(() => {
    threadMessages();
    threadStreamingContent();
    agentStore.pendingPermissions;
    agentStore.pendingDiffProposals;
    requestAnimationFrame(scrollToBottom);
  });

  // Clear "awaiting login" banner once a session starts
  createEffect(() => {
    if (hasSession()) setAwaitingLogin(null);
  });

  // Restart for the currently selected thread by resuming from SQLite.
  // The bare spawnSession(cwd, agentType) path silently dropped the
  // thread.id ↔ conversationId binding and stranded persisted history,
  // leaving the chat blank after Login / Restart Session. #2097.
  const startSession = async () => {
    const thread = activeAgentThread();
    if (!thread) {
      console.warn("[AgentChat] No active thread to start session");
      return;
    }
    try {
      const sessionId = await agentStore.resumeAgentConversation(thread.id);
      verboseRuntimeConsole.debug("[AgentChat] Session resumed:", sessionId);
    } catch (error) {
      console.error("[AgentChat] Failed to resume session:", error);
    }
  };

  const handleAttachImages = async () => {
    verboseRuntimeConsole.debug(
      "[AgentChat] handleAttachImages called, hasSession:",
      hasSession(),
    );

    // Prevent multiple concurrent attach operations
    if (isAttaching()) {
      verboseRuntimeConsole.debug("[AgentChat] Already attaching, skipping");
      return;
    }

    setIsAttaching(true);
    try {
      const files = await pickAndReadAttachments();
      verboseRuntimeConsole.debug(
        "[AgentChat] pickAndReadAttachments returned:",
        files.length,
        "files",
        files.map((f) => f.name),
      );
      if (files.length > 0) {
        setAttachedImages((prev) => {
          const newAttachments = [...prev, ...files];
          verboseRuntimeConsole.debug(
            "[AgentChat] Updating attachedImages, prev:",
            prev.length,
            "adding:",
            files.length,
            "total:",
            newAttachments.length,
          );
          return newAttachments;
        });
        // Log the current state after the update
        verboseRuntimeConsole.debug(
          "[AgentChat] attachedImages after update:",
          attachedImages().length,
        );
      } else {
        verboseRuntimeConsole.debug(
          "[AgentChat] No files selected or dialog cancelled",
        );
      }
    } catch (error) {
      console.error("[AgentChat] handleAttachImages error:", error);
      // Show error status to user
      setCommandStatus(
        `Failed to attach files: ${error instanceof Error ? error.message : "Unknown error"}`,
      );
      setTimeout(() => setCommandStatus(null), 5000);
    } finally {
      setIsAttaching(false);
    }
  };

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

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

    const markdown = formatChatHistoryMarkdown(messages, {
      header: "# Agent Chat History",
    });

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

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

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

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

    const markdown = formatChatHistoryMarkdown(messages, {
      header: "# Agent Chat History",
      exportedAt: now,
    });

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

  const clearHistory = async () => {
    const confirmClear = await confirm(
      "Clear all agent chat history for this session?",
      { title: "Clear Agent Chat", kind: "warning" },
    );
    if (!confirmClear) return;

    const session = threadSession();
    if (!session) return;

    // Clear messages in the active session
    agentStore.clearSessionMessages(session.info.id);
  };

  const compactConversation = async (preserveCount: number) => {
    const session = threadSession();
    if (!session) return;

    const messages = threadMessages();
    if (messages.length <= preserveCount) {
      alert("Not enough messages to compact");
      return;
    }

    const confirmCompact = await confirm(
      `Compact older messages, preserving the most recent ${preserveCount}?`,
      { title: "Compact Conversation", kind: "warning" },
    );
    if (!confirmCompact) return;

    await agentStore.compactAgentConversation(session.info.id, preserveCount);
  };

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

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

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

  const sendMessage = async () => {
    const trimmed = input().trim();
    const images = attachedImages();
    verboseRuntimeConsole.debug("[AgentChat] sendMessage called:", {
      hasText: !!trimmed,
      attachments: images.length,
      mimeTypes: images.map((a) => a.mimeType),
      hasSession: hasSession(),
      isPrompting: isPrompting(),
    });

    // Cold-start path (#1631): if the thread has no live session yet,
    // turn on the thinking indicator and spawn synchronously so the user
    // bubble + dots appear before the first stream chunk arrives.
    const thread = activeAgentThread();
    if (!hasSession()) {
      if (!trimmed) {
        console.warn(
          "[AgentChat] sendMessage aborted: empty input (no session)",
        );
        return;
      }
      if (!thread) {
        console.warn("[AgentChat] sendMessage aborted: no active agent thread");
        return;
      }
      if (!fileTreeState.rootPath) {
        console.warn("[AgentChat] sendMessage aborted: no open folder");
        return;
      }
      agentStore.setTurnInFlight(thread.id, true);
      agentStore.armRestartTimer(thread.id, 60_000, "spawn_failed");
      try {
        const sid = await agentStore.spawnSession(
          fileTreeState.rootPath,
          lockedAgentType(),
          { localSessionId: thread.id },
        );
        // User pressed Stop mid-spawn — abortTurn flipped turnInFlight off.
        // Honor the cancel: terminate the spawn we just created (if any) and
        // bail before dispatching the prompt. Without this, the async spawn
        // resolves after Cancel and the user's prompt sneaks through.
        if (!agentStore.isTurnInFlight(thread.id)) {
          verboseRuntimeConsole.debug(
            "[AgentChat] cold-start cancelled during spawn",
          );
          if (sid) await agentStore.terminateSession(sid);
          return;
        }
        if (!sid) {
          console.warn("[AgentChat] cold-start spawn failed");
          agentStore.setTurnError(thread.id, "spawn_failed");
          return;
        }
      } catch (err) {
        console.error("[AgentChat] cold-start spawn threw:", err);
        // If the user cancelled, don't overwrite their cancel with an error.
        if (!agentStore.isTurnInFlight(thread.id)) return;
        agentStore.setTurnError(
          thread.id,
          "spawn_failed",
          err instanceof Error ? err.message : String(err),
        );
        return;
      }
    }

    // Require text content even when images are attached
    if (!trimmed) {
      if (images.length > 0) {
        console.warn(
          "[AgentChat] sendMessage aborted: no text with",
          images.length,
          "attachments",
        );
        setCommandStatus(
          "Please add text to your message. Image-only messages are not supported.",
        );
        setTimeout(() => setCommandStatus(null), 4000);
      } else {
        console.warn("[AgentChat] sendMessage aborted: empty input");
      }
      return;
    }

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

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

        setInput("");
        setHistoryIndex(-1);
        setSavedInput("");
        userHasScrolledUp = false;

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

        const directive = buildSkillInvocationDirective({
          slug: skill.slug,
          content: skillContent,
          args,
        });

        await agentStore.sendPrompt(
          directive,
          undefined,
          {
            displayContent: trimmed,
          },
          threadSessionId() ?? undefined,
        );
        return;
      }
    }

    // If agent is prompting, queue the message in the store
    if (isPrompting()) {
      const sid = threadSessionId();
      if (sid) {
        agentStore.enqueuePrompt(sid, trimmed);
      }
      // Persist to input history even on the queued path — the user typed and
      // submitted this, so up-arrow recall must see it (#1624).
      const convId = activeAgentThread()?.id ?? null;
      if (convId) {
        setPersistedInputs((prev) => {
          const next = [...prev, trimmed];
          return next.length > 200 ? next.slice(-200) : next;
        });
        void appendInputHistory(convId, trimmed).catch((err) => {
          console.warn("[AgentChat] Failed to persist input history:", err);
        });
      }
      setInput("");
      setHistoryIndex(-1);
      setSavedInput("");
      verboseRuntimeConsole.debug("[AgentChat] Message queued:", trimmed);
      return;
    }

    // Mark the turn in-flight now so the late-cancel guard below distinguishes
    // "user actively submitting" from "user pressed Stop mid-awaits." Cold-start
    // already set this true inside the !hasSession() block; this is a no-op
    // there but ensures the warm path also flips it. Without this, the guard
    // aborted every warm-path send because turnInFlight stayed false. #1631 hotfix.
    if (thread) {
      agentStore.setTurnInFlight(thread.id, true);
    }

    // Split attachments: images go as agent context blocks; docreader files get extracted to text
    const imageAttachments = images.filter((a) => !isDocreaderMime(a.mimeType));
    const docAttachments = images.filter((a) => isDocreaderMime(a.mimeType));
    verboseRuntimeConsole.debug("[AgentChat] Attachment split:", {
      imageAttachments: imageAttachments.length,
      docAttachments: docAttachments.map((d) => d.name),
    });

    // Process documents BEFORE clearing state so input/attachments are preserved on failure
    let promptWithDocs = trimmed;
    if (docAttachments.length > 0) {
      setIsProcessingDocs(true);
      setCommandStatus("Processing documents…");
      verboseRuntimeConsole.debug(
        "[AgentChat] Starting DocReader processing for",
        docAttachments.length,
        "documents",
      );
      try {
        const docBlocks = await Promise.all(
          docAttachments.map(async (doc: Attachment) => {
            verboseRuntimeConsole.debug(
              "[AgentChat] Processing document:",
              doc.name,
              doc.mimeType,
              "base64 length:",
              doc.base64.length,
            );
            const text = await readDocument(doc);
            verboseRuntimeConsole.debug(
              "[AgentChat] DocReader success for",
              doc.name,
              "extracted",
              text.length,
              "chars",
            );
            return `[${doc.name}]\n${text}`;
          }),
        );
        promptWithDocs = `${docBlocks.join("\n\n---\n\n")}\n\n${trimmed}`;
      } catch (err) {
        console.error("[AgentChat] DocReader failed:", err);
        setCommandStatus(
          err instanceof Error ? err.message : "Failed to read document.",
        );
        setTimeout(() => setCommandStatus(null), 6000);
        setIsProcessingDocs(false);
        return; // Input and attachments are intentionally preserved
      }
      setCommandStatus(null);
      setIsProcessingDocs(false);
      verboseRuntimeConsole.debug(
        "[AgentChat] DocReader complete, prompt length:",
        promptWithDocs.length,
      );
    }

    const convId = activeAgentThread()?.id ?? null;
    if (convId) {
      setPersistedInputs((prev) => {
        const next = [...prev, trimmed];
        return next.length > 200 ? next.slice(-200) : next;
      });
      void appendInputHistory(convId, trimmed).catch((err) => {
        console.warn("[AgentChat] Failed to persist input history:", err);
      });
    }

    setInput("");
    setAttachedImages([]);
    setHistoryIndex(-1);
    setSavedInput("");
    userHasScrolledUp = false;

    const context: Array<Record<string, string>> | undefined =
      imageAttachments.length > 0
        ? imageAttachments.map((img) => ({
            type: "image",
            data: img.base64,
            mimeType: img.mimeType,
          }))
        : undefined;

    // Late-cancel guard (#1631): Stop may fire between the cold-start spawn
    // and the sendPrompt dispatch, or during an awaited skill/doc load.
    // Honor the cancel before dispatching so the prompt never leaks through.
    if (thread && !agentStore.isTurnInFlight(thread.id)) {
      verboseRuntimeConsole.debug(
        "[AgentChat] sendMessage: cancel detected before dispatch — skipping sendPrompt",
      );
      return;
    }

    verboseRuntimeConsole.debug(
      "[AgentChat] Sending prompt to agent runtime, context blocks:",
      context?.length ?? 0,
    );
    // Real agent activity in this thread bumps its folder's sidebar rank
    // (#2095). Selection clicks intentionally do not — only sends do.
    if (thread) threadStore.noteThreadActivity(thread.id);
    const docNames =
      docAttachments.length > 0 ? docAttachments.map((d) => d.name) : undefined;
    await agentStore.sendPrompt(
      promptWithDocs,
      context,
      {
        displayContent: trimmed,
        docNames,
      },
      threadSessionId() ?? undefined,
    );
  };

  // Guard flag prevents concurrent queue processing

  // Queue drain is handled by the store's promptComplete handler.
  // No component-level drain logic needed.

  const handleCancel = async () => {
    // abortTurn covers every restart-dependent path: it cancels any warming
    // standby, clears the queue, cancels the live turn, and flips turnInFlight
    // off — no error-bubble side-effect because this is a user cancel. #1631.
    const tid = activeAgentThread()?.id;
    if (tid) {
      await agentStore.abortTurn(tid);
      return;
    }
    const sid = threadSessionId();
    if (sid) agentStore.clearPromptQueue(sid);
    await agentStore.cancelPrompt(sid ?? undefined);
  };

  const [forking, setForking] = createSignal(false);

  const handleForkFromMessage = async (messageId: string) => {
    const session = threadSession();
    if (!session || forking()) return;

    setForking(true);
    try {
      await threadStore.forkAgentThread(session.conversationId, messageId);
    } catch (e) {
      console.error("[AgentChat] Fork failed:", e);
    } finally {
      setForking(false);
    }
  };

  const handleGlobalKeyDown = (event: KeyboardEvent) => {
    if (!isPaneActive()) return;
    if (event.key === "Escape" && isPrompting()) {
      event.preventDefault();
      handleCancel();
    }
  };

  const handleRunSkillEvent = (event: Event) => {
    const detail = (event as CustomEvent<RunSkillEventDetail>).detail;
    if (detail?.kind !== "agent") return;
    const thread = activeAgentThread();
    if (!thread || thread.id !== detail.threadId) return;

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

  onMount(() => {
    document.addEventListener("keydown", handleGlobalKeyDown);
    window.addEventListener(RUN_SKILL_EVENT, handleRunSkillEvent);
  });

  onCleanup(() => {
    document.removeEventListener("keydown", handleGlobalKeyDown);
    window.removeEventListener(RUN_SKILL_EVENT, handleRunSkillEvent);
  });

  const handleKeyDown = (event: KeyboardEvent) => {
    // Slash command popup keyboard navigation
    const isSlashInput = input().startsWith("/") && !input().includes(" ");
    if (isSlashInput) {
      const matches = getCompletions(input(), "agent");
      if (matches.length > 0) {
        if (event.key === "ArrowUp") {
          event.preventDefault();
          setCommandPopupIndex((i) => (i > 0 ? i - 1 : matches.length - 1));
          return;
        }
        if (event.key === "ArrowDown") {
          event.preventDefault();
          setCommandPopupIndex((i) => (i < matches.length - 1 ? i + 1 : 0));
          return;
        }
        if (event.key === "Tab" || (event.key === "Enter" && !event.shiftKey)) {
          event.preventDefault();
          const selected = matches[commandPopupIndex()];
          if (selected) {
            if (event.key === "Tab" || selected.argHint) {
              setInput(`/${selected.name} `);
            } else {
              executeSlashCommand(`/${selected.name}`);
            }
            setCommandPopupIndex(0);
          }
          return;
        }
        if (event.key === "Escape") {
          event.preventDefault();
          setInput("");
          return;
        }
      }
    }

    // Up arrow: navigate to older prompts
    const history = userMessageHistory();
    if (event.key === "ArrowUp" && history.length > 0) {
      const textarea = event.currentTarget as HTMLTextAreaElement;
      if (textarea.selectionStart === 0 || input() === "") {
        event.preventDefault();
        if (historyIndex() === -1) {
          setSavedInput(input());
        }
        const newIndex = Math.min(historyIndex() + 1, history.length - 1);
        setHistoryIndex(newIndex);
        setInput(history[newIndex] ?? "");
        queueMicrotask(() => {
          textarea.setSelectionRange(
            textarea.value.length,
            textarea.value.length,
          );
        });
      }
    }

    // Down arrow: navigate to newer prompts
    if (event.key === "ArrowDown" && historyIndex() >= 0) {
      const textarea = event.currentTarget as HTMLTextAreaElement;
      if (textarea.selectionStart === textarea.value.length) {
        event.preventDefault();
        const newIndex = historyIndex() - 1;
        setHistoryIndex(newIndex);
        if (newIndex < 0) {
          setInput(savedInput());
          setSavedInput("");
        } else {
          setInput(history[newIndex] ?? "");
        }
        queueMicrotask(() => {
          textarea.setSelectionRange(
            textarea.value.length,
            textarea.value.length,
          );
        });
      }
    }

    if (event.key === "Enter" && !event.shiftKey) {
      event.preventDefault();
      sendMessage();
    }
  };

  type GroupedMessage =
    | { type: "single"; message: AgentMessage }
    | {
        type: "tool_group";
        /** Stable identity across regroup remounts (#1748). */
        id: string;
        messages: AgentMessage[];
        toolCalls: ToolCallEvent[];
      };

  const flushToolGroup = (
    group: AgentMessage[],
    out: GroupedMessage[],
  ): void => {
    if (group.length === 0) return;
    if (group.length >= 3) {
      const toolCalls = group
        .filter((m) => m.toolCall)
        .map((m) => m.toolCall as ToolCallEvent);
      const id = toolCalls[0]?.toolCallId ?? group[0].id;
      out.push({ type: "tool_group", id, messages: group, toolCalls });
    } else {
      for (const msg of group) {
        out.push({ type: "single", message: msg });
      }
    }
  };

  /** Group consecutive tool messages into collapsed groups */
  const groupConsecutiveToolCalls = createMemo(() => {
    const messages = threadMessages();
    const grouped: GroupedMessage[] = [];
    let currentGroup: AgentMessage[] = [];

    for (const message of messages) {
      if (message.type === "tool" && message.toolCall) {
        currentGroup.push(message);
      } else {
        flushToolGroup(currentGroup, grouped);
        currentGroup = [];
        grouped.push({ type: "single", message });
      }
    }
    flushToolGroup(currentGroup, grouped);

    return grouped;
  });

  const renderMessage = (message: AgentMessage, isLastMessage = false) => {
    switch (message.type) {
      case "user": {
        // Inline terminal-error state (#1631): a 2px red left-border + "Couldn't
        // send. Retry" link on the most-recent user bubble when the turn fails
        // beyond the invisibility budget. Scope is the turn, not the app.
        const tid = activeAgentThread()?.id;
        const showTurnError = () => {
          if (!isLastMessage || !tid) return false;
          return agentStore.getTurnError(tid) !== null;
        };
        return (
          <article
            class="chat-message-row group/msg relative px-5 py-4 bg-surface-1 border-b border-surface-2 [contain:layout]"
            classList={{ "border-l-2 border-l-destructive": showTurnError() }}
          >
            <Show when={message.docNames?.length}>
              <div class="flex flex-wrap gap-1.5 mb-2">
                <For each={message.docNames}>
                  {(name: string) => (
                    <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-surface-2 text-muted-foreground border border-border">
                      <svg
                        aria-hidden="true"
                        width="12"
                        height="12"
                        viewBox="0 0 16 16"
                        fill="currentColor"
                        class="shrink-0 opacity-60"
                      >
                        <path d="M4 1h5.586L13 4.414V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zm5 1H4v12h8V5H9V2z" />
                      </svg>
                      {name}
                    </span>
                  )}
                </For>
              </div>
            </Show>
            <div
              class="chat-message-content leading-relaxed text-foreground whitespace-pre-wrap"
              innerHTML={escapeHtmlWithSkillsAndLinks(
                message.content,
                installedSkillSlugs(),
              )}
            />
            <Show when={forkSupported()}>
              <button
                type="button"
                class="absolute top-2 right-2 hidden group-hover/msg:inline-flex items-center gap-1 px-2 py-1 rounded text-xs bg-surface-2 border border-border text-muted-foreground cursor-pointer transition-all hover:bg-surface-3 hover:text-foreground"
                onClick={() => handleForkFromMessage(message.id)}
                title="Fork conversation from here"
              >
                <svg
                  aria-hidden="true"
                  width="12"
                  height="12"
                  viewBox="0 0 16 16"
                  fill="currentColor"
                  class="shrink-0"
                >
                  <path d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM5 12.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm7-7.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM2.75 3.25a1.5 1.5 0 0 0 2.75.8v4.9a1.5 1.5 0 1 0-1.5 0v-4.9a1.5 1.5 0 0 0-1.25-.8Zm8.5 0a1.5 1.5 0 0 1-1.25.8v2.7a.75.75 0 0 1-1.5 0v-2.7a1.5 1.5 0 1 1 2.75-.8Z" />
                </svg>
                Fork
              </button>
            </Show>
            <Show when={showTurnError()}>
              <div class="mt-2 text-xs text-destructive">
                Couldn't send.{" "}
                <button
                  type="button"
                  class="underline bg-transparent border-none text-destructive cursor-pointer p-0 hover:brightness-125"
                  onClick={() => {
                    const id = activeAgentThread()?.id;
                    if (!id) return;
                    void agentStore.retryLastPrompt(id);
                  }}
                >
                  Retry
                </button>
              </div>
            </Show>
          </article>
        );
      }

      case "assistant": {
        const normalized = extractAgentThinkingMarkup(message.content);
        const visibleContent = normalized.content;
        // Paired threads label every assistant-visible message with the
        // producing agent so Jill never has to infer who is talking (#2368).
        const pairedAttribution = () => {
          if (!isPairedThread()) return null;
          switch (message.provider) {
            case "claude-code":
              return "Claude";
            case "codex":
              return "Codex";
            case "seren":
              return "Seren";
            default:
              return null;
          }
        };
        return (
          <article class="chat-message-row group/msg relative px-5 py-4 border-b border-surface-2 [contain:layout]">
            <Show when={pairedAttribution()}>
              <div
                class="mb-1.5 text-[11px] font-semibold uppercase tracking-[0.04em] text-muted-foreground"
                data-testid="paired-attribution"
              >
                {pairedAttribution()}
              </div>
            </Show>
            <Show when={normalized.thinking}>
              <div class={visibleContent ? "mb-3" : ""}>
                <ThinkingBlock thinking={normalized.thinking} />
              </div>
            </Show>
            <Show
              when={isLastMessage && isLikelyAuthError(message.content)}
              fallback={
                <Show when={visibleContent}>
                  <div
                    class="chat-message-content leading-relaxed text-foreground break-words [&_p]:m-0 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_h1]:text-[1.3em] [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2 [&_h2]:text-[1.15em] [&_h2]:font-bold [&_h2]:mt-3 [&_h2]:mb-2 [&_h3]:text-[1.05em] [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-1 [&_h4]:text-[1em] [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1 [&_code]:bg-surface-2 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:font-mono [&_code]:text-[0.92em] [&_pre]:bg-surface-1 [&_pre]:border [&_pre]:border-border [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-[0.92em] [&_pre_code]:leading-normal [&_ul]:my-2 [&_ul]:pl-6 [&_ol]:my-2 [&_ol]:pl-6 [&_li]:my-1 [&_blockquote]:border-l-[3px] [&_blockquote]:border-border [&_blockquote]:my-3 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_a]:text-primary [&_a]:no-underline [&_a:hover]:underline"
                    innerHTML={collapseBuildOutput(
                      collapseDirectoryListings(
                        htmlCache[message.id] ??
                          escapeHtml(visibleContent).replace(/\n/g, "<br>"),
                      ),
                    )}
                  />
                </Show>
              }
            >
              <div class="px-3 py-2 border rounded-md text-sm bg-warning/10 border-warning/40 text-warning">
                <div class="flex items-center justify-between gap-2">
                  <span>
                    Authentication expired. Please log in to continue.
                  </span>
                  <button
                    type="button"
                    class="px-2 py-1 text-xs font-medium bg-warning text-background rounded hover:brightness-110 flex-shrink-0"
                    onClick={async () => {
                      const agentType =
                        threadSession()?.info.agentType ??
                        agentStore.selectedAgentType;
                      launchLogin(agentType);
                      const sid = threadSessionId();
                      if (sid) {
                        await agentStore.terminateSession(sid);
                      }
                      agentStore.clearError();
                      setAwaitingLogin(agentType);
                    }}
                  >
                    Login
                  </button>
                </div>
              </div>
            </Show>
            <Show when={message.duration}>
              {(() => {
                const { verb, duration, costDisplay } = formatDurationWithVerb(
                  message.duration ?? 0,
                  message.cost,
                );
                return (
                  <div class="mt-2 text-xs text-muted-foreground">
                    ✻ {verb} for {duration}
                    {costDisplay && ` at ${costDisplay}`}
                  </div>
                );
              })()}
            </Show>
            <Show when={forkSupported()}>
              <button
                type="button"
                class="absolute top-2 right-2 hidden group-hover/msg:inline-flex items-center gap-1 px-2 py-1 rounded text-xs bg-surface-2 border border-border text-muted-foreground cursor-pointer transition-all hover:bg-surface-3 hover:text-foreground"
                onClick={() => handleForkFromMessage(message.id)}
                title="Fork conversation from here"
              >
                <svg
                  aria-hidden="true"
                  width="12"
                  height="12"
                  viewBox="0 0 16 16"
                  fill="currentColor"
                  class="shrink-0"
                >
                  <path d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM5 12.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm7-7.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM2.75 3.25a1.5 1.5 0 0 0 2.75.8v4.9a1.5 1.5 0 1 0-1.5 0v-4.9a1.5 1.5 0 0 0-1.25-.8Zm8.5 0a1.5 1.5 0 0 1-1.25.8v2.7a.75.75 0 0 1-1.5 0v-2.7a1.5 1.5 0 1 1 2.75-.8Z" />
                </svg>
                Fork
              </button>
            </Show>
          </article>
        );
      }

      case "thought":
        return (
          <article class="chat-message-row px-5 py-3 border-b border-surface-2">
            <ThinkingBlock thinking={message.content} />
          </article>
        );

      case "tool":
        return message.toolCall ? (
          <div class="chat-tool-row px-5 py-2">
            <ToolCallCard toolCall={message.toolCall} />
          </div>
        ) : null;

      case "diff":
        return message.diff ? (
          <div class="chat-tool-row px-5 py-2">
            <DiffCard diff={message.diff} onViewInEditor={props.onViewDiff} />
          </div>
        ) : null;

      case "handoff":
        // Lightweight paired-workflow activity line: ownership change
        // between Claude and Codex, scannable but part of the transcript.
        return (
          <div
            class="chat-message-row px-5 py-2 border-b border-surface-2 flex items-center gap-2"
            data-testid="paired-handoff"
          >
            <span class="text-[12px]" aria-hidden="true">
              {"\u{1F91D}"}
            </span>
            <span class="text-xs text-muted-foreground italic">
              {message.content}
            </span>
          </div>
        );

      case "error":
        return (
          <article class="chat-message-row px-5 py-3 border-b border-surface-2">
            <div
              class={`px-3 py-2 border rounded-md text-sm ${
                isAuthError(message.content)
                  ? "bg-warning/10 border-warning/40 text-warning"
                  : "bg-destructive/10 border-destructive/40 text-destructive"
              }`}
            >
              <Show
                when={isAuthError(message.content)}
                fallback={<span>{message.content}</span>}
              >
                <div class="flex items-center justify-between gap-2">
                  <span>
                    Authentication expired. Please log in to continue.
                  </span>
                  <button
                    type="button"
                    class="px-2 py-1 text-xs font-medium bg-warning text-background rounded hover:brightness-110 flex-shrink-0"
                    onClick={async () => {
                      const agentType =
                        threadSession()?.info.agentType ??
                        agentStore.selectedAgentType;
                      launchLogin(agentType);
                      const sid = threadSessionId();
                      if (sid) {
                        await agentStore.terminateSession(sid);
                      }
                      agentStore.clearError();
                      setAwaitingLogin(agentType);
                    }}
                  >
                    Login
                  </button>
                </div>
              </Show>
            </div>
          </article>
        );

      default:
        return null;
    }
  };

  return (
    <div
      class="chat-surface relative flex-1 flex flex-col min-h-0"
      onClick={handleSkillChipClick}
    >
      <Show when={isDragging() && isPaneActive()}>
        <div class="absolute inset-0 bg-primary/10 border-2 border-dashed border-primary/50 rounded-sm z-50 pointer-events-none flex items-center justify-center">
          <span class="text-primary text-sm font-medium bg-background/90 px-3 py-1.5 rounded-md shadow-sm">
            Drop files to attach
          </span>
        </div>
      </Show>
      {/* Plan Header */}
      <PlanHeader />

      {/* Paired workflow header — thread title + compact active stage (#2368) */}
      <Show when={isPairedThread()}>
        <div
          class="flex items-center justify-between px-4 py-1.5 border-b border-surface-3 bg-surface-1"
          data-testid="paired-thread-header"
        >
          <span class="text-xs font-medium text-foreground">
            {"\u{1F91D}"} Claude + Codex
          </span>
          <span class="text-[11px] text-muted-foreground">
            {pairedHeaderState()}
          </span>
        </div>
      </Show>

      {/* Action Buttons Header */}
      <Show when={threadMessages().length > 0}>
        <div class="flex items-center justify-end gap-2 px-4 py-2 border-b border-surface-3">
          <Show when={!isPairedThread()}>
            <button
              type="button"
              class="bg-transparent border border-border text-muted-foreground px-2 py-1 rounded text-xs cursor-pointer transition-all hover:bg-surface-2 hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
              onClick={() =>
                compactConversation(
                  settingsStore.get("autoCompactPreserveMessages"),
                )
              }
              disabled={threadSession()?.isCompacting}
              title={
                threadSession()?.isCompacting
                  ? "Compacting..."
                  : "Compact older messages"
              }
            >
              {threadSession()?.isCompacting ? "Compacting..." : "Compact"}
            </button>
          </Show>
          <button
            type="button"
            class="bg-transparent border border-border text-muted-foreground p-1.5 rounded text-xs cursor-pointer transition-all hover:bg-surface-2 hover:text-foreground"
            onClick={copyAllChatHistory}
            title="Copy all chat history"
          >
            <svg
              class="w-3.5 h-3.5"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
              role="img"
              aria-label="Copy"
            >
              <title>Copy chat history</title>
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-width="2"
                d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
              />
            </svg>
          </button>
          <button
            type="button"
            class="bg-transparent border border-border text-muted-foreground p-1.5 rounded text-xs cursor-pointer transition-all hover:bg-surface-2 hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
            onClick={downloadChatHistory}
            disabled={isSaving()}
            title="Download chat history"
          >
            <svg
              class="w-3.5 h-3.5"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
              role="img"
              aria-label="Download"
            >
              <title>Download chat history</title>
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-width="2"
                d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
              />
            </svg>
          </button>
          <button
            type="button"
            class="bg-transparent border border-border text-muted-foreground px-2 py-1 rounded text-xs cursor-pointer transition-all hover:bg-surface-2 hover:text-foreground"
            onClick={clearHistory}
          >
            Clear
          </button>
        </div>
      </Show>

      {/* Messages Area */}
      <div
        ref={messagesRef}
        onScroll={handleMessagesScroll}
        class="flex-1 min-h-0 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-surface-3 [&::-webkit-scrollbar-thumb]:rounded"
        onContextMenu={(e) => {
          if ((e.target as HTMLElement).closest(".external-link")) {
            e.preventDefault();
          }
        }}
        onClick={(e) => {
          const target = e.target as HTMLElement;
          const link = target.closest(".external-link") as HTMLAnchorElement;
          if (link) {
            e.preventDefault();
            const url = link.dataset.externalUrl;
            if (url) {
              // Detect local file paths and open them in the editor
              const isFilePath =
                url.startsWith("/") ||
                url.startsWith("./") ||
                url.startsWith("../") ||
                url.startsWith("~");
              if (isFilePath && !url.startsWith("//")) {
                const cwd = threadSession()?.cwd;
                const resolved =
                  url.startsWith("/") || url.startsWith("~")
                    ? url
                    : cwd
                      ? `${cwd.replace(/\/$/, "")}/${url}`
                      : url;
                openFileInTab(resolved, { cwd: cwd ?? undefined })
                  .then(() => {
                    workspaceStore.bindEditorToWorkspace(resolved);
                  })
                  .catch((err) =>
                    console.warn(
                      "[AgentChat] Failed to open file:",
                      resolved,
                      err,
                    ),
                  );
              } else {
                openExternalLink(url);
              }
            }
            return;
          }
          const copyBtn = target.closest(".code-copy-btn") as HTMLElement;
          if (copyBtn) {
            const code = copyBtn.dataset.code;
            if (code) {
              navigator.clipboard.writeText(code);
              const original = copyBtn.innerHTML;
              copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path></svg> Copied!`;
              copyBtn.classList.add("copied");
              setTimeout(() => {
                copyBtn.innerHTML = original;
                copyBtn.classList.remove("copied");
              }, 2000);
            }
          }
        }}
      >
        {/* Session Messages */}
        <Show
          when={threadMessages().length > 0 || threadStreamingContent()}
          fallback={
            <div class="flex flex-col items-center justify-center p-10 text-muted-foreground">
              <h3 class="m-0 mb-2 text-base font-medium text-foreground">
                Agent Ready
              </h3>
              <p class="m-0 text-sm text-center max-w-[280px]">
                Describe what you'd like the agent to do. It can read files,
                make edits, run commands, and more.
              </p>
            </div>
          }
        >
          <For each={groupConsecutiveToolCalls()}>
            {(item, index) => {
              const isLast = () =>
                index() === groupConsecutiveToolCalls().length - 1;
              if (item.type === "tool_group") {
                return (
                  <ToolCallGroup
                    groupId={item.id}
                    toolCalls={item.toolCalls}
                    isComplete={true}
                  />
                );
              }
              return renderMessage(item.message, isLast());
            }}
          </For>

          {/* Loading placeholder while waiting for first chunk.
              Thinking dots follow `turnInFlight` — continuous across session
              boundaries (predictive promotion, reactive retry, crash re-dispatch)
              so the user never sees the indicator flicker off mid-restart. #1631. */}
          <Show
            when={(() => {
              const thread = activeAgentThread();
              return (
                thread &&
                agentStore.isTurnInFlight(thread.id) &&
                !threadStreamingContent() &&
                !threadStreamingThinking()
              );
            })()}
          >
            <article class="chat-message-row px-5 py-4 border-b border-surface-2">
              <ThinkingStatus startTime={promptStartTime} />
            </article>
          </Show>

          {/* Streaming Thinking */}
          <Show when={threadStreamingThinking()}>
            <article class="chat-message-row px-5 py-3 border-b border-surface-2">
              <ThinkingBlock
                thinking={threadStreamingThinking()}
                isStreaming={true}
              />
            </article>
          </Show>

          {/* Streaming Content */}
          <Show when={threadStreamingContent()}>
            <article class="chat-message-row px-5 py-4 border-b border-surface-2">
              <div
                class="chat-message-content leading-relaxed text-foreground break-words [&_p]:m-0 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_h1]:text-[1.3em] [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2 [&_h2]:text-[1.15em] [&_h2]:font-bold [&_h2]:mt-3 [&_h2]:mb-2 [&_h3]:text-[1.05em] [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-1 [&_h4]:text-[1em] [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1 [&_code]:bg-surface-2 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:font-mono [&_code]:text-[0.92em] [&_pre]:bg-surface-1 [&_pre]:border [&_pre]:border-border [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-[0.92em] [&_pre_code]:leading-normal [&_ul]:my-2 [&_ul]:pl-6 [&_ol]:my-2 [&_ol]:pl-6 [&_li]:my-1 [&_blockquote]:border-l-[3px] [&_blockquote]:border-border [&_blockquote]:my-3 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_a]:text-primary [&_a]:no-underline [&_a:hover]:underline"
                textContent={threadStreamingContent()}
              />
              <span class="inline-block w-2 h-4 ml-0.5 bg-primary animate-pulse" />
            </article>
          </Show>
        </Show>
      </div>

      {/* Permission and diff proposal dialogs — rendered outside the scroll
          container so they stay visible while the agent streams content.
          Filter by the thread-specific session, not the global activeSessionId,
          to prevent permissions from one session bleeding into another thread. */}
      <Show
        when={
          agentStore.pendingDiffProposals.some(
            (p) => p.sessionId === threadSession()?.info.id,
          ) ||
          agentStore.pendingPermissions.some(
            (p) => p.sessionId === threadSession()?.info.id,
          )
        }
      >
        <div class="border-t border-border bg-background max-h-[40vh] overflow-y-auto">
          <For
            each={agentStore.pendingDiffProposals.filter(
              (p) => p.sessionId === threadSession()?.info.id,
            )}
          >
            {(proposal) => (
              <div class="chat-tool-row px-5 py-2">
                <DiffProposalDialog proposal={proposal} />
              </div>
            )}
          </For>
          <For
            each={agentStore.pendingPermissions.filter(
              (p) => p.sessionId === threadSession()?.info.id,
            )}
          >
            {(perm) => (
              <div class="chat-tool-row px-5 py-2">
                <AgentPermissionDialog permission={perm} />
              </div>
            )}
          </For>
        </div>
      </Show>

      {/* Agent Fallback Banner (rate limit or context window full) */}
      <Show when={agentStore.agentFallbackNeeded}>
        {(() => {
          const session = threadSession();
          const agentType =
            session?.info.agentType ?? agentStore.selectedAgentType;
          const agentModelId = session?.currentModelId;
          const chatModelId = mapAgentModelToChat(agentModelId, agentType);
          const modelName = getModelDisplayName(chatModelId);
          const agentName =
            agentType === "codex"
              ? "Codex"
              : agentType === "gemini"
                ? "Gemini"
                : agentType === "claude-codex"
                  ? "Claude + Codex"
                  : "Claude Code";
          const reason = agentStore.agentFallbackReason;
          const title =
            reason === "prompt_too_long"
              ? `${agentName}'s context window is full`
              : `${agentName} hit its rate limit`;
          const description =
            reason === "prompt_too_long"
              ? `Automatically switching to Chat mode with ${modelName}. Your conversation history will be preserved.`
              : `Automatically switching to Chat mode with ${modelName}. Your conversation history will be preserved.`;
          return (
            <div class="mx-4 mb-2 px-3 py-3 border rounded-md text-sm bg-primary/10 border-primary/40 text-primary">
              <div class="flex items-start gap-3">
                <svg
                  class="w-5 h-5 mt-0.5 flex-shrink-0"
                  fill="currentColor"
                  viewBox="0 0 20 20"
                  role="img"
                  aria-label="Info"
                >
                  <path
                    fill-rule="evenodd"
                    d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
                    clip-rule="evenodd"
                  />
                </svg>
                <div class="flex-1">
                  <p class="m-0 mb-2 font-medium text-foreground">{title}</p>
                  <p class="m-0 text-xs text-muted-foreground">{description}</p>
                </div>
                <button
                  type="button"
                  class="px-2 py-1 text-xs font-medium bg-transparent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
                  onClick={() => agentStore.dismissRateLimitPrompt()}
                  title="Dismiss"
                >
                  ✕
                </button>
              </div>
            </div>
          );
        })()}
      </Show>

      {/* Error Display */}
      <Show when={sessionError()}>
        <div
          class={`mx-4 mb-2 px-3 py-2 border rounded-md text-sm ${
            isAuthError(sessionError())
              ? "bg-warning/10 border-warning/40 text-warning"
              : "bg-destructive/10 border-destructive/40 text-destructive"
          }`}
        >
          <div class="flex items-start justify-between gap-2">
            <span class="flex-1 max-h-28 overflow-y-auto break-words">
              {isAuthError(sessionError())
                ? "Authentication expired. Please log in again to continue."
                : sessionError()}
            </span>
            <div class="flex items-center gap-2 shrink-0">
              <Show when={isAuthError(sessionError())}>
                <button
                  type="button"
                  class="px-2 py-1 text-xs font-medium bg-warning text-background rounded hover:brightness-110"
                  onClick={async () => {
                    const agentType =
                      threadSession()?.info.agentType ??
                      agentStore.selectedAgentType;
                    launchLogin(agentType);
                    const sid = threadSessionId();
                    if (sid) {
                      await agentStore.terminateSession(sid);
                    }
                    agentStore.clearError();
                    setAwaitingLogin(agentType);
                  }}
                >
                  Login
                </button>
              </Show>
              <Show
                when={
                  !isAuthError(sessionError()) &&
                  threadSession()?.info.status === "error"
                }
              >
                <button
                  type="button"
                  class="text-xs underline hover:no-underline"
                  onClick={async () => {
                    const sid = threadSessionId();
                    if (sid) {
                      await agentStore.terminateSession(sid);
                    }
                    agentStore.clearError();
                    startSession();
                  }}
                >
                  Restart Session
                </button>
              </Show>
              <button
                type="button"
                class="text-xs underline hover:no-underline"
                onClick={() => agentStore.clearError()}
              >
                Dismiss
              </button>
            </div>
          </div>
          <Show when={isAuthError(sessionError())}>
            <p class="mt-1 text-xs opacity-80">
              Click "Login" to open authentication. Once complete, click "Start
              Session" to continue.
            </p>
          </Show>
        </div>
      </Show>

      {/* Awaiting Login Banner */}
      <Show when={awaitingLogin()}>
        <div class="mx-4 mb-2 px-3 py-2 border rounded-md text-sm bg-success/10 border-success/40 text-success">
          <div class="flex items-center justify-between">
            <span class="flex-1">
              Complete authentication in the opened window, then start a new
              session.
            </span>
            <div class="flex items-center gap-2 ml-2">
              <button
                type="button"
                class="px-2 py-1 text-xs font-medium bg-success text-background rounded hover:brightness-110"
                onClick={() => {
                  setAwaitingLogin(null);
                  startSession();
                }}
              >
                Start Session
              </button>
              <button
                type="button"
                class="text-xs underline hover:no-underline"
                onClick={() => setAwaitingLogin(null)}
              >
                Dismiss
              </button>
            </div>
          </div>
        </div>
      </Show>

      {/* Agent CWD Display */}
      <Show when={hasSession() && agentStore.cwd}>
        <div class="shrink-0 px-4 py-1.5 border-t border-surface-2 bg-background flex items-center gap-2 text-xs text-muted-foreground">
          <svg
            class="w-3 h-3 flex-shrink-0"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
            role="img"
            aria-label="Folder"
          >
            <path
              stroke-linecap="round"
              stroke-linejoin="round"
              stroke-width="2"
              d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
            />
          </svg>
          <span class="truncate" title={agentStore.cwd ?? ""}>
            {agentStore.cwd}
          </span>
        </div>
      </Show>

      {/* Input Area */}
      <Show when={hasSession()}>
        <div class="shrink-0 border-t border-surface-2 bg-surface-1">
          <form
            class="chat-composer-form flex flex-col gap-2 px-4 pb-4 pt-1.5"
            onSubmit={(e) => {
              e.preventDefault();
              sendMessage();
            }}
          >
            <ImageAttachmentBar
              images={attachedImages()}
              onAttach={handleAttachImages}
              onRemove={handleRemoveImage}
              isLoading={isAttaching()}
            />
            <div class="relative">
              <SlashCommandPopup
                input={input() ?? ""}
                panel="agent"
                selectedIndex={commandPopupIndex()}
                onSelect={(cmd) => {
                  if (cmd.argHint) {
                    setInput(`/${cmd.name} `);
                    inputRef?.focus();
                  } else {
                    executeSlashCommand(`/${cmd.name}`);
                  }
                  setCommandPopupIndex(0);
                }}
              />
              <ResizableTextarea
                workspaceDefaultFocus={hasSession()}
                ref={(el) => (inputRef = el)}
                value={input() ?? ""}
                placeholder={
                  isPrompting()
                    ? "Type to queue message..."
                    : "Tell the agent what to do… (type / for commands)"
                }
                class="w-full bg-background border border-border rounded-xl text-foreground px-3.5 py-3 font-inherit text-[14px] leading-normal transition-all duration-150 focus:outline-none focus:border-primary/60 focus:shadow-[var(--input-focus-glow)] placeholder:text-muted-foreground/60 disabled:opacity-60 disabled:cursor-not-allowed"
                minHeight={80}
                maxHeight={window.innerHeight * 0.5}
                onInput={(e) => {
                  setInput(e.currentTarget.value);
                  setCommandPopupIndex(0);
                }}
                onDragOver={handleSkillDragOver}
                onDrop={(event) => void handleSkillDrop(event)}
                onKeyDown={handleKeyDown}
                disabled={!hasSession()}
              />
            </div>
            <Show when={commandStatus()}>
              <div class="px-3 py-2 bg-surface-2 border border-border rounded-lg text-xs text-muted-foreground whitespace-pre-wrap">
                {commandStatus()}
              </div>
            </Show>
            <div class={COMPOSER_TOOLBAR_ROOT_CLASSES}>
              <div class={COMPOSER_TOOLBAR_LEFT_GROUP_CLASSES}>
                <Show
                  when={props.threadId}
                  fallback={
                    <span class="px-2 py-1 bg-surface-2 border border-surface-3 rounded-md text-xs text-foreground font-medium">
                      {lockedAgentName()}
                    </span>
                  }
                >
                  {(threadId) => (
                    <ThreadProviderSwitcher threadId={threadId()} />
                  )}
                </Show>
                <Show
                  when={isPairedThread()}
                  fallback={
                    <>
                      <AgentModelSelector session={threadSession()} />
                      <AgentModeSelector session={threadSession()} />
                      <AgentFastModeSelector session={threadSession()} />
                      <AgentEffortSelector session={threadSession()} />
                    </>
                  }
                >
                  <PairedModelSelector
                    session={threadSession()}
                    pairedRole="planner"
                  />
                  <PairedModelSelector
                    session={threadSession()}
                    pairedRole="executor"
                  />
                  <PairedEffortSelector
                    session={threadSession()}
                    pairedRole="planner"
                  />
                  <PairedEffortSelector
                    session={threadSession()}
                    pairedRole="executor"
                  />
                </Show>
                <SkillsButton
                  recentSkill={recentSkill()}
                  onLaunch={() => {
                    setInput("/");
                    setCommandPopupIndex(0);
                    inputRef?.focus();
                    const len = inputRef?.value.length ?? 0;
                    inputRef?.setSelectionRange(len, len);
                  }}
                  onRecall={(skill) => {
                    // Same shape as the transcript chip click: fill the
                    // composer with `/slug ` and let the user add args or
                    // hit Enter to send. No auto-submit.
                    setInput(`${buildSkillInvocationDisplay(skill.slug)} `);
                    setCommandPopupIndex(0);
                    inputRef?.focus();
                    const len = inputRef?.value.length ?? 0;
                    inputRef?.setSelectionRange(len, len);
                  }}
                />

                <Show when={messageQueue().length > 0}>
                  <span class="flex items-center gap-2 px-2 py-1 bg-surface-2 border border-border rounded text-xs text-muted-foreground">
                    {messageQueue().length} message
                    {messageQueue().length > 1 ? "s" : ""} queued
                    <button
                      type="button"
                      class="text-destructive hover:underline"
                      onClick={() => {
                        const sid = threadSessionId();
                        if (sid) agentStore.clearPromptQueue(sid);
                      }}
                    >
                      Clear
                    </button>
                  </span>
                </Show>
              </div>
              <div class={COMPOSER_TOOLBAR_RIGHT_GROUP_CLASSES}>
                <VoiceInputButton
                  mode="agent"
                  onTranscript={(text) => {
                    setInput((prev) => (prev ? `${prev} ${text}` : text));
                    if (settingsStore.get("voiceAutoSubmit")) {
                      sendMessage();
                    } else {
                      inputRef?.focus();
                    }
                  }}
                />
                <Show
                  when={isPrompting()}
                  fallback={
                    <button
                      type="submit"
                      class="px-4 py-1.5 bg-success text-white rounded-md text-[13px] font-medium hover:bg-emerald-700 transition-colors disabled:bg-surface-2 disabled:text-muted-foreground disabled:cursor-not-allowed"
                      disabled={
                        !hasSession() ||
                        !(input() ?? "").trim() ||
                        isProcessingDocs()
                      }
                    >
                      Send
                    </button>
                  }
                >
                  <button
                    type="button"
                    class="px-4 py-1.5 bg-destructive text-white rounded-md text-[13px] font-medium hover:bg-destructive transition-colors"
                    onClick={handleCancel}
                  >
                    Cancel
                  </button>
                </Show>
              </div>
            </div>
          </form>
        </div>
      </Show>
    </div>
  );
};
