"use client";

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { useRouter } from "next/navigation";
import {
  AlertCircle,
  CheckCircle2,
  ChevronRight,
  Edit3,
  FileText,
  Globe,
  Loader2,
  Send,
  StopCircle,
  Terminal,
  Wrench,
  type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import { Markdown } from "@/components/markdown";
import { RunningDot } from "@/components/running-dot";
import {
  WorkingIndicator,
  type WorkingMood,
  type WorkingPhase,
} from "@/components/working-indicator";
import { SlashCommandPopover } from "@/components/slash-command-popover";
import { cn } from "@/lib/utils";
import {
  executeLocalSlashCommand,
  filterSlashCommands,
  parseSlashMessage,
  type SlashCommand,
} from "@/lib/slash-commands";
import type { TranscriptEvent } from "@/server/openclaw/transcript-tail";
import { projectHref } from "@/lib/project-href";

const POLL_INTERVAL_MS = 2_000;

/**
 * Content signature for cross-writer dedup. The id-based set in
 * seenEventIdsRef catches in-channel dups (e.g. polling racing itself);
 * this catches dups WHEN the same logical event arrives via different
 * channels with different ids — e.g. the shadow transcript stream gave
 * us an assistant turn during the run, and OpenClaw's eventual JSONL
 * flush gives us the same turn with a fresh UUID at session-end.
 *
 * Signature keys are intentionally narrow: text body for messages
 * (case-insensitive trim collapses whitespace differences from the
 * shadow vs. final renders), tool_call_id for tool rows (those are
 * unique per invocation regardless of the writer).
 */
function eventSignature(e: TranscriptEvent): string {
  switch (e.kind) {
    case "user_message":
    case "assistant_text":
      return `${e.kind}|${e.body.trim()}`;
    case "tool_call":
    case "tool_result":
      return `${e.kind}|${e.tool_call_id}`;
    default:
      return `${e.kind}|${e.id}`;
  }
}

type Props = {
  projectSlug: string;
  agentSlug: string;
  agentDisplayName: string;
  /** OpenClaw thread (the URL label half of `agent:<agent>:<label>`). */
  threadId: string;
  /** Canonical sessionKey for /api/chat sends. */
  sessionKey: string;
  /** Server-rendered initial slice of the transcript. */
  initialEvents: TranscriptEvent[];
  /** Byte offset *after* `initialEvents` — polls start from here. */
  initialByteOffset: number;
  /**
   * When true, disables the composer (e.g., task is running and the user
   * shouldn't send mid-run input). Default: composer always enabled.
   */
  composerDisabled?: boolean;
  /**
   * Set when the agent's task is parked in `blocked` (e.g., waiting on a
   * pending approval). Replaces the "thinking…" / "wrapping up…" indicator
   * — those imply forward motion, but a blocked task is dormant by design.
   */
  blockedReason?: string;
  /**
   * When set, on each successful poll we call this so the parent can
   * react to JSONL growth (e.g., trigger router.refresh to refetch task
   * statuses). Returning true tells us to stop background polling.
   */
  onPolled?: (info: { newEvents: number; fileSize: number }) => boolean | void;
  /**
   * Auto-kickoff: when true AND the transcript is empty AND we're not
   * already sending, fire a hidden first message so the agent runs
   * without the user typing. Used by the task workspace for tasks still
   * in `proposed` so the user sees gateway events stream in immediately
   * — JSONL polling alone can't (OpenClaw's codex-app-server mode flushes
   * the file once per turn, not incrementally).
   */
  autoKickoff?: boolean;
  /** Override for the auto-kickoff message body. */
  kickoffMessage?: string;
  /**
   * Task this turn belongs to. Forwarded to /api/chat so the server can
   * atomically claim the task (proposed → working) before forwarding to
   * the gateway. Without it, two tabs / a fast reload could race two
   * kickoffs against each other and double-fire the agent. The server
   * returns 409 when the claim fails; we treat that as a benign no-op.
   */
  taskId?: string;
};

/** Module-level guard so React StrictMode dev double-mounts don't double-fire. */
const KICKOFF_FIRED = new Set<string>();

export function LiveTranscript({
  projectSlug,
  agentSlug,
  agentDisplayName,
  threadId,
  sessionKey,
  initialEvents,
  initialByteOffset,
  composerDisabled = false,
  blockedReason,
  onPolled,
  autoKickoff = false,
  kickoffMessage,
  taskId,
}: Props) {
  const router = useRouter();
  const [events, setEvents] = useState<TranscriptEvent[]>(initialEvents);
  const [byteOffset, setByteOffset] = useState(initialByteOffset);
  const [input, setInput] = useState("");
  const [sendingChat, setSendingChat] = useState(false);
  /**
   * Wallclock when the current turn started, in ms. Drives the "elapsed"
   * counter in WorkingStatus during the gap between hitting send and the
   * agent's first transcript event landing — without this, elapsed reflects
   * the *previous* turn's last event timestamp (often minutes/hours stale).
   * Cleared once the new user_message lands and rendering catches up.
   */
  const [turnStartedAt, setTurnStartedAt] = useState<number | null>(null);
  const [stopPolling, setStopPolling] = useState(false);
  const [slashIndex, setSlashIndex] = useState(0);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  // Slash command autocomplete: open while input starts with "/" and the
  // user is still composing the command name (no space yet). After they
  // pick or type a space, the popover closes.
  const slashQuery = input.startsWith("/") && !input.includes(" ") ? input : null;
  const slashOpen = slashQuery !== null && !sendingChat && !composerDisabled;
  const slashMatches = useMemo<SlashCommand[]>(
    () => (slashOpen ? filterSlashCommands(slashQuery!) : []),
    [slashOpen, slashQuery],
  );
  const safeSlashIndex =
    slashMatches.length === 0
      ? 0
      : Math.min(slashIndex, slashMatches.length - 1);

  function insertSlashCommand(cmd: SlashCommand) {
    // Catalog `name` is the command without the leading slash ("new", "clear").
    // Insert ends with a trailing space so the popover closes — a second
    // Enter then submits.
    const insert = cmd.insert ?? `/${cmd.name} `;
    setInput(insert);
    setSlashIndex(0);
    requestAnimationFrame(() => {
      const ta = textareaRef.current;
      if (ta) {
        ta.focus();
        ta.setSelectionRange(insert.length, insert.length);
      }
    });
  }

  // Optimistic state for the active /api/chat send. Rendered after committed
  // events so the user sees their message + the streaming response before
  // polling materializes them from JSONL. Cleared once polling catches up.
  const [pendingUserMsg, setPendingUserMsg] = useState<string | null>(null);
  const [pendingAssistant, setPendingAssistant] = useState("");
  const [pendingTools, setPendingTools] = useState<ToolEntry[]>([]);
  const [pendingError, setPendingError] = useState<string | null>(null);
  /**
   * Most recent OpenClaw lifecycle phase for the in-flight turn (run.start,
   * run.warming, etc.). Surfaced in the "thinking…" indicator so a long
   * wait before the first model token at least shows forward motion.
   * Cleared when the turn ends.
   */
  const [pendingLifecycle, setPendingLifecycle] = useState<string | null>(null);

  const scrollRef = useRef<HTMLDivElement>(null);
  const stickyBottomRef = useRef(true);
  const abortRef = useRef<AbortController | null>(null);
  /**
   * Ids of every transcript event we've already committed to state. Acts as
   * a dedupe set for poll-result merging: two `pollOnce` calls can fire
   * with the same `byteOffset` closure before React commits the next
   * offset (e.g. the post-stream catch-up poll racing the background
   * polling effect), so both fetches would return the same JSONL slice and
   * we'd end up with duplicate React keys. Lives in a ref so the dedupe
   * is synchronous — `setEvents(prev => …)` updater functions don't run
   * until React's next commit, so they can't be used to derive the count
   * we return from `pollOnce`.
   */
  const seenEventIdsRef = useRef<Set<string>>(
    new Set(initialEvents.map((e) => e.id)),
  );

  /**
   * Content signatures we've already rendered, for cross-writer dedup.
   * The id-based set above can't catch the case where the SAME logical
   * message arrives twice via different paths with different ids — the
   * server-side-kickoff path writes a shadow transcript (ids like
   * `shadow-<uuid>`), and OpenClaw later flushes its own copy of the
   * session.jsonl at session-end with FRESH UUIDs. Without a content
   * key, every assistant turn and tool call would render twice. Keyed by
   * the semantic identity: text body for messages, tool_call_id for
   * tool rows. Pure ref so React doesn't see it.
   */
  const seenSignaturesRef = useRef<Set<string>>(
    new Set(initialEvents.map(eventSignature)),
  );

  // ── Auto-scroll: only when the user is already near the bottom. ─────
  function onScroll() {
    const el = scrollRef.current;
    if (!el) return;
    const remaining = el.scrollHeight - (el.scrollTop + el.clientHeight);
    stickyBottomRef.current = remaining < 96;
  }
  useLayoutEffect(() => {
    if (!stickyBottomRef.current) return;
    const el = scrollRef.current;
    if (!el) return;
    el.scrollTop = el.scrollHeight;
  }, [events, sendingChat, pendingAssistant, pendingTools, pendingUserMsg]);

  // ── Live tail polling. ─────────────────────────────────────────────
  const pollOnce = useCallback(async () => {
    try {
      // Pass projectSlug explicitly: the API route would otherwise fall back
      // to the active-project cookie, which can lag the URL on first paint
      // after a project switch or direct deep-link.
      const url = `/api/agents/${agentSlug}/threads/${threadId}/transcript?offset=${byteOffset}&project=${encodeURIComponent(projectSlug)}`;
      const res = await fetch(url, { cache: "no-store" });
      if (!res.ok) return { newEvents: 0 };
      const data = (await res.json()) as {
        events: TranscriptEvent[];
        byteOffset: number;
        file_size: number;
      };
      // Dedupe against both id (in-channel races) and content signature
      // (cross-channel dups, e.g. shadow stream vs. OpenClaw's eventual
      // JSONL flush). Rationale on the refs' declarations.
      const fresh = data.events.filter((e) => {
        if (seenEventIdsRef.current.has(e.id)) return false;
        const sig = eventSignature(e);
        if (seenSignaturesRef.current.has(sig)) return false;
        return true;
      });
      for (const e of fresh) {
        seenEventIdsRef.current.add(e.id);
        seenSignaturesRef.current.add(eventSignature(e));
      }
      if (fresh.length > 0) {
        setEvents((prev) => [...prev, ...fresh]);
        // Clear pending state: JSONL now has the canonical events so the
        // optimistic placeholders are no longer needed.
        setPendingUserMsg(null);
        setPendingAssistant("");
        setPendingTools([]);
        setPendingError(null);
        setPendingLifecycle(null);
      }
      if (data.byteOffset !== byteOffset) setByteOffset(data.byteOffset);
      const shouldStop = onPolled?.({
        newEvents: fresh.length,
        fileSize: data.file_size,
      });
      if (shouldStop) setStopPolling(true);
      return { newEvents: fresh.length };
    } catch {
      return { newEvents: 0 };
    }
  }, [agentSlug, byteOffset, onPolled, projectSlug, threadId]);

  // Poll faster while the parent says the task is in flight — typical
  // first-task experience is "land on workspace, watch the audit run."
  // 2s feels laggy when nothing's on screen; 800ms keeps the pulse fresh.
  const pollIntervalMs = composerDisabled ? 800 : POLL_INTERVAL_MS;

  // Background polling: runs while mounted, paused during an active send so
  // we don't double-render content we're already streaming via SSE.
  useEffect(() => {
    if (stopPolling) return;
    if (sendingChat) return;
    let cancelled = false;
    let timer: ReturnType<typeof setTimeout> | null = null;
    const tick = async () => {
      if (cancelled) return;
      await pollOnce();
      if (cancelled) return;
      timer = setTimeout(tick, pollIntervalMs);
    };
    timer = setTimeout(tick, pollIntervalMs);
    return () => {
      cancelled = true;
      if (timer) clearTimeout(timer);
    };
  }, [pollIntervalMs, pollOnce, sendingChat, stopPolling]);

  // ── Live re-attach: SSE bridge to the shadow-transcript stream. ─────
  // OpenClaw buffers session JSONL until session.ended, so JSONL polling
  // sees nothing mid-turn. Both server-side task kickoffs and /api/chat
  // tee their gateway events to a shadow JSONL — this bridge tails that
  // file so a tab switching back to a thread mid-run picks up the stream
  // instead of staring at a frozen transcript until the turn flushes.
  //
  // The bridge runs on every thread mount, not just tasks. The shadow
  // file may not exist (idle thread) or exist-but-be-idle (run already
  // finished); both cases are no-ops on the server. Same dedup set as
  // polling, so events that later land in OpenClaw's JSONL don't
  // double-render.
  //
  // Skipped during an active /api/chat send — that path already streams
  // its own deltas; layering re-attach on top would just duplicate work.
  useEffect(() => {
    // Comprehensive client-side trace for the SSE re-attach. Prefix
    // [live-bridge] mirrors the server-side logs so a browser-console +
    // dev-server pair shows the full path. Toggle off in prod later.
    const log = (...args: unknown[]) =>
      console.log("[live-bridge]", ...args);
    if (sendingChat) {
      log("skip: sendingChat=true (/api/chat path owns streaming)", {
        threadId,
      });
      return;
    }
    if (stopPolling) {
      log("skip: stopPolling=true", { threadId });
      return;
    }
    if (typeof EventSource === "undefined") {
      log("skip: no EventSource in env (jsdom test)");
      return;
    }
    const url = `/api/agents/${agentSlug}/threads/${threadId}/live?project=${encodeURIComponent(projectSlug)}`;
    log("opening", { url });
    const es = new EventSource(url);
    es.addEventListener("open", () => log("opened"));
    es.addEventListener("ready", (e: MessageEvent) => log("ready", e.data));
    es.addEventListener("transcript", (e: MessageEvent) => {
      try {
        const data = JSON.parse(e.data) as { events: TranscriptEvent[] };
        if (!Array.isArray(data.events)) {
          log("transcript payload not an array", data);
          return;
        }
        const fresh = data.events.filter((ev) => {
          if (seenEventIdsRef.current.has(ev.id)) return false;
          const sig = eventSignature(ev);
          if (seenSignaturesRef.current.has(sig)) return false;
          return true;
        });
        log("transcript", {
          incoming: data.events.length,
          fresh: fresh.length,
        });
        for (const ev of fresh) {
          seenEventIdsRef.current.add(ev.id);
          seenSignaturesRef.current.add(eventSignature(ev));
        }
        if (fresh.length > 0) {
          setEvents((prev) => [...prev, ...fresh]);
          setPendingUserMsg(null);
          setPendingAssistant("");
          setPendingTools([]);
          setPendingError(null);
          setPendingLifecycle(null);
        }
      } catch (err) {
        log("transcript parse error", err);
      }
    });
    es.addEventListener("error", (e) => {
      log("error", { readyState: es.readyState, event: e });
    });
    return () => {
      log("closing", { threadId });
      es.close();
    };
  }, [agentSlug, projectSlug, sendingChat, stopPolling, threadId]);

  // ── Send: optimistic user message + SSE-driven streaming reply. ─────
  const send = useCallback(
    async (overrideText?: string, opts: { hidden?: boolean } = {}) => {
      const usingOverride = typeof overrideText === "string";
      const text = (usingOverride ? overrideText : input).trim();
      if (!text || sendingChat) return;

      // Local slash commands intercept the send. Skip when the message
      // was sent programmatically (overrides are always real prompts).
      const parsed = usingOverride ? null : parseSlashMessage(text);
      if (parsed) {
        const action = executeLocalSlashCommand(parsed.command);
        if (action) {
          setInput("");
          switch (action.kind) {
            case "clear":
              setEvents([]);
              setByteOffset(0);
              toast.info(
                "Local view cleared. Full transcript is still on disk; the next agent reply will repopulate.",
              );
              return;
            case "new-session": {
              const newId = crypto.randomUUID();
              router.push(
                projectHref(projectSlug, `/agents/${agentSlug}/chat/${newId}`),
              );
              return;
            }
            case "stop":
              abortRef.current?.abort();
              return;
            case "help":
              toast.message("Slash commands", { description: action.content });
              return;
          }
        }
      }

      if (!usingOverride) setInput("");
      setSendingChat(true);
      setTurnStartedAt(Date.now());
      setPendingUserMsg(opts.hidden ? null : text);
      setPendingAssistant("");
      setPendingTools([]);
      setPendingError(null);

      const ctrl = new AbortController();
      abortRef.current = ctrl;

      // Auto-kickoffs forward task_id so the server can atomically claim
      // the task (proposed → working). User-typed messages skip it — the
      // composer is already disabled while the task is in flight, so any
      // message the user can actually send is post-claim.
      const includeTaskId = opts.hidden && taskId;
      try {
        const res = await fetch("/api/chat", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            message: text,
            agent: agentSlug,
            project: projectSlug,
            sessionId: threadId,
            sessionKey,
            ...(includeTaskId ? { task_id: taskId } : {}),
          }),
          signal: ctrl.signal,
        });
        if (res.status === 409) {
          // Task was already claimed elsewhere (concurrent tab, reload
          // during a working turn). The agent is running on OpenClaw —
          // JSONL polling will surface the output once the turn flushes.
          // Bail silently; this is not a user-facing error.
          return;
        }
        if (!res.ok || !res.body) {
          throw new Error((await res.text()) || `HTTP ${res.status}`);
        }
        const reader = res.body.getReader();
        const decoder = new TextDecoder();
        let buffer = "";
        for (;;) {
          const { value, done } = await reader.read();
          if (done) break;
          buffer += decoder.decode(value, { stream: true });
          const events = buffer.split("\n\n");
          buffer = events.pop() ?? "";
          for (const raw of events) {
            handleSseEvent(raw, {
              onText: (chunk) => setPendingAssistant((s) => s + chunk),
              onTool: (evt) => {
                setPendingTools((prev) =>
                  upsertToolEntry(prev, evt),
                );
              },
              onError: (msg) => setPendingError(msg),
              onLifecycle: (phase) => setPendingLifecycle(phase),
              onMeta: (meta) => {
                if (process.env.NODE_ENV !== "production") {
                  // eslint-disable-next-line no-console
                  console.log(
                    `[chat-perf] turn start agent=${meta.agent} session=${meta.session_id} message_chars=${meta.message_chars}${meta.is_kickoff ? " (kickoff)" : ""}`,
                  );
                }
              },
              onPerf: (marks) => {
                if (process.env.NODE_ENV !== "production") {
                  // eslint-disable-next-line no-console
                  console.groupCollapsed(
                    `[chat-perf] turn complete (${marks.length} marks)`,
                  );
                  // eslint-disable-next-line no-console
                  console.table(
                    marks.map((m) => ({
                      name: m.name,
                      "at (ms)": Math.round(m.at),
                      "Δ (ms)": Math.round(m.delta),
                    })),
                  );
                  // eslint-disable-next-line no-console
                  console.groupEnd();
                }
              },
            });
          }
        }
        // Stream closed. Give OpenClaw a moment to flush the JSONL, then
        // pull the committed events. pollOnce clears pending state when it
        // returns new events; if it returns nothing yet, the regular
        // polling effect picks it up on the next tick.
        await new Promise((r) => setTimeout(r, 400));
        const { newEvents } = await pollOnce();
        if (newEvents === 0) {
          // Re-try once more shortly. Avoids the case where OpenClaw is
          // slow to flush and the user briefly sees pending state with no
          // backing JSONL.
          setTimeout(() => {
            void pollOnce();
          }, 1200);
        }
      } catch (err) {
        const isAbort = err instanceof DOMException && err.name === "AbortError";
        if (!isAbort) {
          const msg = err instanceof Error ? err.message : String(err);
          setPendingError(msg);
          toast.error(msg);
        }
      } finally {
        setSendingChat(false);
        setTurnStartedAt(null);
        setPendingLifecycle(null);
        abortRef.current = null;
      }
    },
    [agentSlug, input, pollOnce, projectSlug, router, sendingChat, sessionKey, taskId, threadId],
  );

  // ── Auto-kickoff for FIRST_TURN-style flows. ────────────────────────
  useEffect(() => {
    if (!autoKickoff) return;
    if (KICKOFF_FIRED.has(threadId)) return;
    if (events.length > 0) return;
    if (sendingChat) return;
    KICKOFF_FIRED.add(threadId);
    void send(kickoffMessage ?? "(session start)", { hidden: true });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [autoKickoff, threadId]);

  const rendered = useMemo(() => collapseEvents(events), [events]);
  // Show the working indicator any time the agent is plausibly running —
  // the user just sent (sendingChat) OR the parent says the task is in
  // flight (composerDisabled). Keep it at the bottom of the transcript
  // throughout the turn, BELOW pending tool calls and streaming text,
  // so the user sees the same anchor whether the agent is mid-call,
  // mid-stream, or waiting on the next model token.
  //
  // For `blocked` tasks the composer is enabled (so the user can answer
  // a question or interject) but the agent is still dormant — render
  // the BlockedStatus pill so the chat doesn't read as live.
  //
  // (Earlier we hid the indicator once anything pending was rendered.
  // That made the chat read as "stuck" the moment streaming started —
  // there was no longer any active animation to telegraph "I'm still
  // working.")
  const showThinking = sendingChat || composerDisabled || Boolean(blockedReason);

  return (
    <div className="flex h-full flex-col">
      <div
        ref={scrollRef}
        onScroll={onScroll}
        className="min-h-0 flex-1 overflow-y-auto"
        role="log"
        aria-live="polite"
      >
        <div className="mx-auto w-full max-w-3xl px-6 py-6">
          {rendered.length === 0 &&
          !pendingUserMsg &&
          !sendingChat &&
          !composerDisabled &&
          !blockedReason ? (
            <TranscriptEmptyState agentDisplayName={agentDisplayName} />
          ) : (
            <ol className="space-y-4">
              {rendered.map((item) => (
                <li key={item.key}>
                  <RenderItem item={item} />
                </li>
              ))}
              {pendingUserMsg && (
                <li>
                  <UserBubble body={pendingUserMsg} />
                </li>
              )}
              {pendingTools.length > 0 && (
                <li>
                  <ToolGroup tools={pendingTools} />
                </li>
              )}
              {pendingAssistant && (
                <li>
                  <AssistantText body={pendingAssistant} />
                </li>
              )}
              {pendingError && (
                <li>
                  <ErrorRow agentDisplayName={agentDisplayName} body={pendingError} />
                </li>
              )}
              {showThinking && (
                <li>
                  {blockedReason ? (
                    <BlockedStatus reason={blockedReason} />
                  ) : (
                    <LiveWorkingIndicator
                      agentDisplayName={agentDisplayName}
                      events={events}
                      turnStartedAt={turnStartedAt}
                      lifecyclePhase={pendingLifecycle}
                      pendingTools={pendingTools}
                      hasPendingAssistant={pendingAssistant.length > 0}
                    />
                  )}
                </li>
              )}
            </ol>
          )}
        </div>
      </div>

      <div className="border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
        <div className="relative mx-auto w-full max-w-3xl px-6 py-3">
          {slashOpen && (
            <SlashCommandPopover
              commands={slashMatches}
              selectedIndex={safeSlashIndex}
              onSelect={insertSlashCommand}
              onHover={setSlashIndex}
            />
          )}
          <form
            className="flex items-end gap-2"
            onSubmit={(e) => {
              e.preventDefault();
              void send();
            }}
          >
            <textarea
              ref={textareaRef}
              value={input}
              onChange={(e) => {
                setInput(e.target.value);
                setSlashIndex(0);
              }}
              onKeyDown={(e) => {
                // Slash autocomplete: arrow keys cycle, Tab/Enter insert.
                if (slashOpen && slashMatches.length > 0) {
                  if (e.key === "ArrowDown") {
                    e.preventDefault();
                    setSlashIndex((i) => (i + 1) % slashMatches.length);
                    return;
                  }
                  if (e.key === "ArrowUp") {
                    e.preventDefault();
                    setSlashIndex(
                      (i) =>
                        (i - 1 + slashMatches.length) % slashMatches.length,
                    );
                    return;
                  }
                  if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
                    e.preventDefault();
                    const picked = slashMatches[safeSlashIndex];
                    if (picked) insertSlashCommand(picked);
                    return;
                  }
                  if (e.key === "Escape") {
                    e.preventDefault();
                    setInput("");
                    setSlashIndex(0);
                    return;
                  }
                }
                if (e.key === "Enter" && !e.shiftKey) {
                  e.preventDefault();
                  void send();
                }
              }}
              disabled={composerDisabled}
              placeholder={
                composerDisabled
                  ? `${agentDisplayName} is on a task — the transcript updates live`
                  : blockedReason
                    ? `Reply to ${agentDisplayName} — typing won't resolve the block, but the agent will see your message`
                    : `Message ${agentDisplayName}…  (type / for commands)`
              }
              rows={1}
              className="flex min-h-[40px] flex-1 resize-none rounded-xl border bg-background px-3.5 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
            />
            {sendingChat ? (
              <Button
                type="button"
                variant="outline"
                size="sm"
                onClick={() => abortRef.current?.abort()}
                className="h-10 rounded-xl"
                aria-label="Stop"
              >
                <StopCircle className="size-4" />
              </Button>
            ) : (
              <Button
                type="submit"
                size="sm"
                disabled={composerDisabled || !input.trim()}
                className="h-10 rounded-xl"
                aria-label="Send"
              >
                <Send className="size-4" />
              </Button>
            )}
          </form>
          <p className="pt-1.5 text-center text-[10px] text-muted-foreground">
            {sendingChat ? (
              <span className="inline-flex items-center gap-1.5">
                <RunningDot size="sm" aria-label="" />
                Streaming — click stop to abort
              </span>
            ) : (
              <>Enter to send · Shift+Enter for newline</>
            )}
          </p>
        </div>
      </div>
    </div>
  );
}

