import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { generateId } from "ai";
import {
  ActionIcon,
  Alert,
  Box,
  Button,
  Group,
  Loader,
  Stack,
  Text,
  Tooltip,
} from "@mantine/core";
import {
  IconClockHour4,
  IconPlayerPlayFilled,
  IconX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import MessageList from "@/features/ai-chat/components/message-list.tsx";
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
import {
  IAiChatMessageRow,
  IAiRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import {
  roleLaunchMessage,
  shouldResetRolePicked,
} from "@/features/ai-chat/utils/role-launch.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import {
  extractServerChatId,
  extractRunId,
} from "@/features/ai-chat/utils/adopt-chat-id.ts";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import {
  isStreamingTail,
  isSettledAssistantTail,
  stepsPersistedOf,
  mergeById,
} from "@/features/ai-chat/utils/resume-helpers.ts";
import { getRun } from "@/features/ai-chat/services/ai-chat-service.ts";
import { AI_CHAT_MESSAGES_RQ_KEY } from "@/features/ai-chat/queries/ai-chat-query.ts";
import type { EditorSelectionContext } from "@/features/editor/utils/get-editor-selection.ts";
import {
  dequeue,
  enqueueMessage,
  promoteToHead,
  removeQueuedById,
  type QueuedMessage,
} from "@/features/ai-chat/utils/queue-helpers.ts";
import {
  reduce,
  initialMachine,
  RECONNECT_MAX_ATTEMPTS,
  type Machine,
  type Event as RunEvent,
  type Effect as RunEffect,
} from "@/features/ai-chat/state/run-fsm.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";

// Throttle how often the streamed `messages` state triggers a re-render. Without
// it, useChat updates state on EVERY token, so the whole transcript's markdown
// (marked + DOMPurify) is re-parsed per token — on a long agent run that grows
// into a quadratic CPU storm that pins the main thread and freezes the UI.
// ~50ms (20 Hz) keeps streaming visually smooth while decoupling re-render cost
// from the token rate.
const STREAM_THROTTLE_MS = 50;

// #488 commit 4a: how long the degraded poll may run with NO new run activity
// (its persisted rows unchanged) before the FSM declares the run STALLED and
// surfaces a banner + Retry — instead of the old silent `refetchInterval -> false`
// ("forever half-done answer"). Measured from INACTIVITY, so a long-but-progressing
// run keeps polling; only a genuinely stuck run trips it. This cap lives in the
// THREAD now (the FSM owns polling->stalled); the window just polls while armed.
const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000;

/** The #487 active (non-terminal) run statuses — mirrors the server's
 *  ACTIVE_RUN_STATUSES. A run-fact is "active" only for these. */
function isActiveRunStatus(status: string | null | undefined): boolean {
  return status === "pending" || status === "running";
}

/** Read the `{ code, activeRunId }` off a JSON error response WITHOUT consuming
 *  the original body (reads a clone), so the caller can still return the Response
 *  untouched to the SDK. `activeRunId` is the server's field for the run currently
 *  active on the chat — it is the name emitted on BOTH 409 branches
 *  (SUPERSEDE_TARGET_MISMATCH and A_RUN_ALREADY_ACTIVE, see ai-chat.controller.ts).
 *  Reading `runId` here (the field the server never sends) silently yields
 *  `undefined`. Any parse failure => empty. */
async function read409(response: Response): Promise<{ code?: string; activeRunId?: string }> {
  try {
    const b = (await response.clone().json()) as { code?: unknown; activeRunId?: unknown };
    return {
      code: typeof b?.code === "string" ? b.code : undefined,
      activeRunId: typeof b?.activeRunId === "string" ? b.activeRunId : undefined,
    };
  } catch {
    return {};
  }
}

/** The page the user is currently viewing, sent as chat context. */
export interface OpenPageContext {
  id: string;
  title: string;
}

interface ChatThreadProps {
  /** The open chat id, or null for a brand-new (not-yet-created) chat. */
  chatId: string | null;
  /** This thread's mount key (the same value the parent uses as React `key`).
   *  Forwarded to onTurnFinished so the session can tell a turn finishing on the
   *  CURRENT thread from one ABANDONED by New chat mid-stream — whose onFinish/
   *  onError still fire after unmount and must not adopt the abandoned chat (#161). */
  threadKey?: string;
  /** Persisted rows to seed initial messages (existing chats only). */
  initialRows?: IAiChatMessageRow[];
  /** The page currently open in the workspace, or null on a non-page route.
   *  Sent with each turn so the agent knows what "this page" refers to. */
  openPage?: OpenPageContext | null;
  /** #388: snapshot the user's current editor selection at SEND time. Invoked
   *  inside prepareSendMessagesRequest and nested into openPage on the wire, so a
   *  fresh snapshot ships each turn. Null/absent => nothing selected. */
  getEditorSelection?: () => EditorSelectionContext | null;
  /** The agent role selected for a NEW chat (null = universal assistant). Sent
   *  in the request body so the server persists it on chat creation; ignored by
   *  the server for existing chats (the role is read from the chat row). */
  roleId?: string | null;
  /** Enabled roles for the new-chat empty state (only meaningful when
   *  `chatId === null`). Rendered as the colored role cards. */
  roles?: IAiRole[];
  /** Notify the parent which role was picked via a card, so it can update the
   *  header badge / assistant name for the brand-new chat. */
  onRolePicked?: (role: IAiRole) => void;
  /** Display name for the assistant label / typing line (the role name);
   *  forwarded to MessageList. Absent => the generic "AI agent". */
  assistantName?: string;
  /** Called when a turn finishes; the parent refreshes the chat list and, for a
   *  new chat, adopts the freshly created chat id. `serverChatId` is the
   *  authoritative id the server streamed on the assistant message metadata, or
   *  undefined on a failed turn — see adopt-chat-id.ts for the full #137 design.
   *  `finishingThreadKey` (this thread's mount key) lets the session ignore a turn
   *  finishing on a thread already abandoned by New chat mid-stream (#161). */
  onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
  /** Called EARLY (at the stream's `start` chunk) with the authoritative server
   *  chat id streamed on the assistant message metadata, so a brand-new chat
   *  adopts its real id WHILE the first turn is still streaming (#174 — makes the
   *  Copy/export button available mid-stream). Distinct from onTurnFinished,
   *  which fires only at the terminal outcome. */
  onServerChatId?: (serverChatId?: string) => void;
  /** #184 phase 1.5: arm/disarm the parent's degraded-poll fallback for THIS
   *  chat's window. Called `true` when the FSM enters a poll-bearing recovery
   *  (attach 204 / starved-or-torn resumed finish / a stop), `false` the moment a
   *  local stream starts or the run settles. The window owns only the dumb 2.5s
   *  timer; the THREAD's FSM owns arm/disarm + the stalled cap (#488). */
  onResumeFallback?: (active: boolean) => void;
  /** #184: whether detached/autonomous agent runs are enabled for this workspace.
   *  When true the Stop button must additionally hit the AUTHORITATIVE server stop
   *  (via onServerStop) — aborting only the local SSE is just a client disconnect,
   *  which the server deliberately ignores, so the detached run would keep going. */
  autonomousRunsEnabled?: boolean;
  /** #184: request the server-side stop of this chat's active run. Called with the
   *  resolved chat id when the user presses Stop in autonomous mode. */
  onServerStop?: (chatId: string) => void;
}

/**
 * Map a persisted server row to an AI SDK UIMessage. Mirrors the server's
 * `rowToUiMessage`: `metadata.parts` are the UIMessage parts; otherwise fall
 * back to a single text part built from the plain-text `content`.
 */
function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
  const role = row.role === "assistant" ? "assistant" : "user";
  const parts =
    Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
      ? row.metadata.parts
      : ([{ type: "text", text: row.content ?? "" }] as UIMessage["parts"]);
  const error = row.metadata?.error;
  const finishReason = row.metadata?.finishReason;
  const metadata: Record<string, unknown> = {};
  if (error) metadata.error = error;
  if (finishReason) metadata.finishReason = finishReason;
  return {
    id: row.id,
    role,
    parts,
    // Carry persisted turn outcome (error text and/or finishReason) so MessageItem
    // can render the error banner / "stopped" marker after a remount and in
    // reopened history.
    ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
  } as UIMessage;
}

