import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { Minus, Plus } from "lucide-react";
import ChatPanel, {
  type ChatMessage,
  type ImageContent,
} from "@/components/ChatPanel";
import QueenSessionSwitcher from "@/components/QueenSessionSwitcher";
import { executionApi } from "@/api/execution";
import { sessionsApi } from "@/api/sessions";
import { queensApi } from "@/api/queens";
import { useMultiSSE } from "@/hooks/use-sse";
import { usePendingQueue } from "@/hooks/use-pending-queue";
import type { AgentEvent, HistorySession } from "@/api/types";
import {
  newReplayState,
  newTokenAccumulator,
  replayEvent,
  replayEventsToMessages,
} from "@/lib/chat-helpers";
import { useColony } from "@/context/ColonyContext";
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
import { useHeaderActions } from "@/context/HeaderActionsContext";
import { getQueenForAgent, slugToColonyId } from "@/lib/colony-registry";

const makeId = () => Math.random().toString(36).slice(2, 9);

// Remembers the last session the user had open in each queen DM so that
// navigating away (e.g. to another queen) and back lands on the session
// they were just in, instead of whichever session the server picks.
const lastSessionKey = (queenId: string) => `hive:queen:${queenId}:lastSession`;
const readLastSession = (queenId: string): string | null => {
  try {
    return localStorage.getItem(lastSessionKey(queenId));
  } catch {
    return null;
  }
};
const writeLastSession = (queenId: string, sessionId: string) => {
  try {
    localStorage.setItem(lastSessionKey(queenId), sessionId);
  } catch {
    /* storage disabled/full — best-effort */
  }
};
const clearLastSession = (queenId: string) => {
  try {
    localStorage.removeItem(lastSessionKey(queenId));
  } catch {
    /* ignore */
  }
};