// ── SSE plumbing for the active send ────────────────────────────────────

type SseToolEvent = {
  phase: "start" | "update" | "result";
  tool_call_id: string;
  name: string;
  label?: string;
};

type SsePerfMark = { name: string; at: number; delta: number };
type SseMeta = {
  message_chars?: number;
  is_kickoff?: boolean;
  agent?: string;
  session_id?: string;
};

function handleSseEvent(
  raw: string,
  handlers: {
    onText: (chunk: string) => void;
    onTool: (evt: SseToolEvent) => void;
    onError: (msg: string) => void;
    onLifecycle?: (phase: string) => void;
    onMeta?: (meta: SseMeta) => void;
    onPerf?: (marks: SsePerfMark[]) => void;
  },
) {
  const lines = raw.split("\n");
  const evtLine = lines.find((l) => l.startsWith("event: "));
  const dataLine = lines.find((l) => l.startsWith("data: "));
  if (!evtLine || !dataLine) return;
  const evt = evtLine.slice("event: ".length);
  let data: unknown;
  try {
    data = JSON.parse(dataLine.slice("data: ".length));
  } catch {
    return;
  }
  if (evt === "text") {
    const chunk = (data as { chunk?: string }).chunk;
    if (typeof chunk === "string") handlers.onText(chunk);
    return;
  }
  if (evt === "tool") {
    handlers.onTool(data as SseToolEvent);
    return;
  }
  if (evt === "lifecycle") {
    const phase = (data as { phase?: string }).phase;
    if (typeof phase === "string" && handlers.onLifecycle) {
      handlers.onLifecycle(phase);
    }
    return;
  }
  if (evt === "meta") {
    if (handlers.onMeta) handlers.onMeta(data as SseMeta);
    return;
  }
  if (evt === "perf") {
    const marks = (data as { marks?: SsePerfMark[] }).marks;
    if (Array.isArray(marks) && handlers.onPerf) handlers.onPerf(marks);
    return;
  }
  if (evt === "error") {
    const msg = (data as { message?: string }).message ?? "unknown error";
    handlers.onError(msg);
    return;
  }
}