/**
 * Owns the AI SDK `useChat` lifecycle for ONE chat. The parent remounts this
 * with a `key` when the selected chat changes, so initial messages re-seed
 * cleanly (the v6 transport-based hook keeps its state per mount).
 *
 * #488: the resume/reconnect/poll/stop/supersede lifecycle is driven by the pure
 * FSM in `state/run-fsm.ts` (see `run-fsm.spec.md`). The component is the RUNTIME:
 * it dispatches typed events (from SDK callbacks / HTTP outcomes) and executes the
 * reducer's command EFFECTS (attach GET, POST /run, POST /stop, POST /stream
 * supersede, backoff timers, poll arm/disarm). The FSM lives in this thread (not
 * the window) so a late SDK callback dies with the owner (#161). The one-shot-ref
 * zoo is gone: the epoch counter (I1) drops stale-generation outcomes.
 */
export default function ChatThread({
  chatId,
  threadKey,
  initialRows,
  openPage,
  getEditorSelection,
  roleId,
  roles,
  onRolePicked,
  assistantName,
  onTurnFinished,
  onServerChatId,
  onResumeFallback,
  autonomousRunsEnabled,
  onServerStop,
}: ChatThreadProps) {
  const { t } = useTranslation();
  const queryClient = useQueryClient();

  // --- The run-lifecycle FSM (source of truth) -----------------------------
  // Stored in state (drives UI re-render on phase/ctx change) + mirrored in a ref
  // for the transport/timer closures (useMemo([])-stable) that read it live.
  const [machine, setMachine] = useState<Machine>(() => initialMachine());
  const machineRef = useRef<Machine>(machine);
  // I1: the current generation. Command-transitions bump it; async OUTCOME events
  // carry the epoch they were issued under; a mismatch is dropped by the reducer.
  const epochRef = useRef(0);
  // The generation an in-flight attach GET was issued under (for stamping its
  // 2xx/204/throw outcome). Replaces the one-shot `noStreamHandledRef` guard.
  const pendingAttachEpochRef = useRef(0);
  // Stable dispatch handle for closures defined before `dispatch` (transport,
  // timers) — assigned once `dispatch` exists below.
  const dispatchRef = useRef<(e: RunEvent) => void>(() => undefined);

  // --- Effect-dependency refs (live values for the stable effect runner) -----
  // These are useChat/prop values that change identity across renders; the effect
  // runner (useCallback([])) reads them live so it never captures a stale closure.
  const resumeStreamRef = useRef<(() => Promise<void> | void) | null>(null);
  const setMessagesRef = useRef<
    ((updater: (prev: UIMessage[]) => UIMessage[]) => void) | null
  >(null);
  const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
  const stopFnRef = useRef<(() => void) | null>(null);
  const onResumeFallbackRef = useRef<typeof onResumeFallback>(onResumeFallback);
  onResumeFallbackRef.current = onResumeFallback;
  const onServerStopRef = useRef<typeof onServerStop>(onServerStop);
  onServerStopRef.current = onServerStop;

  // Live mount flag: the attach GET / a resumed onFinish are async and can land
  // AFTER unmount (the parent remounts per chat). The epoch (I1) drops stale FSM
  // OUTCOMES, but the imperative onFinish side-effects (parent flush, invalidate)
  // still need a plain React-liveness bit — orthogonal to the run-lifecycle, so it
  // is NOT one of the lifecycle flags the FSM replaced.
  const mountedRef = useRef(true);

  // attachStrategy DATA (behind the resumeStream effect; #491 tail-only, WITHOUT
  // touching the FSM). The controller is effect-owned (aborted in cleanup, I5).
  // `anchorRef` is the PERSISTED assistant row that pins the run (server invariant
  // 6) and its persisted step frontier N: it feeds `?anchor=<id>&n=<stepsPersisted>`
  // so the tail-only attach returns frames for steps >= N (the seed carries 0..N-1).
  // It is NOT a "stripped" row — the seed keeps every row (tail-only replaces the
  // old full-replay+strip). Null when there is no streaming/active tail to resume.
  const attachAbortRef = useRef<AbortController | null>(null);
  const anchorRef = useRef<{ id: string; stepsPersisted: number } | null>(
    (() => {
      if (chatId === null || !isStreamingTail(initialRows ?? [])) return null;
      const rows = initialRows ?? [];
      const tail = rows[rows.length - 1];
      return { id: tail.id, stepsPersisted: stepsPersistedOf(tail) };
    })(),
  );
  // Effect-owned backoff timers (not lifecycle flags): the reconnect ladder and the
  // stalled inactivity cap. Cleared by the cancelReconnect effect / the cap effect.
  const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const idleCapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  // #491 tail-only: seed EVERY persisted row unchanged (no strip). The streaming
  // tail holds steps 0..N-1; the run-stream registry's tail (steps >= N) is APPENDED
  // to it by the SDK continuation (readUIMessageStream({ message })), so it must be
  // present in the store for the attach to continue the RIGHT message.
  const initialMessages = useMemo<UIMessage[]>(
    () => (initialRows ?? []).map(rowToUiMessage),
    [initialRows],
  );

  // Identity/data mirrors (NOT lifecycle flags — they stay as data).
  const chatIdRef = useRef<string | null>(chatId);
  chatIdRef.current = chatId;
  const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
  openPageRef.current = openPage ?? null;
  const getEditorSelectionRef = useRef<
    (() => EditorSelectionContext | null) | undefined
  >(getEditorSelection);
  getEditorSelectionRef.current = getEditorSelection;
  const roleIdRef = useRef<string | null>(roleId ?? null);
  roleIdRef.current = roleId ?? null;
  // Stable useChat store key for this mount (see the long #137/#174 note that used
  // to live here — recreating the store mid-stream wipes the live turn).
  const stableIdRef = useRef<string>(chatId ?? `new-${generateId()}`);
  const chatStoreId = stableIdRef.current;

  // Pending messages composed while a turn streams; flushed FIFO on a clean finish.
  const [queued, setQueued] = useState<QueuedMessage[]>([]);
  const queuedRef = useRef<QueuedMessage[]>([]);
  const setQueue = useCallback((next: QueuedMessage[]) => {
    queuedRef.current = next;
    setQueued(next);
  }, []);

  // #488 commit 5: the runId to inject into the NEXT POST as `supersede:{runId}`
  // (read-and-cleared by prepareSendMessagesRequest). This is send-plumbing DATA
  // (like chatIdRef/roleIdRef), the single replacement for the three REMOVED
  // one-shot flags flushOnAbortRef/interruptNextSendRef/supersedeRetryRef.
  const pendingSupersedeRef = useRef<string | null>(null);
  // #488 F1: the interrupt-and-send text, held until the superseded stream A has
  // FULLY finalized (its SDK `finally` cleared `activeResponse`) — only THEN is
  // stream B started, so A cannot corrupt B (ai@6 AbstractChat.makeRequest reads
  // `this.activeResponse.state.message` in its finally and then nulls it, so an
  // overlapping B is clobbered). Send-plumbing DATA, not a lifecycle flag.
  const pendingSupersedeTextRef = useRef<string | null>(null);
  // #488 F1: the epoch under which the CURRENTLY-OWNED stream started, used to
  // STAMP its onFinish (I1). A superseded/dead stream's late finish carries an OLD
  // generation and is dropped by the reducer, so it cannot drive the live machine
  // into a false reconnect or reset its run-fact. Set at each honored stream start
  // (local send, resume/reconnect attach, the supersede B-send).
  const turnEpochRef = useRef(0);

  // --- Effect runner: executes the reducer's command effects ---------------
  const runEffect = useCallback(
    (eff: RunEffect, epoch: number) => {
      switch (eff.type) {
        case "resumeStream": {
          // The attach GET. Stamp the outcome's generation (I1). #491 tail-only: the
          // store already holds EXACTLY the persisted steps 0..N-1 (the mount seed IS
          // persist; a reconnect was re-seeded from persist BEFORE FINISH_DISCONNECT
          // scheduled it — see the onFinish disconnect handler), so there is nothing
          // to filter here: the SDK continues that seeded message, appending the tail
          // (steps >= N) without duplicating the pre-drop partial step.
          pendingAttachEpochRef.current = epoch;
          // The resumed stream's onFinish is stamped with THIS attach generation
          // (F1), so a superseded attempt's late finish is dropped.
          turnEpochRef.current = epoch;
          void resumeStreamRef.current?.();
          break;
        }
        case "scheduleReconnect": {
          if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
          const attempt = eff.attempt;
          const scheduledEpoch = epoch;
          reconnectTimerRef.current = setTimeout(() => {
            dispatchRef.current({
              type: "RECONNECT_ATTEMPT",
              attempt,
              epoch: scheduledEpoch,
            });
          }, eff.delayMs);
          break;
        }
        case "cancelReconnect":
          if (reconnectTimerRef.current) {
            clearTimeout(reconnectTimerRef.current);
            reconnectTimerRef.current = null;
          }
          break;
        case "armPoll":
          onResumeFallbackRef.current?.(true);
          break;
        case "disarmPoll":
          onResumeFallbackRef.current?.(false);
          break;
        case "abortAttach":
          attachAbortRef.current?.abort();
          break;
        case "stopRun":
          // Authoritative stop. When the chat id is not known yet (brand-new chat's
          // first moment), it is deferred: the chat-id adoption effect fires
          // onServerStop the moment the id lands while still in `stopping`.
          if (chatIdRef.current)
            onServerStopRef.current?.(chatIdRef.current);
          break;
        case "postRun": {
          // (Re)establish / verify the run-fact from POST /ai-chat/run. Stamp the
          // RUN_FACT outcome with the issuing generation so a superseded verify is
          // dropped (I1).
          const cid = chatIdRef.current;
          if (!cid) break;
          const ep = epoch;
          void getRun(cid)
            .then((res) => {
              const active =
                res.run && isActiveRunStatus(res.run.status)
                  ? { runId: res.run.id }
                  : null;
              dispatchRef.current({ type: "RUN_FACT", runFact: active, epoch: ep });
            })
            .catch(() => undefined);
          break;
        }
        case "supersede":
          // Arm the next POST to carry `supersede:{runId}`; the send itself is
          // triggered by the caller (sendNow) via sendMessage.
          pendingSupersedeRef.current = eff.targetRunId;
          break;
      }
    },
    // Reads only refs + the stable queryClient — safe as an empty-dep callback.
    [],
  );

  // Dispatch: reduce, run effects, re-render on a real state change.
  const dispatch = useCallback(
    (event: RunEvent) => {
      const prev = machineRef.current;
      const next = reduce(prev, event);
      machineRef.current = next;
      epochRef.current = next.ctx.epoch;
      if (next.phase !== prev.phase || next.ctx !== prev.ctx) setMachine(next);
      for (const eff of next.effects) runEffect(eff, next.ctx.epoch);
    },
    [runEffect],
  );
  dispatchRef.current = dispatch;

  // FIFO dequeue + local send of the next queued message (no-op when empty).
  const localSend = useCallback((text: string) => {
    dispatchRef.current({ type: "SEND_LOCAL" });
    // F1: this local stream's onFinish is stamped with the just-bumped generation.
    turnEpochRef.current = epochRef.current;
    sendMessageRef.current?.({ text });
  }, []);
  const flushNext = useCallback(() => {
    const { head, rest } = dequeue(queuedRef.current);
    if (!head) return false;
    setQueue(rest);
    localSend(head.text);
    return true;
  }, [setQueue, localSend]);

  const enqueue = useCallback(
    (text: string) => {
      setQueue(enqueueMessage(queuedRef.current, { id: generateId(), text }));
    },
    [setQueue],
  );
  const removeQueued = useCallback(
    (id: string) => {
      setQueue(removeQueuedById(queuedRef.current, id));
    },
    [setQueue],
  );

  const transport = useMemo(
    () =>
      new DefaultChatTransport<UIMessage>({
        api: "/api/ai-chat/stream",
        credentials: "include",
        prepareReconnectToStreamRequest: () => {
          // #491 tail-only attach URL. When there is an anchor (a streaming/active
          // tail to resume) build `?anchor=<assistantRowId>&n=<stepsPersisted>`: the
          // server returns the TAIL — a synthetic `start` frame + frames for steps
          // >= n, then live — which the SDK continuation appends to the seeded row.
          // The server 204s (-> restore-noop + poll) when it cannot cover the
          // frontier (overflow/rotation gap) or the anchor mismatches (a newer run).
          // No anchor (a user tail / pre-first-frame break) => no params.
          const anchor = anchorRef.current;
          return {
            api: `/api/ai-chat/runs/${chatIdRef.current}/stream${
              anchor
                ? `?anchor=${anchor.id}&n=${anchor.stepsPersisted}`
                : ""
            }`,
          };
        },
        fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
          if ((init.method ?? "GET") !== "GET") {
            // Send path (POST). #488 commit 5: NO client 409 retry ladder anymore
            // (the CAS supersede replaces it). We DO peek 409/CAS outcomes to drive
            // the FSM, then return the Response untouched to the SDK (a 409 body is
            // surfaced by the classified error banner via error-message.ts).
            const wasSupersede =
              machineRef.current.phase.name === "superseding";
            const ep = epochRef.current;
            const response = await fetch(input, init);
            if (wasSupersede) {
              if (response.ok) {
                dispatchRef.current({ type: "SUPERSEDE_READY", epoch: ep });
              } else if (response.status === 409) {
                const { code, activeRunId } = await read409(response);
                if (code === "SUPERSEDE_TARGET_MISMATCH")
                  dispatchRef.current({
                    type: "SUPERSEDE_MISMATCH",
                    currentRunId: activeRunId,
                    epoch: ep,
                  });
                else if (code === "SUPERSEDE_TIMEOUT")
                  dispatchRef.current({ type: "SUPERSEDE_TIMEOUT", epoch: ep });
                else if (code === "SUPERSEDE_INVALID")
                  dispatchRef.current({ type: "SUPERSEDE_INVALID", epoch: ep });
              }
            } else if (response.status === 409) {
              const { code, activeRunId } = await read409(response);
              if (code === "A_RUN_ALREADY_ACTIVE")
                // S4: thread the server's activeRunId into the event so the FSM can
                // adopt it as the run-fact — a later "Send now" then CAS-supersedes
                // that (possibly foreign-tab) run instead of a blind promote+abort.
                dispatchRef.current({ type: "RUN_ALREADY_ACTIVE", activeRunId });
            }
            return response;
          }
          // Reconnect/attach GET: the SDK passes no AbortSignal, so wire our own
          // controller for observer Stop / unmount abort (effect-owned, I5).
          const controller = new AbortController();
          attachAbortRef.current = controller;
          const ep = pendingAttachEpochRef.current;
          const wasReconnecting =
            machineRef.current.phase.name === "reconnecting";
          try {
            const response = await fetch(input, {
              ...init,
              signal: controller.signal,
            });
            if (response.status === 204 || !response.ok)
              handleAttachOutcome(ep, wasReconnecting, false);
            else handleAttachOutcome(ep, wasReconnecting, true);
            return response;
          } catch (err) {
            handleAttachOutcome(ep, wasReconnecting, false);
            throw err;
          }
        },
        prepareSendMessagesRequest: ({ messages, body }) => {
          // #488 commit 5: read-and-clear the supersede runId so `supersede:{runId}`
          // is carried by ONLY this request (the CAS "interrupt and send now").
          const supersedeRunId = pendingSupersedeRef.current;
          pendingSupersedeRef.current = null;
          return {
            body: {
              ...body,
              chatId: chatIdRef.current,
              openPage: openPageRef.current
                ? {
                    ...openPageRef.current,
                    selection: getEditorSelectionRef.current?.() ?? null,
                  }
                : null,
              roleId: roleIdRef.current,
              ...(supersedeRunId
                ? { supersede: { runId: supersedeRunId } }
                : {}),
              messages,
            },
          };
        },
      }),
    [],
  );

  // Attach GET outcome -> FSM event. The epoch guard replaces BOTH the one-shot
  // 204 guard (noStreamHandledRef) and the unmount gate: a stale/superseded or
  // post-DISPOSE outcome is dropped (I1). #491 tail-only: on a NONE outcome there is
  // NOTHING to restore — the anchor row was never stripped from the view (the seed
  // keeps it) — so we only invalidate for a fresh poll + dispatch the FSM event.
  const handleAttachOutcome = useCallback(
    (ep: number, wasReconnecting: boolean, live: boolean) => {
      if (ep !== epochRef.current) return; // stale generation — drop
      if (live) {
        dispatchRef.current(
          wasReconnecting
            ? { type: "RECONNECT_ATTACHED", epoch: ep }
            : { type: "ATTACH_LIVE", epoch: ep },
        );
        return;
      }
      queryClient.invalidateQueries({
        queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
      });
      dispatchRef.current(
        wasReconnecting
          ? { type: "RECONNECT_NONE", epoch: ep }
          : { type: "ATTACH_NONE", epoch: ep },
      );
    },
    [queryClient],
  );

  const {
    messages,
    sendMessage,
    status,
    stop,
    error,
    setMessages,
    resumeStream,
  } = useChat({
    id: chatStoreId,
    messages: initialMessages,
    transport,
    experimental_throttle: STREAM_THROTTLE_MS,
    onFinish: ({ message, isAbort, isDisconnect, isError }) => {
      // #488 F1: STAMP this finish with the generation the stream STARTED under
      // (I1). A superseded/dead stream's late finish carries an OLD generation and
      // is dropped by the reducer, so it cannot drive the live machine.
      const stampEpoch = turnEpochRef.current;
      // Ownership (I2) is the FSM ctx: a resumed/attached/reconnected turn is an
      // OBSERVER; a local send is the owner. The queue flushes ONLY under local
      // ownership; an observer never flushes (invariant 7).
      const wasObserver = machineRef.current.ctx.ownership === "observer";
      // Notify the parent on EVERY terminal outcome (threadKey-guarded downstream
      // for #161); fires even while unmounting.
      onTurnFinished(extractServerChatId(message), threadKey);

      // A missing message (a pre-first-frame break) has no visible content.
      const msgHasVisible = message
        ? assistantMessageHasVisibleContent(message)
        : false;

      // #488 F1: the SUPERSEDED stream A just finalized (we are still `superseding`
      // and stream B has not been sent yet). Record A's terminal outcome STAMPED —
      // I1 drops it (superseding bumped the epoch), so A cannot drive a false
      // reconnect / reset the run-fact — then start B NOW that A is fully done. B is
      // deferred to a microtask so A's SDK `finally` (`activeResponse = void 0`)
      // runs BEFORE B's makeRequest sets `activeResponse`, else the dying A clobbers
      // B (ai@6). This is the no-overlap guarantee the CAS supersede needs.
      if (
        machineRef.current.phase.name === "superseding" &&
        pendingSupersedeTextRef.current !== null
      ) {
        // Disconnect-first (see the routing note below): a real drop is
        // { isError:true, isDisconnect:true }. All of these are dropped by I1 here
        // (superseding bumped the epoch) — the order only mirrors the main routing.
        if (isDisconnect)
          dispatch({ type: "FINISH_DISCONNECT", hasVisibleContent: msgHasVisible, epoch: stampEpoch });
        else if (isError) dispatch({ type: "FINISH_ERROR", kind: "stream", epoch: stampEpoch });
        else if (isAbort) dispatch({ type: "FINISH_ABORT", epoch: stampEpoch });
        else dispatch({ type: "FINISH_CLEAN", epoch: stampEpoch });
        const text = pendingSupersedeTextRef.current;
        pendingSupersedeTextRef.current = null;
        setStopNotice(null);
        queueMicrotask(() => {
          if (!mountedRef.current) return;
          turnEpochRef.current = epochRef.current; // B's (superseding) generation
          sendMessageRef.current?.({ text });
        });
        return;
      }

      // #488 (browser QA): route DISCONNECT FIRST. In ai@6.0.207 a real network drop
      // yields BOTH `{ isError:true, isDisconnect:true }` — the SDK sets `isError`
      // unconditionally in its catch and `isDisconnect` only ALONGSIDE it (for a
      // fetch/network TypeError). Checking `isError` first therefore sent EVERY real
      // drop to the terminal error banner and NEVER entered the reconnect ladder
      // (`FINISH_DISCONNECT` is its only entry). A disconnect — the detached run
      // keeps executing server-side — must win; only a NON-disconnect error (a
      // provider 500, `{ isError:true, isDisconnect:false }`) is terminal.
      if (isDisconnect) {
        if (!mountedRef.current) {
          setStopNotice(null);
          return;
        }
        // No detached run to recover (legacy, non-autonomous): a plain disconnect —
        // terminal notice, no reconnect. (An observer only exists in autonomous mode,
        // so this is always a local turn.)
        if (autonomousRunsEnabled !== true) {
          dispatch({
            type: "FINISH_DISCONNECT",
            hasVisibleContent: false,
            epoch: stampEpoch,
          });
          setStopNotice("disconnect");
          return;
        }
        // A mount-resume OBSERVER (one-shot resume, NOT live-follow) drop falls to
        // the degraded POLL, which merges by id — it does NOT attach, so there is
        // nothing to re-seed. #491 tail-only: the anchor row was never removed from
        // the view (the seed keeps it; the continuation only APPENDED), so nothing to
        // restore either. The FSM routes this to `polling` (ownership observer,
        // !liveFollow).
        if (wasObserver && !machineRef.current.ctx.liveFollow) {
          queryClient.invalidateQueries({
            queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
          });
          dispatch({
            type: "FINISH_DISCONNECT",
            hasVisibleContent: msgHasVisible,
            epoch: stampEpoch,
          });
          setStopNotice(null);
          return;
        }
        // We will (re-)ENTER THE RECONNECT LADDER (an attach): a LOCAL live turn's
        // first drop, OR a live-follow observer's SUBSEQUENT drop (#488 commit 3).
        // #488 commit 2: recover by the RUN-FACT, not by the presence of an assistant
        // message — a setup-phase break still leaves a detached run writing to pages.
        //
        // #491 tail-only (THE crux): the live store holds a PARTIAL step that is AHEAD
        // of the persisted boundary; tail-applying the reconnect's step frames over it
        // would DUPLICATE that partial step. So entering reconnecting is ALWAYS via a
        // RE-SEED FROM PERSIST — never the live store. Fetch the authoritative
        // persisted assistant row (`getRun` returns the projected `message`), replace
        // the live partial by id (mergeById -> the store now holds EXACTLY steps
        // 0..N-1), and set the anchor to `{ id, n = stepsPersisted }`. Only AFTER the
        // re-seed is applied do we enter the ladder (FINISH_DISCONNECT schedules the
        // backoff) — so the attach can never tail-apply over the live partial.
        const cid = chatIdRef.current;
        // The live-message runId is the run-fact source (the attach GET keys on
        // chatId, so a sentinel still recovers a setup-phase break).
        const runId = extractRunId(message ?? undefined) ?? "pending";
        const enterReconnect = (fact: string): void => {
          if (!mountedRef.current) return;
          // Epoch-stamp the run-fact too (I1): the getRun rtt widens the
          // onFinish->dispatch window, so a concurrent SEND_LOCAL during it must be
          // able to drop this stale RUN_FACT (else it clobbers the new turn's
          // runFact.runId). Consistent with the postRun RUN_FACT stamp.
          dispatch({ type: "RUN_FACT", runFact: { runId: fact }, epoch: stampEpoch });
          dispatch({
            type: "FINISH_DISCONNECT",
            hasVisibleContent: msgHasVisible,
            epoch: stampEpoch,
          });
        };
        // Restore the STRUCTURAL guarantee that the live partial is never the
        // tail-apply base: drop the live partial from the store by id and null the
        // anchor, so the reconnect replays from step 0 into a CLEAN store (a full
        // rebuild) or, past any rotation, 204s -> degraded poll. Used on BOTH the
        // no-persisted-row and getRun-FAILURE paths — after this there is no path
        // where the attach tail-applies frames onto a row that already has them
        // (the #137/#161 duplication class).
        const dropLivePartialAndReplayFromStart = (): void => {
          if (message?.role === "assistant" && typeof message.id === "string") {
            const liveId = message.id;
            setMessagesRef.current?.((prev) =>
              prev.filter((m) => m.id !== liveId),
            );
          }
          anchorRef.current = null;
        };
        if (cid) {
          void getRun(cid)
            .then((res) => {
              if (!mountedRef.current) return;
              const persisted = res.message;
              if (persisted && persisted.role === "assistant") {
                anchorRef.current = {
                  id: persisted.id,
                  stepsPersisted: stepsPersistedOf(persisted),
                };
                // Replace the live partial with the persisted row IN PLACE by id —
                // the re-seed from persist. The attach's tail (steps >= N) then
                // appends to a store holding EXACTLY steps 0..N-1: no duplication.
                setMessages((prev) => mergeById(prev, rowToUiMessage(persisted)));
              } else {
                // No persisted assistant row (pre-first-frame break): drop the live
                // partial + replay from start (no anchor/n) so nothing is duplicated.
                dropLivePartialAndReplayFromStart();
              }
              enterReconnect(res.run?.id ?? runId);
            })
            .catch(() => {
              if (!mountedRef.current) return;
              // Persist read FAILED: we cannot re-seed from fresh persist, and a
              // stale mount-time anchor over the live partial would tail-apply
              // already-present steps -> duplication (a flaky-network blip:
              // SSE + getRun both fail, network recovers in ~1s, the registry still
              // covers from the mount frontier). Restore the removed-filter guarantee
              // instead: drop the live partial + replay from start / 204 -> poll.
              dropLivePartialAndReplayFromStart();
              enterReconnect(runId);
            });
        } else {
          dropLivePartialAndReplayFromStart();
          enterReconnect(runId);
        }
        setStopNotice(null);
        return;
      }
      // A NON-disconnect stream error (a provider 500 etc.) -> terminal error banner.
      if (isError) {
        dispatch({ type: "FINISH_ERROR", kind: "stream", epoch: stampEpoch });
        setStopNotice(null);
        return;
      }
      if (isAbort) {
        // A user Stop / an interrupt abort finished. The FSM stopping/idle exit is
        // by DATA (this terminal outcome, I4 — honored in `stopping` by the reducer).
        dispatch({ type: "FINISH_ABORT", epoch: stampEpoch });
        setStopNotice("manual");
        return;
      }
      // Clean finish.
      if (wasObserver) {
        if (mountedRef.current) {
          const hasVisible = msgHasVisible;
          if (!hasVisible) {
            // Starved replay (the tail carried no new steps). #491 tail-only: the
            // seeded steps 0..N-1 are still on screen (the SDK continuation never
            // wiped them — `start` does not reset parts), so there is nothing to
            // restore; just poll to the real terminal.
            queryClient.invalidateQueries({
              queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
            });
            dispatch({ type: "STREAM_INCOMPLETE", reason: "starved", epoch: stampEpoch });
          } else {
            // Healthy resumed finish — nothing to restore/arm, just settle.
            dispatch({ type: "FINISH_CLEAN", epoch: stampEpoch });
          }
        }
        setStopNotice(null);
        return;
      }
      // Local clean finish: settle + flush the queue (gated on liveness, #486).
      dispatch({ type: "FINISH_CLEAN", epoch: stampEpoch });
      setStopNotice(null);
      if (mountedRef.current) flushNext();
    },
    onError: (streamError) => {
      console.error("AI chat stream error:", streamError);
      onTurnFinished(undefined, threadKey);
    },
  });

  // Publish the live useChat handles to the effect-runner refs.
  resumeStreamRef.current = resumeStream;
  setMessagesRef.current = setMessages as unknown as (
    updater: (prev: UIMessage[]) => UIMessage[],
  ) => void;
  sendMessageRef.current = sendMessage;
  stopFnRef.current = stop;

  // EARLY chat-id (#174) + runId (#488) adoption from the streaming assistant
  // message's start metadata. Forward the chat id once; adopt the runId into the
  // FSM run-fact; and (#234 F5) fire a DEFERRED server stop the moment the id lands
  // while still `stopping` — no stopPendingRef needed (the FSM phase is the latch).
  const lastForwardedChatIdRef = useRef<string | undefined>(undefined);
  useEffect(() => {
    const tail = messages[messages.length - 1];
    if (tail?.role !== "assistant") return;
    const serverChatId = extractServerChatId(tail);
    if (
      onServerChatId &&
      serverChatId &&
      serverChatId !== lastForwardedChatIdRef.current
    ) {
      lastForwardedChatIdRef.current = serverChatId;
      onServerChatId(serverChatId);
      if (machineRef.current.phase.name === "stopping")
        onServerStopRef.current?.(serverChatId);
    }
    // #488 F4: the FIRST assistant frame of a LOCAL turn moves the FSM
    // `sending -> streaming` (and adopts the runId), matching the spec's
    // STREAM_START transition. Later frames / observer turns (already streaming)
    // just refresh the run-fact.
    const runId = extractRunId(tail);
    if (machineRef.current.phase.name === "sending") {
      dispatch({ type: "STREAM_START", runId, epoch: epochRef.current });
    } else if (runId && machineRef.current.ctx.runFact?.runId !== runId) {
      dispatch({ type: "RUN_FACT", runFact: { runId } });
    }
  }, [messages, onServerChatId, dispatch]);

  // Live "turn was interrupted" marker (a manual Stop vs a dropped connection — a
  // distinction only available live). Cleared when the next turn starts.
  const [stopNotice, setStopNotice] = useState<null | "manual" | "disconnect">(
    null,
  );

  const isStreaming = status === "submitted" || status === "streaming";
  const phase = machine.phase;
  const isObserver = machine.ctx.ownership === "observer";

  // Mount effect: arm the resume attempt — ONLY on a server-confirmed active run
  // (#488 commit 4b). A STREAMING tail IS the run-fact (status streaming) -> attach
  // now. A USER tail (ended on a user message) may have NO active run, so confirm
  // via POST /run first: a chat with no active run must NOT arm a poll (the old code
  // attached -> 204 -> ~240 req/10min storm). A SETTLED assistant tail never resumes.
  useEffect(() => {
    mountedRef.current = true;
    if (autonomousRunsEnabled === true && chatId !== null) {
      const rows = initialRows ?? [];
      if (isStreamingTail(rows)) {
        dispatch({ type: "ATTACH_START" });
      } else if (!isSettledAssistantTail(rows)) {
        void getRun(chatId)
          .then((res) => {
            if (!mountedRef.current) return;
            if (res.run && isActiveRunStatus(res.run.status))
              dispatch({ type: "ATTACH_START", runId: res.run.id });
          })
          .catch(() => undefined);
      }
    }
    return () => {
      mountedRef.current = false;
      dispatch({ type: "DISPOSE" }); // aborts attach + timers, bumps epoch (I5)
    };
    // Mount-only by design; the parent remounts per chat via `key`.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Reconciliation + degraded-merge (invariant 8): while a poll-bearing recovery is
  // active (polling / reconnecting / stopping), merge the polled assistant tail on
  // every initialRows update and settle to terminal via POLL_TERMINAL.
  useEffect(() => {
    if (isStreaming) return; // a local stream owns the view
    const p = machineRef.current.phase.name;
    if (p !== "polling" && p !== "reconnecting" && p !== "stopping") return;
    const rows = initialRows ?? [];
    const tail = rows[rows.length - 1];
    if (!tail || tail.role !== "assistant") return;
    setMessages((prev) => mergeById(prev, rowToUiMessage(tail)));
    // Anchor-mismatch coherence: if a DIFFERENT run's row B has replaced our anchor
    // row A as the tail, A would linger as an orphan — reconcile A by id from FRESH
    // PERSISTED history (not the pinned live row) so no phantom row survives.
    const anchor = anchorRef.current;
    if (anchor && anchor.id !== tail.id) {
      const historical = rows.find((r) => r.id === anchor.id);
      if (historical)
        setMessages((prev) => mergeById(prev, rowToUiMessage(historical)));
    }
    if (tail.status !== "streaming") dispatch({ type: "POLL_TERMINAL" });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [initialRows, isStreaming, setMessages]);

  // #488 commit 4a: the stalled inactivity cap. While polling/reconnecting, if no
  // new run activity (initialRows unchanged) for DEGRADED_POLL_IDLE_MAX_MS, fire
  // POLL_IDLE_CAP -> the FSM goes `stalled` (banner + Retry) instead of silent.
  // Reset on every activity (initialRows change) and on phase change.
  useEffect(() => {
    if (idleCapTimerRef.current) {
      clearTimeout(idleCapTimerRef.current);
      idleCapTimerRef.current = null;
    }
    // Review #4: `stopping` also arms the poll and needs a bounded exit (the FSM
    // maps POLL_IDLE_CAP from `stopping` -> idle, not stalled).
    const p = phase.name;
    if (p !== "polling" && p !== "reconnecting" && p !== "stopping") return;
    idleCapTimerRef.current = setTimeout(() => {
      dispatchRef.current({ type: "POLL_IDLE_CAP" });
    }, DEGRADED_POLL_IDLE_MAX_MS);
    return () => {
      if (idleCapTimerRef.current) clearTimeout(idleCapTimerRef.current);
    };
  }, [phase, initialRows]);

  // Clear the stopped marker as soon as a new turn begins streaming.
  useEffect(() => {
    if (isStreaming) setStopNotice(null);
  }, [isStreaming]);

  // "Send now" on a queued message: #488 commit 5 — interrupt the active run and
  // send THIS message via the server CAS supersede (POST /stream {supersede:{runId}})
  // when the run id is known. No local stop/re-POST dance, no client retry ladder.
  const sendNow = useCallback(
    (id: string) => {
      const msg = queuedRef.current.find((m) => m.id === id);
      if (!msg) return;
      // #488 LOW-2: a supersede is already in flight (stream A aborted, B not sent
      // yet). A second "Send now" must NOT clobber the pending message nor start a
      // competing send. Least-surprising choice: this click is a NO-OP and the
      // message stays queued — the user can send it once B has started (nothing is
      // lost). (Guarding on both the pending text and the FSM phase.)
      if (
        pendingSupersedeTextRef.current !== null ||
        machineRef.current.phase.name === "superseding"
      ) {
        return;
      }
      // #488 LOW-1: decide "is stream A still live" from the FSM phase (machineRef),
      // which onFinish updates SYNCHRONOUSLY — NOT the render-lagged statusRef. In
      // the sub-frame window where A has already fired onFinish (FSM -> idle) but
      // statusRef still reads "streaming", treating A as live would abort a dead
      // stream (no second onFinish -> B never sent, message lost). The FSM phase
      // closes that window: a settled A reads as not-live -> B is sent immediately.
      const p = machineRef.current.phase.name;
      const aLiveLocal =
        (p === "sending" || p === "streaming") &&
        machineRef.current.ctx.ownership === "local";
      const runId = machineRef.current.ctx.runFact?.runId;
      if (aLiveLocal && autonomousRunsEnabled === true && runId && runId !== "pending") {
        // CAS supersede: the server stops the old run + starts this one atomically.
        // #488 F1: do NOT start stream B synchronously — the local stream A is still
        // live, and overlapping streams corrupt each other in ai@6 (A's `finally`
        // reads then nulls the shared `activeResponse`). ABORT A now and stash the
        // text; A's onFinish (dropped by the epoch stamp) starts B in a microtask,
        // AFTER A is fully finalized (no overlap). The POST then carries the
        // supersede body (pendingSupersedeRef, armed by the effect).
        setQueue(removeQueuedById(queuedRef.current, id));
        pendingSupersedeTextRef.current = msg.text;
        dispatch({ type: "SUPERSEDE_REQUESTED", targetRunId: runId });
        stopFnRef.current?.(); // abort A -> its onFinish sends B
        return;
      }
      if (aLiveLocal) {
        // No CAS possible (legacy without a run, or the runId not adopted yet):
        // promote to head and abort; the local turn's abort finishes and the queue
        // holds the promoted head for the user (a blind re-POST would 409 -> the
        // classified "already answering — interrupt and send" banner guides them).
        setQueue(promoteToHead(queuedRef.current, id));
        stopFnRef.current?.();
        return;
      }
      // Nothing live to interrupt (idle, or A already settled): send it now.
      setQueue(removeQueuedById(queuedRef.current, id));
      localSend(msg.text);
    },
    [setQueue, autonomousRunsEnabled, dispatch, localSend],
  );

  // Stop the current turn. Abort the local SSE + the attach GET; in AUTONOMOUS mode
  // additionally request the AUTHORITATIVE server stop (a local abort is only a
  // client disconnect the server ignores). The FSM `stopping` exits by DATA (I4):
  // the local turn's onFinish (FINISH_ABORT) or the poll reaching terminal.
  const handleStop = useCallback(() => {
    stopFnRef.current?.();
    if (autonomousRunsEnabled) {
      dispatch({ type: "STOP_REQUESTED" });
    } else {
      // Legacy: no server run to stop from the client (the server's onClose issues
      // requestStop on disconnect). Just reset the FSM recovery.
      attachAbortRef.current?.abort();
      if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
      dispatch({ type: "FINISH_ABORT" });
    }
  }, [autonomousRunsEnabled, dispatch]);

  // Manual Retry from the reconnect-failed OR stalled banner.
  const onRetry = useCallback(() => {
    dispatch({ type: "RETRY" });
  }, [dispatch]);

  // Classify the turn error into a heading + detail (connection reset, timeout, the
  // #487 409 codes, ...). Computed here so the SAME text can mirror into the export.
  const errorView = error ? describeChatError(error.message ?? "", t) : null;

  // #488 (browser QA): the FSM PHASE is the source of truth for WHICH banner to
  // show. A real network drop leaves useChat `error` SET even after the FSM has
  // moved to `reconnecting` — in ai@6 a drop is always `{isError:true,
  // isDisconnect:true}` and the SDK sets `error` alongside `isError`. So the
  // terminal error banner must show ONLY when the FSM is actually TERMINAL
  // (`error(kind)`); during recovery (reconnecting / polling / stalled /
  // superseding / stopping) the recovery banner — or the streaming content — wins
  // over the residual `error`, otherwise the terminal "Lost connection… reload"
  // banner masks "reconnecting… (N/5)". This still surfaces the classified #487 409
  // errors: a supersede/gate 409 lands the FSM in `error(kind)`, so it shows then.
  const showError = errorView !== null && phase.name === "error";

  // Role-picker empty state (unchanged from #149).
  const [rolePickedNoSend, setRolePickedNoSend] = useState(false);
  const handleRolePick = (role: IAiRole): void => {
    roleIdRef.current = role.id;
    onRolePicked?.(role);
    const launch = roleLaunchMessage(
      role,
      t("Take a look at the current document"),
    );
    if (launch !== null) {
      localSend(launch);
    } else {
      setRolePickedNoSend(true);
    }
  };
  if (shouldResetRolePicked(chatId, roleId, rolePickedNoSend)) {
    setRolePickedNoSend(false);
  }
  const showRoleCards =
    chatId === null && (roles?.length ?? 0) > 0 && !rolePickedNoSend;
  const roleCardsEmptyState = showRoleCards ? (
    <RoleCards roles={roles ?? []} onPick={handleRolePick} />
  ) : undefined;

  return (
    <Box className={classes.panel}>
      <MessageList
        messages={messages}
        isStreaming={isStreaming}
        emptyState={roleCardsEmptyState}
        assistantName={assistantName}
      />

      {showError && errorView ? (
        <ChatErrorAlert title={errorView.title} detail={errorView.detail} mb="xs" />
      ) : phase.name === "reconnecting" ? (
        // #430/#488: while auto-reconnecting to a detached run's live tail, show
        // progress; once attempts are exhausted, offer a manual Retry (the degraded
        // poll keeps catching up underneath).
        <Alert variant="light" color="gray" p="xs" mb="xs" style={{ flexShrink: 0 }}>
          <Group gap={8} wrap="nowrap" align="center">
            {!phase.failed ? (
              <>
                <Loader size={14} color="gray" style={{ flex: "none" }} />
                <Text size="sm" lh={1.3} c="dimmed">
                  {t("Connection lost — reconnecting…")}
                  {` (${phase.attempt}/${RECONNECT_MAX_ATTEMPTS})`}
                </Text>
              </>
            ) : (
              <>
                <Text size="sm" lh={1.3} c="dimmed" style={{ flex: 1 }}>
                  {t("Couldn't reconnect to the answer.")}
                </Text>
                <Button
                  size="compact-xs"
                  variant="light"
                  color="gray"
                  onClick={onRetry}
                >
                  {t("Retry")}
                </Button>
              </>
            )}
          </Group>
        </Alert>
      ) : phase.name === "stalled" ? (
        // #488 commit 4a: the degraded poll hit the inactivity cap — instead of a
        // silent "forever half-done answer", tell the user and offer Retry.
        <Alert variant="light" color="gray" p="xs" mb="xs" style={{ flexShrink: 0 }}>
          <Group gap={8} wrap="nowrap" align="center">
            <Text size="sm" lh={1.3} c="dimmed" style={{ flex: 1 }}>
              {t("The answer may be incomplete — the run stopped responding.")}
            </Text>
            <Button
              size="compact-xs"
              variant="light"
              color="gray"
              onClick={onRetry}
            >
              {t("Retry")}
            </Button>
          </Group>
        </Alert>
      ) : stopNotice ? (
        <ChatStoppedNotice
          text={
            stopNotice === "manual"
              ? t("Response stopped.")
              : t("Connection lost — the answer was interrupted.")
          }
          mb="xs"
        />
      ) : null}

      <Stack gap={0} className={classes.inputWrapper}>
        {queued.length > 0 && (
          <Stack gap={4} className={classes.queuedList}>
            {queued.map((m) => (
              <Group
                key={m.id}
                gap={6}
                wrap="nowrap"
                className={classes.queuedItem}
              >
                <IconClockHour4 size={14} className={classes.queuedIcon} />
                <Text size="xs" lineClamp={2} className={classes.queuedText}>
                  {m.text}
                </Text>
                {/* "Send now" (interrupt) is hidden while OBSERVING a detached run
                    (ownership observer): a local stop() does not abort the attach
                    fetch, so the interrupt would be swallowed. Only the remove
                    affordance stays. */}
                {!isObserver && (
                  <Tooltip label={t("Interrupt and send now")} withArrow>
                    <ActionIcon
                      size="xs"
                      variant="subtle"
                      color="blue"
                      onClick={() => sendNow(m.id)}
                      aria-label={t("Send now")}
                    >
                      <IconPlayerPlayFilled size={12} />
                    </ActionIcon>
                  </Tooltip>
                )}
                <ActionIcon
                  size="xs"
                  variant="subtle"
                  color="gray"
                  onClick={() => removeQueued(m.id)}
                  aria-label={t("Remove queued message")}
                >
                  <IconX size={12} />
                </ActionIcon>
              </Group>
            ))}
          </Stack>
        )}
        <ChatInput
          onSend={(text) => localSend(text)}
          onQueue={enqueue}
          onStop={handleStop}
          isStreaming={isStreaming}
        />
      </Stack>
    </Box>
  );
}