export default function QueenDM() {
  const { queenId } = useParams<{ queenId: string }>();
  const [searchParams, setSearchParams] = useSearchParams();
  const { queens, queenProfiles, refresh } = useColony();
  const { setActions } = useHeaderActions();
  const profileQueen = queenProfiles.find((q) => q.id === queenId);
  const colonyQueen = queens.find((q) => q.id === queenId);
  const queenInfo = getQueenForAgent(queenId || "");
  const queenName = profileQueen?.name ?? colonyQueen?.name ?? queenInfo.name;
  const selectedSessionParam = searchParams.get("session");
  const newSessionFlag = searchParams.get("new");

  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [sessionId, setSessionId] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const [queenReady, setQueenReady] = useState(false);
  const [isTyping, setIsTyping] = useState(false);
  const [isStreaming, setIsStreaming] = useState(false);
  const [pendingQuestions, setPendingQuestions] = useState<
    { id: string; prompt: string; options?: string[] }[] | null
  >(null);
  const [awaitingInput, setAwaitingInput] = useState(false);
  // `cached` and `cacheCreated` are subsets of `input` (providers count both
  // inside prompt_tokens already) — display them, never add them to a total.
  // `costUsd` is the session-total USD cost when the provider supplies one
  // (Anthropic, OpenAI, OpenRouter); 0 means unreported, not free.
  const [tokenUsage, setTokenUsage] = useState({
    input: 0,
    output: 0,
    cached: 0,
    cacheCreated: 0,
    costUsd: 0,
  });
  const [historySessions, setHistorySessions] = useState<HistorySession[]>([]);
  const [historyLoading, setHistoryLoading] = useState(false);
  const [switchingSessionId, setSwitchingSessionId] = useState<string | null>(
    null,
  );
  const [creatingNewSession, setCreatingNewSession] = useState(false);
  const [initialDraft, setInitialDraft] = useState<string | null>(null);
  const [cloneDialogOpen, setCloneDialogOpen] = useState(false);
  const [cloneColonyName, setCloneColonyName] = useState("");
  const [cloneTask, setCloneTask] = useState("");
  const [cloneOutputs, setCloneOutputs] = useState("");
  const [cloneDataSources, setCloneDataSources] = useState("");
  const [cloneSchedule, setCloneSchedule] = useState("");
  const [cloneConcurrency, setCloneConcurrency] = useState("");
  const [showCloneOutputs, setShowCloneOutputs] = useState(false);
  const [showCloneDataSources, setShowCloneDataSources] = useState(false);
  const [showCloneSchedule, setShowCloneSchedule] = useState(false);
  const [showCloneConcurrency, setShowCloneConcurrency] = useState(false);
  // Colony-spawned lock state. Once a colony has been spawned from this DM
  // and the user clicked into it, /chat is rejected server-side and the
  // composer is replaced with a "compact + new session" button. Hydrated
  // from the session detail and updated optimistically on click.
  const [colonySpawned, setColonySpawned] = useState(false);
  const [spawnedColonyName, setSpawnedColonyName] = useState<string | null>(
    null,
  );
  const [compactingAndForking, setCompactingAndForking] = useState(false);

  const replayStateRef = useRef(newReplayState());
  // Flipped true by the auto-flush path; consumed by the next empty-prompt
  // client_input_requested so we don't flicker the typing bubble off while
  // the queen is about to resume on the flushed input.
  const queenAboutToResumeRef = useRef(false);
  // Question bubble for an ask_user that's actively awaiting an answer. We
  // stash it here instead of pushing it into messages so the user only sees
  // ONE copy of the question (the popup widget) while answering. Committed
  // to the transcript on client_input_received so the bubble lands right
  // above the user's answer for scroll-back context.
  const pendingAskUserBubbleRef = useRef<ChatMessage | null>(null);
  const [queenPhase, setQueenPhase] = useState<
    "independent" | "incubating" | "working" | "reviewing"
  >("independent");

  // Publish the active session id into the shared workers/tasks context
  // so AppLayout's right-rail TaskListPanel can attach to it. The colony
  // workers panel itself stays hidden in queen-DM because we don't set
  // colonyName (AppLayout requires both — see LayoutShell).
  const { setSessionId: setCtxSessionId } = useColonyWorkers();
  useEffect(() => {
    setCtxSessionId(sessionId ?? null);
    return () => setCtxSessionId(null);
  }, [sessionId, setCtxSessionId]);

  const resetViewState = useCallback(() => {
    setSessionId(null);
    setMessages([]);
    setQueenReady(false);
    setIsTyping(false);
    setIsStreaming(false);
    setPendingQuestions(null);
    setAwaitingInput(false);
    setQueenPhase("independent");
    setTokenUsage({ input: 0, output: 0, cached: 0, cacheCreated: 0, costUsd: 0 });
    setInitialDraft(null);
    setColonySpawned(false);
    setSpawnedColonyName(null);
    setCompactingAndForking(false);
    replayStateRef.current = newReplayState();
  }, []);

  const upsertMessage = useCallback(
    (chatMsg: ChatMessage, options?: { reconcileOptimisticUser?: boolean }) => {
      setMessages((prev) => {
        const idx = prev.findIndex((m) => m.id === chatMsg.id);
        if (idx >= 0) {
          return prev.map((m, i) =>
            i === idx ? { ...chatMsg, createdAt: m.createdAt ?? chatMsg.createdAt } : m,
          );
        }
        if (options?.reconcileOptimisticUser && chatMsg.type === "user") {
          const incomingTs = chatMsg.createdAt ?? Date.now();
          const matchIdx = prev.findIndex(
            (m) =>
              m.type === "user" &&
              m.content === chatMsg.content &&
              Math.abs(incomingTs - (m.createdAt ?? incomingTs)) <= 15000,
          );
          if (matchIdx !== -1) {
            return prev.map((m, i) =>
              i === matchIdx ? { ...m, id: chatMsg.id, queued: undefined } : m,
            );
          }
        }

        const ts = chatMsg.createdAt ?? Date.now();
        let insertIdx = prev.length - 1;
        while (insertIdx >= 0 && (prev[insertIdx].createdAt ?? 0) > ts) {
          insertIdx--;
        }
        if (insertIdx === -1 || insertIdx === prev.length - 1) {
          return [...prev, chatMsg];
        }
        const next = [...prev];
        next.splice(insertIdx + 1, 0, chatMsg);
        return next;
      });
    },
    [],
  );

  const restoreMessages = useCallback(
    async (sid: string, cancelled: () => boolean) => {
      try {
        const { events, truncated, total, returned } =
          await sessionsApi.eventsHistory(sid);
        if (cancelled()) return;

        // Use the stateful replay so tool_status pills are synthesized
        // the same way the live SSE handler does — without this the
        // refreshed queen DM shows zero tool activity. The token
        // accumulator folds the llm_turn_complete sum into the same
        // pass so we don't iterate the (potentially large) event array
        // twice. SSE does not replay llm_turn_complete (see
        // routes_events.py _REPLAY_TYPES), so no double-count risk —
        // live SSE deltas that may have already landed are kept via the
        // functional merge below.
        const replayState = newReplayState();
        const seed = newTokenAccumulator();
        const restored = replayEventsToMessages(
          events,
          "queen-dm",
          queenName,
          undefined,
          replayState,
          seed,
        );
        replayStateRef.current = replayState;

        if (!cancelled()) {
          setTokenUsage((prev) => ({
            input: prev.input + seed.input,
            output: prev.output + seed.output,
            cached: prev.cached + seed.cached,
            cacheCreated: prev.cacheCreated + seed.cacheCreated,
            costUsd: prev.costUsd + seed.costUsd,
          }));
        }

        // Show a banner if the server truncated older events.
        const droppedCount = Math.max(0, total - returned);
        if (truncated && droppedCount > 0) {
          const firstTs = events[0]?.timestamp;
          const bannerCreatedAt = firstTs
            ? new Date(firstTs).getTime() - 1
            : 0;
          restored.unshift({
            id: `restore-truncated-${sid}`,
            agent: "System",
            agentColor: "",
            type: "run_divider",
            content: `${droppedCount.toLocaleString()} older event${droppedCount === 1 ? "" : "s"} not shown (showing last ${returned.toLocaleString()})`,
            timestamp: firstTs ?? new Date().toISOString(),
            thread: "queen-dm",
            createdAt: bannerCreatedAt,
          });
        }
        if (restored.length > 0 && !cancelled()) {
          setMessages(restored);
          // Only clear typing if the history contains a completed execution;
          // during bootstrap the queen is still processing.
          const hasCompleted = events.some(
            (e: AgentEvent) => e.type === "execution_completed",
          );
          if (hasCompleted) {
            setIsTyping(false);
          }
        }
      } catch {
        // No history
      }
    },
    [queenName],
  );

  useEffect(() => {
    if (!queenId) return;

    // If we arrived without an explicit session in the URL and aren't
    // bootstrapping a new one, redirect to the last session the user had
    // open for this queen. Session IDs are always of the form
    // "session_<timestamp>_<hex>", so we gate on that prefix to avoid
    // redirecting to anything unexpected that landed in storage.
    if (!selectedSessionParam && newSessionFlag !== "1") {
      const stored = readLastSession(queenId);
      if (stored && stored.startsWith("session_")) {
        setSearchParams({ session: stored }, { replace: true });
        return;
      }
    }

    resetViewState();
    setLoading(true);

    let cancelled = false;
    const isBootstrap = newSessionFlag === "1";
    // Consume the pending first message up-front so this bootstrap is one-shot:
    // a re-run after URL rewrite or a browser refresh won't re-fill the composer.
    const pendingFirstMessage = isBootstrap
      ? sessionStorage.getItem(`queenFirstMessage:${queenId}`)
      : null;
    if (isBootstrap && pendingFirstMessage !== null) {
      sessionStorage.removeItem(`queenFirstMessage:${queenId}`);
    }

    (async () => {
      try {
        let bootstrapSessionId: string | null = null;
        if (isBootstrap) {
          // Pass the pending message as initial_prompt so the queen
          // processes it immediately (no phantom "Hello" greeting).
          const bootstrapResult = await queensApi.createNewSession(
            queenId,
            pendingFirstMessage ?? undefined,
            "independent",
          );
          bootstrapSessionId = bootstrapResult.session_id;
        } else if (selectedSessionParam) {
          await queensApi.selectSession(queenId, selectedSessionParam);
        }
        if (cancelled) return;
        let sid: string;

        // Fast path: if we have a session_id in URL from home screen (just created),
        // use it directly without an extra API call. The session is already live.
        // This eliminates the 10-13s delay from the unnecessary selectSession API call.
        if (
          selectedSessionParam &&
          selectedSessionParam.startsWith("session_")
        ) {
          sid = selectedSessionParam;
          setSessionId(sid);
          setQueenReady(true);
          setIsTyping(true);
          setLoading(false); // Hide loading immediately - SSE will connect now
          // Don't await restoreMessages - let it happen in background
          restoreMessages(sid, () => cancelled).then(() => refresh());
          return;
        }

        if (selectedSessionParam) {
          // Resume historical session - need to verify ownership via API
          const result = await queensApi.selectSession(
            queenId,
            selectedSessionParam,
          );
          if (cancelled) return;
          sid = result.session_id;
          setSessionId(sid);
          setQueenReady(true);
          setIsTyping(true);

          if (selectedSessionParam !== sid) {
            setSearchParams({ session: sid }, { replace: true });
          }
        } else {
          // Bootstrap uses the session id from createNewSession directly so a
          // stale live session for this queen can't steal the flow. Otherwise
          // fall back to get-or-create.
          if (bootstrapSessionId) {
            sid = bootstrapSessionId;
          } else {
            const result = await queensApi.getOrCreateSession(
              queenId,
              undefined,
              "independent",
            );
            if (cancelled) return;
            sid = result.session_id;
          }
          setSessionId(sid);
          setQueenReady(true);

          if (isBootstrap) {
            // Swap ?new=1 for ?session={sid} so a browser refresh rehydrates
            // this session instead of creating another new one.
            setSearchParams({ session: sid }, { replace: true });

            // Message was passed as initial_prompt so the queen is already
            // processing it. Show the user bubble and typing indicator.
            if (pendingFirstMessage && !cancelled) {
              const userMsg: ChatMessage = {
                id: makeId(),
                agent: "You",
                agentColor: "",
                content: pendingFirstMessage,
                timestamp: "",
                type: "user",
                thread: "queen-dm",
                createdAt: Date.now(),
              };
              setMessages((prev) => [...prev, userMsg]);
              setIsTyping(true);
            }
          } else {
            setIsTyping(true);
          }

          if (!isBootstrap && selectedSessionParam && selectedSessionParam !== sid) {
            setSearchParams({ session: sid }, { replace: true });
          }
        }

        await restoreMessages(sid, () => cancelled);
        refresh();
      } catch {
        // Session creation/selection failed. If the URL param came from
        // our own localStorage restore, the stored session is stale (e.g.
        // deleted on disk) — clear it so the next navigation falls
        // through to getOrCreate instead of looping on the bad id.
        if (
          queenId &&
          selectedSessionParam &&
          selectedSessionParam === readLastSession(queenId)
        ) {
          clearLastSession(queenId);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
          setSwitchingSessionId(null);
          setCreatingNewSession(false);
        }
      }
    })();

    return () => {
      cancelled = true;
    };
  }, [
    queenId,
    selectedSessionParam,
    newSessionFlag,
    restoreMessages,
    refresh,
    resetViewState,
    setSearchParams,
  ]);

  // Remember the session the user is currently viewing so switching queens
  // and coming back lands on it instead of whatever the server picks.
  useEffect(() => {
    if (!queenId || !sessionId) return;
    writeLastSession(queenId, sessionId);
  }, [queenId, sessionId]);

  useEffect(() => {
    if (!queenId) return;
    let cancelled = false;
    setHistoryLoading(true);

    sessionsApi
      .history()
      .then(({ sessions }) => {
        if (cancelled) return;
        const filtered = sessions
          .filter((session) => session.queen_id === queenId)
          .sort((a, b) => b.created_at - a.created_at);
        setHistorySessions(filtered);
      })
      .catch(() => {
        if (!cancelled) setHistorySessions([]);
      })
      .finally(() => {
        if (!cancelled) setHistoryLoading(false);
      });

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

  // Hydrate the colony-spawned lock + queen phase from the session detail
  // whenever the session ID changes. /sessions/{id} carries both flags
  // (and the cold-info path returns colony_spawned after a server restart),
  // so this single fetch covers live, page-reload, and post-restart states.
  // Without seeding queen_phase here the badge starts at the useState
  // default ("independent") and only updates when a fresh
  // QUEEN_PHASE_CHANGED SSE event fires — a reload mid-incubation would
  // briefly mis-render.
  useEffect(() => {
    if (!sessionId) return;
    let cancelled = false;
    sessionsApi
      .get(sessionId)
      .then((data) => {
        if (cancelled) return;
        const detail = data as {
          colony_spawned?: boolean;
          spawned_colony_name?: string | null;
          queen_phase?: "independent" | "incubating" | "working" | "reviewing";
        };
        setColonySpawned(Boolean(detail.colony_spawned));
        setSpawnedColonyName(detail.spawned_colony_name ?? null);
        if (
          detail.queen_phase === "independent" ||
          detail.queen_phase === "incubating" ||
          detail.queen_phase === "working" ||
          detail.queen_phase === "reviewing"
        ) {
          setQueenPhase(detail.queen_phase);
        }
      })
      .catch(() => {
        // Non-fatal — lock + phase simply won't activate until a fresh
        // SSE event arrives.
      });
    return () => {
      cancelled = true;
    };
  }, [sessionId]);

  const handleColonyLinkClick = useCallback(
    (colonyName: string) => {
      if (!sessionId || !colonyName) return;
      // Optimistically lock so the textarea swaps to the button before the
      // user navigates back. Backend persists the same flag in meta.json so
      // a refresh would re-hydrate the locked state anyway.
      setColonySpawned(true);
      setSpawnedColonyName(colonyName);
      executionApi.markColonySpawned(sessionId, colonyName).catch(() => {
        // Revert on failure so the user isn't stranded with no composer.
        setColonySpawned(false);
        setSpawnedColonyName(null);
      });
    },
    [sessionId],
  );

  const handleCompactAndFork = useCallback(async () => {
    if (!sessionId || compactingAndForking || !queenId) return;
    setCompactingAndForking(true);
    try {
      const result = await executionApi.compactAndFork(sessionId);
      // Navigate to the freshly-forked session for the same queen. Replacing
      // the URL keeps the back button on the home/history page rather than
      // bouncing back to the now-locked DM.
      setSearchParams({ session: result.new_session_id }, { replace: true });
    } catch {
      setCompactingAndForking(false);
    }
  }, [sessionId, compactingAndForking, queenId, setSearchParams]);

  const handleSelectHistoricalSession = useCallback(
    (nextSessionId: string) => {
      if (!nextSessionId || nextSessionId === sessionId) return;
      setSwitchingSessionId(nextSessionId);
      setSearchParams({ session: nextSessionId });
    },
    [sessionId, setSearchParams],
  );

  const handleCreateNewSession = useCallback(() => {
    if (!queenId) return;
    // Bounce through the ?new=1 bootstrap path so the chat shell appears
    // immediately with a typing indicator while createNewSession runs in
    // the background. URL is replaced with ?session=<id> when it resolves.
    // Avoids the 5s "nothing happens, then chat appears" dead window.
    setSearchParams({ new: "1" });
  }, [queenId, setSearchParams]);

  useEffect(() => {
    if (!queenId) return;
    setActions(
      <>
        <QueenSessionSwitcher
          sessions={historySessions}
          currentSessionId={sessionId}
          loading={historyLoading}
          switchingSessionId={switchingSessionId}
          creatingNew={creatingNewSession}
          onSelect={handleSelectHistoricalSession}
          onCreateNew={handleCreateNewSession}
        />
      </>,
    );
    return () => setActions(null);
  }, [
    creatingNewSession,
    handleCreateNewSession,
    handleSelectHistoricalSession,
    historyLoading,
    historySessions,
    queenId,
    sessionId,
    setActions,
    switchingSessionId,
  ]);

  // SSE handler
  const handleSSEEvent = useCallback(
    (_agentType: string, event: AgentEvent) => {
      const isQueen = event.stream_id === "queen";
      if (!isQueen) return;
      const emittedMessages = replayEvent(
        replayStateRef.current,
        event,
        "queen-dm",
        queenName,
      );

      switch (event.type) {
        case "execution_started":
          setIsTyping(true);
          setQueenReady(true);
          // Do NOT clear `queued` on user messages here. The pending queue
          // hook owns that flag — it's cleared on steer / cancel / flush.
          // If the user has queued messages that haven't been flushed yet,
          // the queen starting a new turn (e.g. from a steer or from the
          // flush itself) shouldn't hide the still-queued ones.
          break;

        case "execution_completed":
          setIsTyping(false);
          setIsStreaming(false);
          break;

        case "llm_turn_complete":
          if (event.data) {
            const inp = (event.data.input_tokens as number) || 0;
            const out = (event.data.output_tokens as number) || 0;
            // cached / cache_creation are subsets of input — accumulate
            // separately for display, do NOT roll into input/total.
            const cached = (event.data.cached_tokens as number) || 0;
            const cacheCreated = (event.data.cache_creation_tokens as number) || 0;
            const costUsd = (event.data.cost_usd as number) || 0;
            setTokenUsage((prev) => ({
              input: prev.input + inp,
              output: prev.output + out,
              cached: prev.cached + cached,
              cacheCreated: prev.cacheCreated + cacheCreated,
              costUsd: prev.costUsd + costUsd,
            }));
          }
          // Flush one queued message per LLM turn boundary. This is the
          // real "turn ended" signal in a queen DM — execution_completed
          // only fires at session shutdown because the event loop parks in
          // _await_user_input between turns. Mid-tool-call boundaries
          // count too: sending now lets the queen pick up the message on
          // her next drain, same as clicking Steer.
          flushNextPendingRef.current();
          break;

        case "client_output_delta":
        case "llm_text_delta": {
          for (const msg of emittedMessages) upsertMessage(msg);
          setIsStreaming(true);
          break;
        }

        case "client_input_requested": {
          const rawQuestions = event.data?.questions;
          const questions = Array.isArray(rawQuestions)
            ? (rawQuestions as {
                id: string;
                prompt: string;
                options?: string[];
              }[])
            : null;
          // An empty-prompt client_input_requested means the queen parked
          // in auto-wait. If we just auto-flushed a queued message, our
          // inject will unblock her in a moment — skip flipping isTyping
          // off so the thinking bubble doesn't flicker.
          if (queenAboutToResumeRef.current && !questions) {
            queenAboutToResumeRef.current = false;
            break;
          }
          // Stash the question bubble (synthesized by replayEvent) instead
          // of upserting now: while the popup widget is open the user only
          // wants to see ONE copy of the question. We commit the bubble on
          // client_input_received so it lands right above the user's
          // answer in the transcript.
          if (emittedMessages.length > 0) {
            pendingAskUserBubbleRef.current = emittedMessages[0];
          }
          setAwaitingInput(true);
          setIsTyping(false);
          setIsStreaming(false);
          setPendingQuestions(questions);
          break;
        }

        case "client_input_received": {
          // Commit the stashed ask_user bubble first so it appears above
          // the user's reply in scroll-back. Its createdAt predates this
          // event's, so the timestamp-ordered insert in upsertMessage
          // places it correctly.
          if (pendingAskUserBubbleRef.current) {
            upsertMessage(pendingAskUserBubbleRef.current);
            pendingAskUserBubbleRef.current = null;
          }
          for (const msg of emittedMessages) {
            upsertMessage(msg, { reconcileOptimisticUser: true });
          }
          break;
        }

        case "queen_phase_changed": {
          const rawPhase = event.data?.phase as string;
          if (
            rawPhase === "independent" ||
            rawPhase === "incubating" ||
            rawPhase === "working" ||
            rawPhase === "reviewing"
          ) {
            setQueenPhase(rawPhase);
          }
          break;
        }

        case "colony_created": {
          // Queen called create_colony() — surface a clickable system
          // message linking to /colony/{colony_name} so the user can
          // navigate to the new colony immediately.
          const colonyName = (event.data?.colony_name as string) || "";
          const isNew = (event.data?.is_new as boolean) ?? true;
          const skillName = (event.data?.skill_name as string) || "";
          if (!colonyName) break;
          // ColonyContext keys colonies by slugToColonyId(slug), not by the
          // raw snake_case directory name. Apply the same transform so the
          // /colony/:colonyId route lookup in colony-chat.tsx resolves.
          const routeId = slugToColonyId(colonyName);
          const msg: ChatMessage = {
            id: makeId(),
            agent: "System",
            agentColor: "",
            content: JSON.stringify({
              kind: "colony_created",
              colony_name: colonyName,
              is_new: isNew,
              skill_name: skillName,
              href: `/colony/${routeId}`,
            }),
            timestamp: "",
            type: "colony_link",
            thread: "queen-dm",
            createdAt: Date.now(),
          };
          setMessages((prev) => [...prev, msg]);
          // Refresh the sidebar's colony list so the new colony shows up
          // under "Colonies" immediately (without requiring a page
          // reload or the 30s status poll).
          refresh();
          break;
        }

        case "tool_call_started": {
          for (const msg of emittedMessages) upsertMessage(msg);
          break;
        }

        case "tool_call_completed": {
          for (const msg of emittedMessages) upsertMessage(msg);
          break;
        }

        default:
          break;
      }
    },
    [queenName, refresh, upsertMessage],
  );

  const sseSessions = useMemo((): Record<string, string> => {
    if (sessionId) return { "queen-dm": sessionId };
    return {};
  }, [sessionId]);

  useMultiSSE({ sessions: sseSessions, onEvent: handleSSEEvent });

  // Core backend send — used both for immediate sends and for Steer /
  // auto-flush paths out of the pending queue.
  const sendToBackend = useCallback(
    (text: string, images?: ImageContent[]) => {
      if (!sessionId) return;
      executionApi.chat(sessionId, text, images).catch(() => {
        setIsTyping(false);
        setIsStreaming(false);
      });
    },
    [sessionId],
  );

  const {
    enqueue: enqueuePending,
    steer: handleSteer,
    cancelQueued: handleCancelQueued,
    flushNext: flushNextPending,
    flushNextRef: flushNextPendingRef,
    clear: clearPendingQueue,
  } = usePendingQueue({
    sendToBackend,
    setMessages,
    onFlushStart: useCallback(() => {
      setIsTyping(true);
      queenAboutToResumeRef.current = true;
    }, []),
  });

  // Reset the queue whenever we navigate to a different queen. The hook
  // outlives the route change (same component instance), so without this,
  // a message queued for Queen A would auto-flush into Queen B's session
  // on B's next execution_completed.
  useEffect(() => {
    clearPendingQueue();
  }, [queenId, clearPendingQueue]);

  // Send handler. Queues when the queen is mid-turn (unless the user is
  // answering an ask_user prompt, which must send immediately to unblock
  // the loop). Queued messages are held locally until Steer, Cancel, or
  // the next `execution_completed` auto-flush.
  const handleSend = useCallback(
    (text: string, _thread: string, images?: ImageContent[]) => {
      const answeringQuestion = awaitingInput;
      if (answeringQuestion) {
        setAwaitingInput(false);
        setPendingQuestions(null);
      }

      const shouldQueue = !answeringQuestion && isTyping;

      const msgId = makeId();
      const userMsg: ChatMessage = {
        id: msgId,
        agent: "You",
        agentColor: "",
        content: text,
        timestamp: "",
        type: "user",
        thread: "queen-dm",
        createdAt: Date.now(),
        images,
        queued: shouldQueue,
      };
      setMessages((prev) => [...prev, userMsg]);

      if (shouldQueue) {
        enqueuePending(msgId, { text, images });
        return;
      }

      setIsTyping(true);
      sendToBackend(text, images);
    },
    [awaitingInput, isTyping, sendToBackend, enqueuePending],
  );

  const handleColonySpawn = useCallback(() => {
    const colony = cloneColonyName.trim();
    if (!colony) return;
    const task = cloneTask.trim();

    const briefLines = [
      `Colony name: ${colony}`,
      `Purpose: ${task || "Use the current conversation to propose the colony's purpose."}`,
    ];
    if (showCloneOutputs && cloneOutputs.trim()) {
      briefLines.push(`Expected outputs: ${cloneOutputs.trim()}`);
    }
    if (showCloneDataSources && cloneDataSources.trim()) {
      briefLines.push(`Inputs, data sources, tools, or credentials: ${cloneDataSources.trim()}`);
    }
    if (showCloneSchedule && cloneSchedule.trim()) {
      briefLines.push(`Schedule/triggers: ${cloneSchedule.trim()}`);
    }
    if (showCloneConcurrency && cloneConcurrency.trim()) {
      briefLines.push(`Concurrency: ${cloneConcurrency.trim()}`);
    }

    const message = [
      "I want to set up a persistent colony.",
      "",
      briefLines.join("\n"),
      "",
      "Please use start_incubating_colony if this is appropriate. Ask me for any missing details before calling create_colony, then generate the self-contained task, skill name, skill description, skill body, and any optional triggers or concurrency hint needed by the colony.",
    ].join("\n");

    handleSend(message, "queen-dm");
    setCloneDialogOpen(false);
    setCloneColonyName("");
    setCloneTask("");
    setCloneOutputs("");
    setCloneDataSources("");
    setCloneSchedule("");
    setCloneConcurrency("");
    setShowCloneOutputs(false);
    setShowCloneDataSources(false);
    setShowCloneSchedule(false);
    setShowCloneConcurrency(false);
  }, [
    cloneColonyName,
    cloneConcurrency,
    cloneDataSources,
    cloneOutputs,
    cloneSchedule,
    cloneTask,
    handleSend,
    showCloneDataSources,
    showCloneConcurrency,
    showCloneOutputs,
    showCloneSchedule,
  ]);

  const handleQuestionAnswer = useCallback(
    (answers: Record<string, string>) => {
      setAwaitingInput(false);
      setPendingQuestions(null);
      // For a single question, send just the answer text. For a batch,
      // send "id: answer" lines so the agent can map replies back.
      const entries = Object.entries(answers);
      const formatted =
        entries.length === 1
          ? entries[0][1]
          : entries.map(([id, val]) => `${id}: ${val}`).join("\n");
      handleSend(formatted, "queen-dm");
    },
    [handleSend],
  );

  const handleCancelQueen = useCallback(async () => {
    if (!sessionId) return;
    try {
      await executionApi.cancelQueen(sessionId);
      setIsTyping(false);
      setIsStreaming(false);
      replayStateRef.current = newReplayState();
      // After cancelling the current turn, immediately send the oldest
      // queued message (if any). The remaining queued messages stay put
      // so the user can review them or Steer/Cancel individually.
      flushNextPending();
    } catch {
      // ignore
    }
  }, [sessionId, flushNextPending]);

  return (
    <div className="flex flex-col h-full">
      {/* Chat */}
      <div className="flex-1 min-h-0 relative">
        <ChatPanel
          messages={messages}
          onSend={handleSend}
          onCancel={handleCancelQueen}
          onSteer={handleSteer}
          onCancelQueued={handleCancelQueued}
          activeThread="queen-dm"
          isWaiting={isTyping && !isStreaming}
          isBusy={isTyping}
          // Keep the textarea typable while the queen is warming up so the
          // user can compose a follow-up immediately. Send stays locked
          // until the session is live and the queen is ready.
          sendLocked={loading || !queenReady}
          queenPhase={queenPhase}
          showQueenPhaseBadge
          pendingQuestions={awaitingInput ? pendingQuestions : null}
          onQuestionSubmit={handleQuestionAnswer}
          onQuestionDismiss={() => {
            setAwaitingInput(false);
            setPendingQuestions(null);
          }}
          supportsImages={true}
          initialDraft={initialDraft}
          queenProfileId={queenId ?? null}
          queenId={queenId}
          onColonyLinkClick={handleColonyLinkClick}
          colonySpawned={colonySpawned}
          spawnedColonyName={spawnedColonyName}
          queenDisplayName={queenName}
          onCompactAndFork={handleCompactAndFork}
          compactingAndForking={compactingAndForking}
          onStartNewSession={handleCreateNewSession}
          startingNewSession={creatingNewSession}
          tokenUsage={tokenUsage}
          headerAction={
            <button
              onClick={() => setCloneDialogOpen(true)}
              disabled={!sessionId}
              className="flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] font-medium text-primary hover:bg-primary/10 transition-colors disabled:opacity-40"
            >
              <Plus className="w-3 h-3" />
              Start Colony Setup
            </button>
          }
        />
      </div>

      {cloneDialogOpen && (
        <div className="fixed inset-0 z-50 flex items-center justify-center">
          <div
            className="absolute inset-0 bg-black/40 backdrop-blur-sm"
            onClick={() => setCloneDialogOpen(false)}
          />
          <div className="relative flex w-full max-w-lg h-[min(560px,88vh)] flex-col overflow-hidden rounded-xl border border-border/60 bg-card shadow-2xl">
            <div className="px-6 pt-5 pb-3 space-y-1">
              <h2 className="text-sm font-semibold text-foreground">
                Set Up a Colony
              </h2>
              <p className="text-[11px] text-muted-foreground">
                Share the brief. The queen will fill gaps, write the worker skill,
                and create the colony when the setup is ready.
              </p>
            </div>
            <div className="flex-1 overflow-y-auto px-6 pb-4 space-y-3">
              <div>
                <label className="block text-[11px] font-medium text-muted-foreground mb-1">
                  Colony name
                </label>
                <input
                  type="text"
                  value={cloneColonyName}
                  onChange={(e) =>
                    setCloneColonyName(
                      e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ""),
                    )
                  }
                  placeholder="e.g. research_team"
                  className="w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary"
                  autoFocus
                />
              </div>
              <div>
                <label className="block text-[11px] font-medium text-muted-foreground mb-1">
                  What should it do?
                </label>
                <textarea
                  value={cloneTask}
                  onChange={(e) => setCloneTask(e.target.value)}
                  placeholder="Monitor launches, process a backlog, prepare a report, or continue this session's work."
                  rows={3}
                  className="w-full resize-none rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary"
                />
              </div>

              <div className="space-y-2 pt-1">
                {!showCloneOutputs ? (
                  <button
                    type="button"
                    onClick={() => setShowCloneOutputs(true)}
                    className="flex w-full items-center gap-2 rounded-md border border-border/60 px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors"
                  >
                    <Plus className="h-3.5 w-3.5 shrink-0" />
                    Expected output
                  </button>
                ) : (
                  <div className="rounded-md border border-border/60 p-3 space-y-2">
                    <button
                      type="button"
                      onClick={() => setShowCloneOutputs(false)}
                      className="flex w-full items-center justify-between gap-3 rounded-md px-1 py-1 text-left text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors"
                      aria-label="Hide expected output"
                    >
                      <span className="text-[11px] font-medium">
                        Expected output
                      </span>
                      <span className="inline-flex items-center gap-1 text-[11px] font-medium">
                        <Minus className="h-3.5 w-3.5" />
                        Hide
                      </span>
                    </button>
                    <textarea
                      value={cloneOutputs}
                      onChange={(e) => setCloneOutputs(e.target.value)}
                      placeholder="A digest, saved rows, alerts, files, or a final summary."
                      rows={2}
                      className="w-full resize-none rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary"
                    />
                  </div>
                )}

                {!showCloneDataSources ? (
                  <button
                    type="button"
                    onClick={() => setShowCloneDataSources(true)}
                    className="flex w-full items-center gap-2 rounded-md border border-border/60 px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors"
                  >
                    <Plus className="h-3.5 w-3.5 shrink-0" />
                    Inputs, tools, or credentials
                  </button>
                ) : (
                  <div className="rounded-md border border-border/60 p-3 space-y-2">
                    <button
                      type="button"
                      onClick={() => setShowCloneDataSources(false)}
                      className="flex w-full items-center justify-between gap-3 rounded-md px-1 py-1 text-left text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors"
                      aria-label="Hide inputs, tools, or credentials"
                    >
                      <span className="text-[11px] font-medium">
                        Inputs, tools, or credentials
                      </span>
                      <span className="inline-flex items-center gap-1 text-[11px] font-medium">
                        <Minus className="h-3.5 w-3.5" />
                        Hide
                      </span>
                    </button>
                    <textarea
                      value={cloneDataSources}
                      onChange={(e) => setCloneDataSources(e.target.value)}
                      placeholder="APIs, websites, files, accounts, OAuth tools, or credentials it will need."
                      rows={2}
                      className="w-full resize-none rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary"
                    />
                  </div>
                )}

                {!showCloneSchedule ? (
                  <button
                    type="button"
                    onClick={() => setShowCloneSchedule(true)}
                    className="flex w-full items-center gap-2 rounded-md border border-border/60 px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors"
                  >
                    <Plus className="h-3.5 w-3.5 shrink-0" />
                    Schedule / triggers
                  </button>
                ) : (
                  <div className="rounded-md border border-border/60 p-3 space-y-2">
                    <button
                      type="button"
                      onClick={() => setShowCloneSchedule(false)}
                      className="flex w-full items-center justify-between gap-3 rounded-md px-1 py-1 text-left text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors"
                      aria-label="Hide schedule or triggers"
                    >
                      <span className="text-[11px] font-medium">
                        Schedule / triggers
                      </span>
                      <span className="inline-flex items-center gap-1 text-[11px] font-medium">
                        <Minus className="h-3.5 w-3.5" />
                        Hide
                      </span>
                    </button>
                    <textarea
                      value={cloneSchedule}
                      onChange={(e) => setCloneSchedule(e.target.value)}
                      placeholder="Manual only, every weekday at 9 AM, every 30 minutes, or webhook path."
                      rows={2}
                      className="w-full resize-none rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary"
                    />
                  </div>
                )}

                {!showCloneConcurrency ? (
                  <button
                    type="button"
                    onClick={() => setShowCloneConcurrency(true)}
                    className="flex w-full items-center gap-2 rounded-md border border-border/60 px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors"
                  >
                    <Plus className="h-3.5 w-3.5 shrink-0" />
                    Concurrency
                  </button>
                ) : (
                  <div className="rounded-md border border-border/60 p-3 space-y-2">
                    <button
                      type="button"
                      onClick={() => setShowCloneConcurrency(false)}
                      className="flex w-full items-center justify-between gap-3 rounded-md px-1 py-1 text-left text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors"
                      aria-label="Hide concurrency"
                    >
                      <span className="text-[11px] font-medium">
                        Concurrency
                      </span>
                      <span className="inline-flex items-center gap-1 text-[11px] font-medium">
                        <Minus className="h-3.5 w-3.5" />
                        Hide
                      </span>
                    </button>
                    <input
                      type="text"
                      value={cloneConcurrency}
                      onChange={(e) => setCloneConcurrency(e.target.value)}
                      placeholder="1 for a single worker, 5 for a parallel backlog, or any limit to respect."
                      className="w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary"
                    />
                  </div>
                )}
              </div>
            </div>
            <div className="mt-auto flex justify-end gap-2 border-t border-border/50 px-6 py-4">
              <button
                onClick={() => {
                  setCloneDialogOpen(false);
                  setCloneColonyName("");
                  setCloneTask("");
                  setCloneOutputs("");
                  setCloneDataSources("");
                  setCloneSchedule("");
                  setCloneConcurrency("");
                  setShowCloneOutputs(false);
                  setShowCloneDataSources(false);
                  setShowCloneSchedule(false);
                  setShowCloneConcurrency(false);
                }}
                className="px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
              >
                Cancel
              </button>
              <button
                onClick={handleColonySpawn}
                disabled={!cloneColonyName.trim()}
                className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
              >
                Start setup
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}