function upsertToolEntry(prev: ToolEntry[], evt: SseToolEvent): ToolEntry[] {
  const idx = prev.findIndex((t) => t.toolCallId === evt.tool_call_id);
  if (idx < 0) {
    return [
      ...prev,
      {
        toolCallId: evt.tool_call_id,
        name: evt.name,
        label: evt.label ?? null,
        result: null,
        ok: true,
        done: evt.phase === "result",
      },
    ];
  }
  const next = prev.slice();
  const existing = next[idx]!;
  next[idx] = {
    ...existing,
    name: evt.name,
    label: evt.label ?? existing.label,
    done: evt.phase === "result" ? true : existing.done,
  };
  return next;
}

// ── Rendering helpers ──────────────────────────────────────────────────

type ToolEntry = {
  toolCallId: string;
  name: string;
  label: string | null;
  result: string | null;
  ok: boolean;
  done: boolean;
};

type RenderedItem =
  | { kind: "user_message"; key: string; body: string }
  | { kind: "assistant_text"; key: string; body: string }
  | { kind: "tool_group"; key: string; tools: ToolEntry[] }
  | { kind: "system_unknown"; key: string; raw_type: string };

/**
 * The on-disk JSONL writes `toolCall` and `toolResult` as separate parts,
 * sometimes interleaved across multiple message rows in one turn. We:
 *   1. Pair calls with their later results by tool_call_id so each tool
 *      renders as one logical entry (spinner → check).
 *   2. Group runs of contiguous tool entries (no assistant_text or
 *      user_message between them) into a single collapsible "tool_group".
 *      Mirrors Claude.ai's pattern — one card per cluster, summary shows
 *      the most recent tool name, expand to see all of them.
 */
function collapseEvents(events: TranscriptEvent[]): RenderedItem[] {
  type Step =
    | { tag: "tool"; key: string; entry: ToolEntry }
    | { tag: "msg"; item: RenderedItem };
  const steps: Step[] = [];
  const callIndex = new Map<string, number>();
  for (const e of events) {
    if (e.kind === "tool_call") {
      callIndex.set(e.tool_call_id, steps.length);
      steps.push({
        tag: "tool",
        key: e.id,
        entry: {
          toolCallId: e.tool_call_id,
          name: e.name,
          label: e.label,
          result: null,
          ok: true,
          done: false,
        },
      });
      continue;
    }
    if (e.kind === "tool_result") {
      const idx = callIndex.get(e.tool_call_id);
      const step = idx != null ? steps[idx] : null;
      if (step && step.tag === "tool") {
        step.entry = {
          ...step.entry,
          result: e.summary,
          ok: e.ok,
          done: true,
        };
        continue;
      }
      steps.push({
        tag: "tool",
        key: e.id,
        entry: {
          toolCallId: e.tool_call_id,
          name: e.name,
          label: null,
          result: e.summary,
          ok: e.ok,
          done: true,
        },
      });
      continue;
    }
    if (e.kind === "user_message") {
      steps.push({
        tag: "msg",
        item: { kind: "user_message", key: e.id, body: e.body },
      });
      continue;
    }
    if (e.kind === "assistant_text") {
      steps.push({
        tag: "msg",
        item: { kind: "assistant_text", key: e.id, body: e.body },
      });
      continue;
    }
    if (e.kind === "unknown") {
      steps.push({
        tag: "msg",
        item: { kind: "system_unknown", key: e.id, raw_type: e.raw_type },
      });
      continue;
    }
  }
  const out: RenderedItem[] = [];
  let buffer: ToolEntry[] = [];
  let bufferKey: string | null = null;
  const flush = () => {
    if (buffer.length === 0) return;
    out.push({ kind: "tool_group", key: `tg:${bufferKey}`, tools: buffer });
    buffer = [];
    bufferKey = null;
  };
  for (const step of steps) {
    if (step.tag === "tool") {
      if (bufferKey === null) bufferKey = step.key;
      buffer.push(step.entry);
    } else {
      flush();
      out.push(step.item);
    }
  }
  flush();
  return out;
}

function RenderItem({ item }: { item: RenderedItem }) {
  if (item.kind === "user_message") {
    const isKickoff =
      item.body.startsWith("(task assignment)") ||
      item.body.startsWith("(session start)") ||
      item.body.startsWith("TASK_BRIEF") ||
      item.body.startsWith("FIRST_TURN");
    // Kickoff messages duplicate the task header's Brief expandable
    // (and for FIRST_TURN flows, the on-screen onboarding context).
    // Drop them from the transcript so the chat starts with the
    // assistant's reply instead of the raw assignment payload.
    if (isKickoff) return null;
    return <UserBubble body={item.body} />;
  }
  if (item.kind === "assistant_text") {
    return <AssistantText body={item.body} />;
  }
  if (item.kind === "tool_group") {
    return <ToolGroup tools={item.tools} />;
  }
  return null;
}

function UserBubble({ body }: { body: string }) {
  return (
    <div className="flex justify-end">
      <div className="max-w-[85%] rounded-2xl bg-muted px-4 py-2 text-sm leading-relaxed whitespace-pre-wrap break-words">
        {body}
      </div>
    </div>
  );
}

function AssistantText({ body }: { body: string }) {
  // Orchestration side effects now go through MCP tool calls, not
  // pseudo-XML blocks. We render the assistant's prose as-is.
  if (body.trim() === "") return null;
  return (
    <div className="text-sm leading-relaxed">
      <Markdown>{body}</Markdown>
    </div>
  );
}

function ToolGroup({ tools }: { tools: ToolEntry[] }) {
  const inFlightCount = tools.filter((t) => !t.done).length;
  const isLive = inFlightCount > 0;
  const headline =
    tools.find((t) => !t.done) ?? tools[tools.length - 1] ?? null;
  // Group status reflects the FINAL outcome, not "any error ever". When the
  // agent retried a failed call and the retry succeeded, the user sees
  // green — only expanding the card reveals the intermediate stumble.
  // Matches Claude.ai's pattern of grading by "did this turn ultimately
  // work" rather than punishing every recoverable hiccup.
  const lastDone = [...tools].reverse().find((t) => t.done);
  const hasError = !!(lastDone && !lastDone.ok);
  const HeadIcon = headline ? iconForTool(headline.name) : Wrench;
  const StatusIcon = isLive
    ? Loader2
    : hasError
      ? AlertCircle
      : CheckCircle2;
  const statusClass = isLive
    ? "text-muted-foreground motion-safe:animate-spin"
    : hasError
      ? "text-destructive"
      : "text-emerald-600";

  return (
    <details
      key={isLive ? "live" : "done"}
      open={isLive}
      className="group rounded-md border bg-muted/20"
    >
      <summary
        className={cn(
          "flex cursor-pointer select-none items-center gap-2 px-3 py-2 text-xs",
          "rounded-md hover:bg-muted/40 [&::-webkit-details-marker]:hidden",
        )}
      >
        <ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
        <StatusIcon className={cn("size-3.5 shrink-0", statusClass)} />
        <HeadIcon className="size-3.5 shrink-0 text-muted-foreground" />
        <span className="font-mono text-[11px] font-medium text-foreground">
          {headline ? headline.name : "tool"}
        </span>
        {headline?.label && (
          <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground">
            {headline.label}
          </span>
        )}
        <span className="ml-auto text-[10px] tabular-nums text-muted-foreground">
          {isLive ? (
            <span className="inline-flex items-center gap-1.5">
              <RunningDot size="sm" aria-label="" />
              {tools.length === 1
                ? "working"
                : `${tools.length} steps · ${inFlightCount} live`}
            </span>
          ) : (
            <>
              {tools.length} step{tools.length === 1 ? "" : "s"}
            </>
          )}
        </span>
      </summary>
      <div className="space-y-1 border-t bg-background/40 px-3 py-2">
        {tools.map((t) => (
          <ToolRow key={t.toolCallId} entry={t} />
        ))}
      </div>
    </details>
  );
}

function ToolRow({ entry }: { entry: ToolEntry }) {
  const Icon = iconForTool(entry.name);
  const StatusIcon = entry.done
    ? entry.ok
      ? CheckCircle2
      : AlertCircle
    : Loader2;
  const statusClass = entry.done
    ? entry.ok
      ? "text-emerald-600"
      : "text-destructive"
    : "text-muted-foreground motion-safe:animate-spin";
  return (
    <div className="space-y-0.5">
      <div className="flex items-center gap-2 text-xs">
        <StatusIcon className={cn("size-3.5 shrink-0", statusClass)} />
        <Icon className="size-3.5 shrink-0 text-muted-foreground" />
        <span className="font-mono text-[11px] font-medium text-foreground">
          {entry.name}
        </span>
        {entry.label && (
          <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground">
            {entry.label}
          </span>
        )}
      </div>
      {entry.done && entry.result && (
        <div className="pl-6 font-mono text-[11px] text-muted-foreground/90">
          <span className="text-[10px] uppercase tracking-[0.18em]">
            {entry.ok ? "→ result" : "→ error"}
          </span>{" "}
          <span className="break-words">{entry.result}</span>
        </div>
      )}
    </div>
  );
}

/**
 * Replaces WorkingStatus when the task is parked in `blocked`. The agent
 * isn't currently running — it's dormant until the gating condition (most
 * often a pending approval) resolves. Showing "thinking…" or "wrapping
 * up…" here misleads the user into thinking work is still happening.
 */
function BlockedStatus({ reason }: { reason: string }) {
  return (
    <div className="flex items-start gap-2.5 rounded-md border border-dashed border-amber-500/40 bg-amber-50/40 px-3 py-2 text-xs dark:bg-amber-950/20">
      <span
        className="mt-1 inline-block size-2 shrink-0 rounded-full bg-amber-500"
        aria-hidden
      />
      <div className="min-w-0 flex-1">
        <div className="font-medium text-amber-900 dark:text-amber-200">
          Paused — {reason}
        </div>
        <div className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-300/70">
          The agent will resume automatically when the gating condition
          resolves. You can also reply below to give context or answer a
          question.
        </div>
      </div>
    </div>
  );
}

/**
 * LiveWorkingIndicator — the bottom-anchored "agent is working" card.
 *
 * Wraps the presentational WorkingIndicator with the project-specific
 * logic: derive headline/subtitle/mood/phases from a mix of SSE-leading
 * pending state and JSONL-trailing committed events, then drive the
 * 1Hz elapsed counter. Kept inside live-transcript so the derivation
 * has the full union of TranscriptEvent + ToolEntry types in scope
 * without exporting either across the module boundary.
 *
 * Replaces the old WorkingStatus that paired a small cyan ✳ with
 * cycling verbs (Pondering / Cogitating / Effervescing …). The new
 * indicator carries the same information in a denser, more vivid
 * layout — see working-indicator.tsx for the visual treatment.
 */
function LiveWorkingIndicator({
  agentDisplayName,
  events,
  turnStartedAt,
  lifecyclePhase,
  pendingTools,
  hasPendingAssistant,
}: {
  agentDisplayName: string;
  events: TranscriptEvent[];
  turnStartedAt: number | null;
  lifecyclePhase?: string | null;
  pendingTools?: ToolEntry[];
  hasPendingAssistant?: boolean;
}) {
  const [now, setNow] = useState<number>(() => Date.now());
  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);

  const view = deriveWorkingView({
    agentDisplayName,
    events,
    lifecyclePhase: lifecyclePhase ?? null,
    pendingTools: pendingTools ?? [],
    hasPendingAssistant: hasPendingAssistant ?? false,
    // The indicator must only reflect the current turn — without this
    // filter, the trajectory chips show every tool call from the chat's
    // entire history (e.g. a fresh "hi" lit up runScript / runScript /
    // listConnectedAccounts from prior audit turns).
    turnStartedAt,
  });

  // Anchor elapsed to whichever is later: the turn-start wallclock the
  // composer recorded, or the last event's timestamp. Keeps the counter
  // honest during the SSE-only window where events are still empty.
  const lastEvent = events.length > 0 ? events[events.length - 1] : null;
  const anchorTs = (() => {
    const lastTs = lastEvent?.ts ?? null;
    if (turnStartedAt && lastTs) return Math.max(turnStartedAt, lastTs);
    return turnStartedAt ?? lastTs;
  })();
  const elapsedMs = anchorTs != null ? Math.max(0, now - anchorTs) : null;

  return (
    <WorkingIndicator
      agentDisplayName={agentDisplayName}
      headline={view.headline}
      subtitle={view.subtitle}
      phases={view.phases}
      mood={view.mood}
      elapsedMs={elapsedMs}
    />
  );
}

type WorkingView = {
  headline: string;
  subtitle: string | null;
  phases: WorkingPhase[];
  mood: WorkingMood;
};

/**
 * Build the visible state for the working indicator from the current
 * SSE pending state + committed JSONL events.
 *
 * Precedence (most-specific first):
 *   1. An SSE-pending tool start that hasn't reported a result → that's
 *      the active phase, mood "tool".
 *   2. An SSE-pending assistant text stream → mood "writing".
 *   3. No events yet (pre-first-token kickoff wait) → mood "waiting"
 *      with the gateway lifecycle hint as subtitle.
 *   4. A committed in-flight tool_call → mood "tool" (polling caught up
 *      before the SSE state did, e.g. on rejoin from another tab).
 *   5. Last committed event is a tool_result → mood "waiting".
 *   6. Last committed event is assistant_text → mood "wrapping".
 *
 * Phases are derived from BOTH sources so the trajectory chips include
 * recent SSE tools the JSONL hasn't seen yet, then dedupe by tool name.
 */
function deriveWorkingView(input: {
  agentDisplayName: string;
  events: TranscriptEvent[];
  lifecyclePhase: string | null;
  pendingTools: ToolEntry[];
  hasPendingAssistant: boolean;
  /**
   * Wallclock when the current turn started. We use it to scope the
   * trajectory chips (and the in-flight tool detection) to the active
   * turn — events from earlier turns in the same thread don't belong
   * in "what is the agent doing right now?".
   */
  turnStartedAt: number | null;
}): WorkingView {
  const { agentDisplayName, events, lifecyclePhase, pendingTools, hasPendingAssistant, turnStartedAt } = input;
  // Only consider events from the current turn. Anything older is part
  // of the persistent transcript above this card, not the live status.
  const turnEvents =
    turnStartedAt != null
      ? events.filter((e) => e.ts >= turnStartedAt)
      : events;
  const lastEvent = turnEvents.length > 0 ? turnEvents[turnEvents.length - 1] : null;
  const pendingInFlightTool = pendingTools.find((t) => !t.done) ?? null;
  const inFlightCommittedToolCall: Extract<
    TranscriptEvent,
    { kind: "tool_call" }
  > | null = (() => {
    if (!lastEvent || lastEvent.kind !== "tool_call") return null;
    const matched = turnEvents.some(
      (e) => e.kind === "tool_result" && e.tool_call_id === lastEvent.tool_call_id,
    );
    return matched ? null : lastEvent;
  })();
  const phases = buildPhases(turnEvents, pendingTools);

  if (pendingInFlightTool) {
    return {
      headline: `Calling ${formatToolName(pendingInFlightTool.name)}`,
      subtitle: pendingInFlightTool.label ?? null,
      phases,
      mood: "tool",
    };
  }
  if (hasPendingAssistant) {
    return {
      headline: "Writing the response",
      subtitle: subtitleForLastTool(pendingTools, turnEvents),
      phases,
      mood: "writing",
    };
  }
  // Between-tools window: SSE has seen at least one tool finish this
  // turn but the model hasn't started the next thing yet (no in-flight
  // pending tool, no streaming text, no fresh JSONL event because Codex
  // buffers the file until end-of-turn). Without this branch we'd fall
  // through to "Starting", which is wrong — the agent IS thinking, just
  // about its next move. Surface the last tool so the user has context.
  if (pendingTools.length > 0) {
    const lastDone = [...pendingTools].reverse().find((t) => t.done);
    return {
      headline: "Thinking",
      subtitle: lastDone
        ? `${formatToolName(lastDone.name)} ${lastDone.ok ? "✓" : "failed"} — picking next step`
        : null,
      phases,
      mood: "waiting",
    };
  }
  if (!lastEvent || lastEvent.kind === "user_message" || lastEvent.kind === "unknown") {
    // The gateway lifecycle phase IS the status when it arrives — showing
    // "Starting" with a "Calling the model" subtitle stacked two
    // contradictory descriptions of the same moment. Promote whichever
    // signal we have to the headline; the generic "Starting" only fires
    // before any lifecycle event lands.
    const lifecycleSummary = lifecyclePhase
      ? humanLifecyclePhase(lifecyclePhase)
      : null;
    return {
      headline: lifecycleSummary ?? "Starting",
      subtitle: null,
      phases,
      mood: "waiting",
    };
  }
  if (inFlightCommittedToolCall) {
    return {
      headline: `Calling ${formatToolName(inFlightCommittedToolCall.name)}`,
      subtitle: inFlightCommittedToolCall.label ?? null,
      phases,
      mood: "tool",
    };
  }
  if (lastEvent.kind === "tool_result") {
    return {
      headline: "Thinking",
      subtitle: lastEvent.ok
        ? `${formatToolName(lastEvent.name)} ✓ — picking next step`
        : `${formatToolName(lastEvent.name)} failed — retrying`,
      phases,
      mood: "waiting",
    };
  }
  // assistant_text
  return {
    headline: "Wrapping up",
    subtitle: subtitleForLastTool(pendingTools, events),
    phases,
    mood: "wrapping",
  };
}

function subtitleForLastTool(
  pendingTools: ToolEntry[],
  events: TranscriptEvent[],
): string | null {
  const lastDonePending = [...pendingTools].reverse().find((t) => t.done);
  if (lastDonePending) {
    return `${formatToolName(lastDonePending.name)} ${lastDonePending.ok ? "✓" : "failed"}`;
  }
  const lastDoneCommitted = [...events]
    .reverse()
    .find((e): e is Extract<TranscriptEvent, { kind: "tool_result" }> =>
      e.kind === "tool_result",
    );
  if (lastDoneCommitted) {
    return `${formatToolName(lastDoneCommitted.name)} ${lastDoneCommitted.ok ? "✓" : "failed"}`;
  }
  return null;
}

/**
 * Build the trajectory pills shown in the indicator. Order:
 *   1. Committed tools (in order, deduped by tool_call_id).
 *   2. SSE-pending tools not yet in the committed list.
 * The last entry is the "active" one. Done entries get a check, the
 * active one gets the mood-colored ring + wrench glyph.
 */
function buildPhases(
  events: TranscriptEvent[],
  pendingTools: ToolEntry[],
): WorkingPhase[] {
  const seen = new Set<string>();
  const phases: WorkingPhase[] = [];

  // Walk committed events to build phases in order.
  const committedById = new Map<
    string,
    { name: string; done: boolean; ok: boolean }
  >();
  for (const e of events) {
    if (e.kind === "tool_call") {
      committedById.set(e.tool_call_id, { name: e.name, done: false, ok: true });
    } else if (e.kind === "tool_result") {
      const prev = committedById.get(e.tool_call_id);
      if (prev) {
        prev.done = true;
        prev.ok = e.ok;
      } else {
        committedById.set(e.tool_call_id, { name: e.name, done: true, ok: e.ok });
      }
    }
  }
  for (const [id, t] of committedById) {
    seen.add(id);
    phases.push({
      id,
      label: formatToolName(t.name),
      state: !t.done ? "active" : t.ok ? "done" : "failed",
    });
  }

  // Then any SSE-pending tools the committed list doesn't have yet.
  for (const t of pendingTools) {
    if (seen.has(t.toolCallId)) continue;
    phases.push({
      id: t.toolCallId,
      label: formatToolName(t.name),
      state: !t.done ? "active" : t.ok ? "done" : "failed",
      detail: t.label ?? null,
    });
  }

  // If multiple phases are flagged "active" (shouldn't happen, but defend),
  // demote all but the last so the visual stays clear.
  let lastActive = -1;
  for (let i = 0; i < phases.length; i++) {
    if (phases[i]!.state === "active") lastActive = i;
  }
  return phases.map((p, i) =>
    p.state === "active" && i !== lastActive ? { ...p, state: "done" } : p,
  );
}

/**
 * Translate an OpenClaw `agent` stream lifecycle phase into a short
 * user-facing label. We don't try to enumerate every possible phase —
 * unknown values fall through to a generic "starting up…" so a future
 * gateway protocol bump doesn't render a blank status. Used by the
 * pre-first-event branch of WorkingStatus (the ~15s pause where the
 * model is parsing the system prompt and brief).
 */
function humanLifecyclePhase(phase: string): string {
  const p = phase.toLowerCase();
  // Capitalized + no trailing ellipsis — these strings are used as the
  // indicator's headline now, alongside "Calling runScript" / "Thinking"
  // / "Writing the response". BreathingDots after the headline handles
  // the "…" affordance.
  if (p.includes("warming") || p.includes("warmup")) return "Warming up";
  if (p.includes("compact")) return "Compacting context";
  if (p === "start" || p.endsWith(".start") || p.includes("run.start"))
    return "Calling the model";
  if (p.includes("end") || p.includes("complete")) return "Finishing up";
  return "Starting up";
}

/**
 * Strip the project-slug prefix and the namespace path so the user sees
 * the bare action name. Tools come in two conventions:
 *   `<mcp>.action`             → e.g. `notfair.runScript`
 *   `<project>-<mcp>__action`  → e.g. `demo1-notfair-googleads__runScript`
 * Both should render as just the action. Falls back to the input untouched.
 */
function formatToolName(name: string): string {
  if (!name) return name;
  for (const sep of ["__", "."]) {
    const idx = name.lastIndexOf(sep);
    if (idx >= 0) {
      const tail = name.slice(idx + sep.length);
      if (tail) return tail;
    }
  }
  return name;
}

function ErrorRow({
  agentDisplayName,
  body,
}: {
  agentDisplayName: string;
  body: string;
}) {
  return (
    <div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/5 p-3 text-sm">
      <AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
      <div className="min-w-0 flex-1">
        <div className="font-medium text-destructive">
          Couldn&rsquo;t reach {agentDisplayName}.
        </div>
        <div className="mt-1 text-xs text-muted-foreground whitespace-pre-wrap break-words">
          {body}
        </div>
      </div>
    </div>
  );
}

function TranscriptEmptyState({
  agentDisplayName,
}: {
  agentDisplayName: string;
}) {
  return (
    <div className="py-12 text-center text-sm text-muted-foreground">
      No messages yet. Say hi to {agentDisplayName} below.
    </div>
  );
}

function iconForTool(name: string): LucideIcon {
  const n = name.toLowerCase();
  if (n === "exec" || n === "shell" || n === "bash" || n.includes("bash"))
    return Terminal;
  if (n === "read" || n === "cat" || n === "open" || n.includes("read"))
    return FileText;
  if (n === "write" || n === "edit" || n === "patch") return Edit3;
  if (n === "fetch" || n.includes("http") || n.includes("web")) return Globe;
  return Wrench;
}
