// Owns: message-timestamp helpers, SelectionAttachedPill, UserMessage,
// AssistantMessage, MessageBranchPicker, CheckpointContext, MessageActionsContext,
// RunningActivityStatus, ThinkingIndicator, and displayableUserMessageText.

import { isPastedTextAttachmentName } from "@agent-native/toolkit/composer/pasted-text";
import { PastedTextChip } from "@agent-native/toolkit/composer/PastedTextChip";
import {
  useThread,
  useMessageRuntime,
  useComposer,
  MessagePrimitive,
  ActionBarPrimitive,
  BranchPickerPrimitive,
  ComposerPrimitive,
  useMessagePartReasoning,
  useMessagePartRuntime,
  useAuiState,
} from "@assistant-ui/react";
import type { Attachment } from "@assistant-ui/react";
import {
  IconX,
  IconCheck,
  IconCopy,
  IconChevronDown,
  IconChevronLeft,
  IconChevronRight,
  IconDots,
  IconGitFork,
  IconId,
  IconQuote,
  IconRefresh,
  IconArrowBackUp,
  IconFile,
  IconFolder,
  IconFileText,
  IconCheckbox,
  IconMail,
  IconUser,
  IconPresentation,
  IconStack2,
  IconMessageChatbot,
  IconPencil,
  IconAlertTriangle,
  IconLoader2,
} from "@tabler/icons-react";
import React, { useState, useEffect, useCallback, useRef } from "react";

import { getActiveRun } from "../active-run-state.js";
import { agentNativePath } from "../api-path.js";
import { writeClipboardText } from "../clipboard.js";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "../components/ui/dropdown-menu.js";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "../components/ui/tooltip.js";
import { ThumbsFeedback } from "../observability/ThumbsFeedback.js";
import { McpConnectionSuggestion } from "../resources/McpConnectionSuggestion.js";
import type { ContentPart } from "../sse-event-processor.js";
import {
  isCallAgentToolCallShadowed,
  shadowedCallAgentToolCallIds,
} from "../tool-display.js";
import { cn } from "../utils.js";
import {
  MarkdownText,
  renderMarkdownToClipboardHtml,
} from "./markdown-renderer.js";
import { getAssistantRunDurationMs } from "./repo-helpers.js";
import {
  ToolCallFallback,
  ToolActivityPresentation,
  FilesChangedSummary,
  ASSISTANT_VISIBLE_TOOL_CALL_LIMIT,
  ChatRunningContext,
  ChatRunDurationContext,
  RanToolsSummary,
  ReasoningCell,
  WorkedForSummary,
} from "./tool-call-display.js";

// ─── Pending selection context key ───────────────────────────────────────────
// Mirrored from AssistantChat to avoid a cross-import on a private constant.
const PENDING_SELECTION_KEY = "pending-selection-context";

// ─── displayableUserMessageText ───────────────────────────────────────────────

export function displayableUserMessageText(text: string): string {
  return text
    .replace(/<context\b[^>]*>[\s\S]*?<\/context>\n?/gi, "")
    .replace(/<context\b[^>]*>[\s\S]*$/gi, "")
    .replace(/<\/context>/gi, "")
    .trim();
}

export function isHiddenUserMessage(message: unknown): boolean {
  const meta = (message as { metadata?: unknown })?.metadata as
    | {
        custom?: {
          agentNativeHiddenUserMessage?: unknown;
          agentNativeRecoveryAction?: unknown;
        };
      }
    | undefined;
  return (
    meta?.custom?.agentNativeHiddenUserMessage === true ||
    meta?.custom?.agentNativeRecoveryAction === "continue" ||
    meta?.custom?.agentNativeRecoveryAction === "retry"
  );
}

// ─── Message timestamp helpers ────────────────────────────────────────────────

interface FormattedMessageTimestamp {
  short: string;
  full: string;
}

const messageFooterFadeClassName =
  "opacity-0 transition-[color,opacity] duration-150 group-hover:opacity-100 group-focus-within:opacity-100";

function coerceMessageDate(value: unknown): Date | null {
  if (value instanceof Date) {
    return Number.isNaN(value.getTime()) ? null : value;
  }
  if (typeof value === "string" || typeof value === "number") {
    const date = new Date(value);
    return Number.isNaN(date.getTime()) ? null : date;
  }
  return null;
}

function isSameCalendarDay(a: Date, b: Date): boolean {
  return (
    a.getFullYear() === b.getFullYear() &&
    a.getMonth() === b.getMonth() &&
    a.getDate() === b.getDate()
  );
}

export function formatMessageTimestamp(
  value: unknown,
): FormattedMessageTimestamp | null {
  const date = coerceMessageDate(value);
  if (!date) return null;

  const now = new Date();
  const yesterday = new Date(now);
  yesterday.setDate(now.getDate() - 1);
  const time = new Intl.DateTimeFormat(undefined, {
    hour: "numeric",
    minute: "2-digit",
  }).format(date);

  let short: string;
  if (isSameCalendarDay(date, now)) {
    short = time;
  } else if (isSameCalendarDay(date, yesterday)) {
    short = `Yesterday ${time}`;
  } else if (date.getFullYear() === now.getFullYear()) {
    short = `${new Intl.DateTimeFormat(undefined, {
      month: "short",
      day: "numeric",
    }).format(date)}, ${time}`;
  } else {
    short = `${new Intl.DateTimeFormat(undefined, {
      month: "short",
      day: "numeric",
      year: "numeric",
    }).format(date)}, ${time}`;
  }

  return {
    short,
    full: new Intl.DateTimeFormat(undefined, {
      weekday: "short",
      month: "short",
      day: "numeric",
      year: "numeric",
      hour: "numeric",
      minute: "2-digit",
    }).format(date),
  };
}

export function MessageTimestamp({
  timestamp,
  className,
}: {
  timestamp: FormattedMessageTimestamp;
  className?: string;
}) {
  return (
    <span
      className={cn(
        "text-[11px] leading-none text-muted-foreground",
        className,
      )}
      title={timestamp.full}
    >
      {timestamp.short}
    </span>
  );
}

// ─── SelectionAttachedPill ────────────────────────────────────────────────────

export function SelectionAttachedPill() {
  const [length, setLength] = useState<number | null>(null);

  useEffect(() => {
    let cancelled = false;
    fetch(
      agentNativePath(
        `/_agent-native/application-state/${PENDING_SELECTION_KEY}`,
      ),
    )
      .then((r) => (r.ok && r.status !== 204 ? r.json() : null))
      .then((data) => {
        if (cancelled) return;
        const text =
          (data?.value?.text as string | undefined) ??
          (data?.text as string | undefined);
        if (text) setLength(text.length);
      })
      .catch(() => {});
    return () => {
      cancelled = true;
    };
  }, []);

  useEffect(() => {
    function onAttached(e: Event) {
      const detail = (e as CustomEvent).detail;
      if (typeof detail?.length === "number") setLength(detail.length);
    }
    function onCleared() {
      setLength(null);
    }
    window.addEventListener("agent-panel:selection-attached", onAttached);
    window.addEventListener("agent-panel:selection-cleared", onCleared);
    return () => {
      window.removeEventListener("agent-panel:selection-attached", onAttached);
      window.removeEventListener("agent-panel:selection-cleared", onCleared);
    };
  }, []);

  if (length === null || length === 0) return null;

  return (
    <div className="agent-selection-attached-pill shrink-0 px-3 pt-1.5 -mb-1">
      <div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/50 px-2 py-0.5 text-[11px] text-muted-foreground">
        <IconQuote size={11} />
        <span>{length.toLocaleString()} chars of selection attached</span>
        <button
          type="button"
          aria-label="Clear selection context"
          onClick={() => {
            setLength(null);
            // Dispatch clear event; AssistantChat owns the DELETE call.
            window.dispatchEvent(
              new CustomEvent("agent-panel:selection-clear-requested"),
            );
          }}
          className="flex h-4 w-4 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-accent/60"
        >
          <IconX size={11} />
        </button>
      </div>
    </div>
  );
}

// ─── CheckpointContext / MessageActionsContext ────────────────────────────────

export const CheckpointContext = React.createContext<{
  apiUrl: string;
  devMode: boolean;
  threadId?: string;
  // Run ids that actually have a saved checkpoint. Restore is only offered for
  // these — auto-checkpointing skips turns that started from a dirty tree or a
  // non-git cwd, and without this the menu item appears on every turn and does
  // nothing when clicked.
  checkpointRunIds?: ReadonlySet<string>;
} | null>(null);

export const MessageActionsContext = React.createContext<{
  onForkChat?: () => void | boolean | Promise<void | boolean>;
} | null>(null);

/**
 * Restore rewrites the working tree, so only offer it when the server actually
 * has a checkpoint for this turn. Auto-checkpointing skips turns that started
 * from a dirty tree or a non-git cwd; gating on Code mode alone put a
 * "Revert to here" item on turns where clicking it could do nothing.
 */
export function shouldOfferRestore(args: {
  devMode: boolean | undefined;
  isComplete: boolean;
  isLast: boolean;
  runId: string | undefined;
  checkpointRunIds: ReadonlySet<string> | undefined;
}): boolean {
  return Boolean(
    args.devMode &&
    args.isComplete &&
    !args.isLast &&
    args.runId &&
    args.checkpointRunIds?.has(args.runId),
  );
}

/**
 * Live yields put the run id at `metadata.custom.runId`; server-persisted
 * messages put it at `metadata.runId`.
 */
export function assistantMessageRunId(message: unknown): string | undefined {
  const metadata = (message as { metadata?: unknown })?.metadata as
    | { custom?: { runId?: unknown }; runId?: unknown }
    | undefined;
  return typeof metadata?.custom?.runId === "string"
    ? metadata.custom.runId
    : typeof metadata?.runId === "string"
      ? metadata.runId
      : undefined;
}

// ─── MessageBranchPicker ──────────────────────────────────────────────────────

export function MessageBranchPicker() {
  return (
    <BranchPickerPrimitive.Root
      hideWhenSingleBranch
      className="flex items-center gap-0.5 text-[11px] text-muted-foreground"
    >
      <BranchPickerPrimitive.Previous asChild>
        <button
          type="button"
          aria-label="Previous branch"
          className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
        >
          <IconChevronLeft className="h-3.5 w-3.5" />
        </button>
      </BranchPickerPrimitive.Previous>
      <span className="tabular-nums select-none">
        <BranchPickerPrimitive.Number />
        {"/"}
        <BranchPickerPrimitive.Count />
      </span>
      <BranchPickerPrimitive.Next asChild>
        <button
          type="button"
          aria-label="Next branch"
          className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
        >
          <IconChevronRight className="h-3.5 w-3.5" />
        </button>
      </BranchPickerPrimitive.Next>
    </BranchPickerPrimitive.Root>
  );
}

// ─── Mention rendering ────────────────────────────────────────────────────────

const mentionIconProps = {
  size: 14,
  className: "shrink-0 text-muted-foreground",
};

function MentionChipIcon({ icon }: { icon?: string }) {
  switch (icon) {
    case "folder":
      return <IconFolder {...mentionIconProps} />;
    case "document":
      return <IconFileText {...mentionIconProps} />;
    case "form":
      return <IconCheckbox {...mentionIconProps} />;
    case "email":
      return <IconMail {...mentionIconProps} />;
    case "user":
      return <IconUser {...mentionIconProps} />;
    case "deck":
      return <IconPresentation {...mentionIconProps} />;
    case "agent":
      return <IconMessageChatbot {...mentionIconProps} />;
    case "file":
      return <IconFile {...mentionIconProps} />;
    default:
      return <IconStack2 {...mentionIconProps} />;
  }
}

// Matches rich mention format: @[label|icon] or plain @word
const richMentionPattern = /@\[([^\]|]+)\|([^\]]+)\]/g;
const plainMentionPattern = /((?:^|(?<=\s))@(\w+))/g;

function UserMessageText({ text }: { text: string }) {
  // Strip injected <context>...</context> blocks before display
  const displayText = displayableUserMessageText(text);

  const parts: React.ReactNode[] = [];
  let lastIndex = 0;
  let match: RegExpExecArray | null;
  let hasRichMentions = false;

  // First try rich mentions (@[label|icon])
  richMentionPattern.lastIndex = 0;
  while ((match = richMentionPattern.exec(displayText)) !== null) {
    hasRichMentions = true;
    const matchStart = match.index;
    if (matchStart > lastIndex) {
      parts.push(displayText.slice(lastIndex, matchStart));
    }
    const label = match[1];
    const icon = match[2];
    parts.push(
      <span
        key={matchStart}
        className="inline-flex items-center gap-1 rounded-md border border-input bg-muted/50 px-1.5 py-0.5 text-xs font-medium text-foreground align-middle mx-0.5 max-w-[200px] select-all"
        data-mention-label={label}
      >
        <MentionChipIcon icon={icon} />
        <span className="truncate">{label}</span>
      </span>,
    );
    lastIndex = matchStart + match[0].length;
  }

  if (hasRichMentions) {
    if (lastIndex < displayText.length) {
      parts.push(displayText.slice(lastIndex));
    }
    return <>{parts}</>;
  }

  // Fallback: plain @word mentions (for older messages)
  plainMentionPattern.lastIndex = 0;
  while ((match = plainMentionPattern.exec(displayText)) !== null) {
    const matchStart = match.index;
    if (matchStart > lastIndex) {
      parts.push(displayText.slice(lastIndex, matchStart));
    }
    const mentionName = match[2];
    parts.push(
      <span
        key={matchStart}
        className="inline-flex items-center gap-1 rounded-md border border-input bg-muted/50 px-1.5 py-0.5 text-xs font-medium text-foreground align-middle mx-0.5 select-all"
        data-mention-label={mentionName}
      >
        @{mentionName}
      </span>,
    );
    lastIndex = matchStart + match[0].length;
  }

  if (lastIndex < displayText.length) {
    parts.push(displayText.slice(lastIndex));
  }

  return <>{parts.length > 0 ? parts : displayText}</>;
}

// ─── UserMessageAttachments ───────────────────────────────────────────────────

function UserMessageAttachments() {
  const messageRuntime = useMessageRuntime();
  const msg = messageRuntime.getState();
  // assistant-ui stores user attachments on msg.attachments (separate from content).
  // Each attachment has: { id, type, name, contentType?, content: MessagePart[] }.
  // Image adapters put a {type:"image", image:"data:..."} part in content; text
  // adapters put a {type:"text", text:"<attachment>..."} part. Fall back to a
  // file chip when there's no inline image.
  const attachments = (msg as { attachments?: readonly Attachment[] })
    .attachments;
  if (!attachments || attachments.length === 0) return null;

  return (
    <div className="flex flex-wrap justify-end gap-1.5 mb-1.5">
      {attachments.map((att) => {
        if (isPastedTextAttachmentName(att.name)) {
          return <PastedTextChip key={att.id} attachment={att} compact />;
        }

        // Prefer the hosted upload URL when available (set by the server after
        // preUploadAttachments). This avoids re-shipping base64 in each poll
        // and lets the browser cache the image via a stable URL.
        const uploadUrl = (
          att as unknown as { metadata?: { uploadUrl?: string } }
        ).metadata?.uploadUrl;
        const imagePart = att.content?.find(
          (p): p is { type: "image"; image: string } =>
            p.type === "image" &&
            "image" in p &&
            !!(p as { image?: string }).image,
        );
        const imageSrc = uploadUrl || imagePart?.image || null;
        if (imageSrc) {
          return (
            <div
              key={att.id}
              className="h-16 w-16 overflow-hidden rounded-lg border border-border/70 bg-muted/50"
              title={att.name}
            >
              <img
                src={imageSrc}
                alt={att.name}
                className="h-full w-full object-cover"
              />
            </div>
          );
        }
        return (
          <div
            key={att.id}
            className="flex items-center gap-1.5 rounded-lg border border-border/70 bg-muted/50 px-2 py-1.5 text-xs text-muted-foreground"
            title={att.name}
          >
            <IconFile className="h-3.5 w-3.5 shrink-0" />
            <span className="truncate max-w-[120px]">{att.name || "file"}</span>
          </div>
        );
      })}
    </div>
  );
}

// ─── UserMessageEditComposer ──────────────────────────────────────────────────

function UserMessageEditComposer() {
  return (
    <ComposerPrimitive.Root className="flex flex-col gap-2 rounded-lg border border-border bg-background px-3 py-2 shadow-sm">
      <ComposerPrimitive.Input
        className="w-full resize-none bg-transparent text-sm leading-relaxed text-foreground placeholder:text-muted-foreground focus:outline-none"
        rows={1}
        submitMode="enter"
      />
      <div className="flex justify-end gap-2">
        <ComposerPrimitive.Cancel asChild>
          <button
            type="button"
            className="cursor-pointer rounded-md px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
          >
            Cancel
          </button>
        </ComposerPrimitive.Cancel>
        <ComposerPrimitive.Send asChild>
          <button
            type="submit"
            className="cursor-pointer rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background hover:opacity-90 disabled:opacity-50"
          >
            Save
          </button>
        </ComposerPrimitive.Send>
      </div>
    </ComposerPrimitive.Root>
  );
}

// ─── MessageActionsMenu ────────────────────────────────────────────────────────

export function MessageActionsMenu({
  showRevert,
  onRevert,
}: {
  showRevert?: boolean;
  onRevert?: () => void;
} = {}) {
  const [open, setOpen] = useState(false);
  const [copied, setCopied] = useState<string | null>(null);
  const messageRuntime = useMessageRuntime();
  const actionsCtx = React.useContext(MessageActionsContext);
  const timestamp = formatMessageTimestamp(messageRuntime.getState().createdAt);

  const handleCopyMessage = useCallback(() => {
    const m = messageRuntime.getState();
    const text = m.content
      .filter((p) => p.type === "text")
      .map((p) => (p as { text: string }).text)
      .join("\n");
    // Rich flavor keeps formatting in targets that read text/html (e.g. Slack);
    // null when the markdown renderer isn't ready yet, so we copy plain markdown.
    const html = renderMarkdownToClipboardHtml(text);
    void writeClipboardText(text, html ? { html } : undefined).then((ok) => {
      if (!ok) return;
      setCopied("message");
      setTimeout(() => {
        setCopied(null);
        setOpen(false);
      }, 1000);
    });
  }, [messageRuntime]);

  const handleCopyRequestId = useCallback(() => {
    const m = messageRuntime.getState();
    // If the message carries no run id (e.g. the run is still in flight and
    // this is the first message), fall back to the active-run state so a hung /
    // mid-stream chat still surfaces a usable trace ID. Last resort is the
    // assistant-ui local message id.
    const runId =
      assistantMessageRunId(m) ||
      (typeof window !== "undefined" ? getActiveRun()?.runId : null) ||
      m.id ||
      "";
    void writeClipboardText(runId).then((ok) => {
      if (!ok) return;
      setCopied("id");
      setTimeout(() => {
        setCopied(null);
        setOpen(false);
      }, 1000);
    });
  }, [messageRuntime]);

  const handleForkChat = useCallback(() => {
    setOpen(false);
    actionsCtx?.onForkChat?.();
  }, [actionsCtx]);

  const handleRevert = useCallback(() => {
    setOpen(false);
    onRevert?.();
  }, [onRevert]);

  return (
    <DropdownMenu open={open} onOpenChange={setOpen}>
      <DropdownMenuTrigger asChild>
        <button
          aria-label="Message actions"
          className={cn(
            "flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground",
            open && "bg-accent text-foreground",
          )}
        >
          <IconDots className="h-3.5 w-3.5" />
        </button>
      </DropdownMenuTrigger>
      <DropdownMenuContent
        align="start"
        sideOffset={6}
        className="w-48 rounded-lg border-border p-1.5 shadow-xl"
      >
        {actionsCtx?.onForkChat && (
          <DropdownMenuItem onSelect={handleForkChat}>
            <IconGitFork className="h-3.5 w-3.5" />
            Fork Chat
          </DropdownMenuItem>
        )}
        <DropdownMenuItem
          onSelect={(e) => {
            e.preventDefault();
            handleCopyMessage();
          }}
        >
          {copied === "message" ? (
            <IconCheck className="h-3.5 w-3.5" />
          ) : (
            <IconCopy className="h-3.5 w-3.5" />
          )}
          {copied === "message" ? "Copied!" : "Copy Message"}
        </DropdownMenuItem>
        <DropdownMenuItem
          onSelect={(e) => {
            e.preventDefault();
            handleCopyRequestId();
          }}
        >
          {copied === "id" ? (
            <IconCheck className="h-3.5 w-3.5" />
          ) : (
            <IconId className="h-3.5 w-3.5" />
          )}
          {copied === "id" ? "Copied!" : "Copy Request ID"}
        </DropdownMenuItem>
        {showRevert && (
          <DropdownMenuItem onSelect={handleRevert}>
            <IconArrowBackUp className="h-3.5 w-3.5" />
            Revert to here
          </DropdownMenuItem>
        )}
        {timestamp && (
          <>
            <DropdownMenuSeparator />
            <DropdownMenuLabel className="px-2 py-1 text-[11px] font-normal text-muted-foreground">
              Sent {timestamp.short}
            </DropdownMenuLabel>
          </>
        )}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

// ─── UserMessage ──────────────────────────────────────────────────────────────

export function UserMessage() {
  const [expanded, setExpanded] = useState(false);
  const [isExpandable, setIsExpandable] = useState(false);
  const contentRef = useRef<HTMLDivElement>(null);
  const messageRuntime = useMessageRuntime();
  const message = messageRuntime.getState();
  const timestamp = formatMessageTimestamp(message.createdAt);
  const isEditing = useComposer((state) => state.isEditing);
  const chatRunning = React.useContext(ChatRunningContext);
  const hidden = isHiddenUserMessage(message);
  const hasDisplayableText =
    !hidden &&
    (message.content
      ?.filter((part): part is { type: "text"; text: string } => {
        return part.type === "text" && typeof part.text === "string";
      })
      .some((part) => displayableUserMessageText(part.text).length > 0) ??
      false);

  useEffect(() => {
    const el = contentRef.current;
    if (!el || !hasDisplayableText) return;

    const measure = () => {
      setIsExpandable(el.scrollHeight > 200);
    };

    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(el);
    return () => observer.disconnect();
  }, [hasDisplayableText]);

  if (hidden) return null;

  // When in edit mode, show the inline edit composer instead of the message bubble.
  if (isEditing) {
    return (
      <div className="flex justify-end">
        <div className="w-full max-w-[85%]">
          <UserMessageEditComposer />
        </div>
      </div>
    );
  }

  return (
    <div
      className="group flex justify-end"
      style={{ contentVisibility: "auto" }}
    >
      <div className="max-w-[85%]">
        <UserMessageAttachments />
        {hasDisplayableText && (
          <div
            className="relative rounded-lg bg-accent px-3 py-2 text-sm leading-relaxed text-foreground"
            onCopy={(e) => {
              const selection = window.getSelection();
              if (!selection || selection.rangeCount === 0) return;
              const fragment = selection.getRangeAt(0).cloneContents();
              const mentions = fragment.querySelectorAll(
                "[data-mention-label]",
              );
              if (mentions.length === 0) return;
              e.preventDefault();
              mentions.forEach((el) => {
                el.textContent = `@${el.getAttribute("data-mention-label")}`;
              });
              const div = document.createElement("div");
              div.appendChild(fragment);
              e.clipboardData.setData("text/plain", div.textContent || "");
            }}
          >
            <div
              ref={contentRef}
              className={cn(
                "whitespace-pre-wrap break-words",
                !expanded && isExpandable && "max-h-[200px] overflow-hidden",
              )}
            >
              <MessagePrimitive.Parts
                components={{
                  Text: UserMessageText,
                }}
              />
            </div>
            {!expanded && isExpandable && (
              <div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 rounded-b-lg bg-gradient-to-t from-accent via-accent/90 to-transparent" />
            )}
            {/* Edit hover affordance — appears on hover when not running */}
            {!chatRunning && hasDisplayableText && (
              <TooltipProvider delayDuration={400}>
                <Tooltip>
                  <TooltipTrigger asChild>
                    <ActionBarPrimitive.Edit asChild>
                      <button
                        type="button"
                        aria-label="Edit message"
                        className="absolute -left-8 top-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground/0 transition-colors duration-150 group-hover:text-muted-foreground/70 group-hover:hover:bg-accent group-hover:hover:text-foreground"
                      >
                        <IconPencil className="h-3.5 w-3.5" />
                      </button>
                    </ActionBarPrimitive.Edit>
                  </TooltipTrigger>
                  <TooltipContent side="left" className="text-xs">
                    Edit message
                  </TooltipContent>
                </Tooltip>
              </TooltipProvider>
            )}
          </div>
        )}
        {hasDisplayableText && isExpandable && (
          <button
            type="button"
            onClick={() => setExpanded((prev) => !prev)}
            className="mt-1 inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium text-muted-foreground hover:text-foreground"
          >
            <IconChevronDown
              className={cn(
                "h-3.5 w-3.5 transition-transform",
                expanded && "rotate-180",
              )}
            />
            {expanded ? "Collapse" : "Expand"}
          </button>
        )}
        <div className="mt-1 flex items-center justify-end gap-1">
          <MessageBranchPicker />
          {timestamp && (
            <MessageTimestamp
              timestamp={timestamp}
              className={messageFooterFadeClassName}
            />
          )}
        </div>
      </div>
    </div>
  );
}

// ─── AssistantMessage ─────────────────────────────────────────────────────────

function assistantMessageHasRenderableContent(message: {
  content?: unknown;
}): boolean {
  const content = message.content;
  if (typeof content === "string") return content.trim().length > 0;
  if (!Array.isArray(content)) return false;
  return content.some((part) => {
    if (!part || typeof part !== "object") return false;
    const type = (part as { type?: unknown }).type;
    if (type === "text" || type === "reasoning") {
      const text = (part as { text?: unknown }).text;
      return typeof text === "string" && text.trim().length > 0;
    }
    return true;
  });
}

function assistantMessageStatusIsTerminal(message: {
  status?: { type?: unknown };
}): boolean {
  const statusType = message.status?.type;
  return statusType === "complete" || statusType === "incomplete";
}

export function messageTextFromContent(content: unknown): string {
  if (typeof content === "string") return content;
  if (!Array.isArray(content)) return "";
  return content
    .flatMap((part) => {
      if (!part || typeof part !== "object") return [];
      const record = part as {
        type?: unknown;
        text?: unknown;
      };
      return record.type === "text" && typeof record.text === "string"
        ? [record.text]
        : [];
    })
    .join("\n");
}

export function latestUserMessageText(messages: readonly unknown[]): string {
  for (let index = messages.length - 1; index >= 0; index -= 1) {
    const message = messages[index];
    if (!message || typeof message !== "object") continue;
    const record = message as { role?: unknown; content?: unknown };
    if (record.role !== "user" || isHiddenUserMessage(message)) continue;
    return displayableUserMessageText(messageTextFromContent(record.content));
  }
  return "";
}

export function userMessageTextBeforeAssistant(
  messages: readonly unknown[],
  assistantMessageId: string,
): string {
  const assistantIndex = messages.findIndex((message) => {
    if (!message || typeof message !== "object") return false;
    return (message as { id?: unknown }).id === assistantMessageId;
  });
  if (assistantIndex < 1) return "";

  for (let index = assistantIndex - 1; index >= 0; index -= 1) {
    const message = messages[index];
    if (!message || typeof message !== "object") continue;
    const record = message as { role?: unknown; content?: unknown };
    if (record.role !== "user" || isHiddenUserMessage(message)) continue;
    return displayableUserMessageText(messageTextFromContent(record.content));
  }
  return "";
}

export function assistantMessageHasUnresolvedTool(content: unknown): boolean {
  if (!Array.isArray(content)) return false;
  return content.some((part): boolean => {
    if (!part || typeof part !== "object") return false;
    const record = part as { type?: unknown; result?: unknown };
    return record.type === "tool-call" && record.result === undefined;
  });
}

export function assistantMessageHasCompletedCustomUi(
  content: unknown,
): boolean {
  if (!Array.isArray(content)) return false;
  let lastTextIndex = -1;
  for (let index = 0; index < content.length; index++) {
    const part = content[index];
    if (
      part &&
      typeof part === "object" &&
      (part as { type?: unknown }).type === "text"
    ) {
      lastTextIndex = index;
    }
  }
  let hasCompletedTool = false;
  let lastCompletedToolIsCustomUi = false;
  for (let index = lastTextIndex + 1; index < content.length; index++) {
    const part = content[index];
    if (!part || typeof part !== "object") continue;
    const record = part as {
      type?: unknown;
      result?: unknown;
      isError?: unknown;
      activity?: unknown;
      chatUI?: unknown;
      mcpApp?: unknown;
    };
    if (
      record.type !== "tool-call" ||
      record.activity === true ||
      record.result === undefined ||
      record.isError === true
    ) {
      continue;
    }
    hasCompletedTool = true;
    lastCompletedToolIsCustomUi =
      record.chatUI !== undefined || record.mcpApp !== undefined;
  }
  return hasCompletedTool && lastCompletedToolIsCustomUi;
}

export function assistantMessageHasCustomUi(content: unknown): boolean {
  if (!Array.isArray(content)) return false;
  return content.some((part) => {
    if (!part || typeof part !== "object") return false;
    const record = part as {
      type?: unknown;
      chatUI?: unknown;
      mcpApp?: unknown;
    };
    return (
      record.type === "tool-call" &&
      (record.chatUI !== undefined || record.mcpApp !== undefined)
    );
  });
}

// Only the last assistant message may shimmer as "the currently running
// tool" — an older message's dangling unresolved tool-call must never
// shimmer once a later run is active.
export function computeActiveTailToolCallId(
  content: ContentPart[] | undefined,
  { chatRunning, isLast }: { chatRunning: boolean; isLast: boolean },
): string | null {
  if (!isLast) return null;
  return (
    content?.reduce(
      (latestToolCallId, part, index) =>
        part.type === "tool-call" &&
        !isCallAgentToolCallShadowed(content, index) &&
        (chatRunning || part.activity === true)
          ? part.toolCallId
          : latestToolCallId,
      null as string | null,
    ) ?? null
  );
}

export function shouldShowAssistantMessageFooter({
  isLast,
  chatRunning,
  hasRenderableContent,
  statusIsTerminal,
  hasUnresolvedTool,
}: {
  isLast: boolean;
  chatRunning: boolean;
  hasRenderableContent: boolean;
  statusIsTerminal: boolean;
  hasUnresolvedTool?: boolean;
}): boolean {
  if (!hasRenderableContent) return false;
  if (!isLast) return true;
  if (chatRunning) return false;
  if (hasUnresolvedTool) return false;
  return statusIsTerminal;
}

/**
 * Server-authoritative "a run for this thread is still active and running".
 * Local `chatRunning` dips to not-running at every chunk boundary and transport
 * re-attach while the turn is alive server-side, so it cannot decide on its own
 * that the agent stopped.
 */
export const ServerRunActiveContext = React.createContext(false);

export function shouldShowMissingFinalResponse({
  isCurrentTurnRunning,
  serverRunActive,
  statusIsTerminal,
  hasAssistantText,
  hasUnresolvedTool,
  hasCompletedCustomUi,
}: {
  isCurrentTurnRunning: boolean;
  serverRunActive?: boolean;
  statusIsTerminal: boolean;
  hasAssistantText: boolean;
  hasUnresolvedTool: boolean;
  hasCompletedCustomUi?: boolean;
}): boolean {
  if (serverRunActive) return false;
  // A completed tool can make the latest message look terminal before the
  // active turn attaches its follow-up text.
  return (
    !isCurrentTurnRunning &&
    statusIsTerminal &&
    !hasAssistantText &&
    !hasUnresolvedTool &&
    !hasCompletedCustomUi
  );
}

/**
 * "The agent stopped" is derived from local client state, which dips to
 * not-running at every chunk boundary and transport re-attach while the turn is
 * still alive server-side. Requiring the shape to hold for a beat keeps the
 * notice off the screen for those gaps without hiding a real stop for long.
 */
const MISSING_FINAL_RESPONSE_SETTLE_MS = 3_000;

export function useSettledFlag(active: boolean, delayMs: number): boolean {
  const [settled, setSettled] = useState(false);
  useEffect(() => {
    if (!active || delayMs <= 0) {
      setSettled(false);
      return;
    }
    const timer = window.setTimeout(() => setSettled(true), delayMs);
    return () => window.clearTimeout(timer);
  }, [active, delayMs]);
  return active && (delayMs <= 0 || settled);
}

export function shouldShowAssistantWorkSummary({
  isLast,
  isComplete,
  hasCollapsibleWork,
  hasUnresolvedTool,
}: {
  isLast: boolean;
  isComplete: boolean;
  hasCollapsibleWork: boolean;
  hasUnresolvedTool: boolean;
}): boolean {
  if (!hasCollapsibleWork || hasUnresolvedTool) return false;

  // Keep completed historical work wrapped while a later turn is running.
  // Removing the wrapper exposes/remounts ReasoningCell and resets its
  // disclosure state to the default-open value on every new submission.
  return isComplete || !isLast;
}

function ReasoningMessagePart() {
  const part = useMessagePartReasoning();
  const partRuntime = useMessagePartRuntime();
  const messageParts = useAuiState((state) => state.message.parts);
  const isStreaming = part.status?.type === "running";
  const partIndex =
    partRuntime.path.messagePartSelector.type === "index"
      ? partRuntime.path.messagePartSelector.index
      : -1;
  const latestReasoningPartIndex = messageParts.reduce(
    (latestIndex, messagePart, index) =>
      messagePart.type === "reasoning" ? index : latestIndex,
    -1,
  );
  // Time thinking client-side: record the moment streaming first starts and
  // the moment it stops so the cell can show "Thought for Xs". Historical
  // messages that were never observed streaming in this session never get a
  // start time, so they correctly fall back to a plain "Thought" label.
  const startedAtRef = useRef<number | null>(null);
  const [durationMs, setDurationMs] = useState<number | null>(null);
  useEffect(() => {
    if (isStreaming) {
      startedAtRef.current ??= Date.now();
      return;
    }
    if (startedAtRef.current != null) {
      setDurationMs(Date.now() - startedAtRef.current);
      startedAtRef.current = null;
    }
  }, [isStreaming]);
  return (
    <ReasoningCell
      text={part.text}
      isStreaming={isStreaming}
      resetKey={`message-reasoning-${partIndex}`}
      durationMs={durationMs}
      defaultOpen={partIndex === latestReasoningPartIndex}
      collapseWhenReplaced={partIndex < latestReasoningPartIndex}
    />
  );
}

const ALWAYS_VISIBLE_ASSISTANT_TOOLS = new Set(["connect-builder"]);

export function isCollapsibleAssistantWorkPart(part: {
  type?: string;
  toolName?: string;
  chatUI?: unknown;
  mcpApp?: unknown;
}): boolean {
  if (part.type === "reasoning") return true;
  return (
    part.type === "tool-call" &&
    !ALWAYS_VISIBLE_ASSISTANT_TOOLS.has(part.toolName ?? "") &&
    part.chatUI === undefined &&
    part.mcpApp === undefined
  );
}

export function getAssistantToolSummaryInfo(
  parts: readonly {
    type?: string;
    toolCallId?: string;
    toolName?: string;
    args?: Record<string, unknown>;
    chatUI?: unknown;
    mcpApp?: unknown;
  }[],
): { startIndex: number; hiddenToolCount: number } {
  const toolCallIndices = parts.reduce<number[]>((indices, part, index) => {
    if (
      part.type === "tool-call" &&
      !isCallAgentToolCallShadowed(parts, index) &&
      isCollapsibleAssistantWorkPart(part)
    ) {
      indices.push(index);
    }
    return indices;
  }, []);

  if (toolCallIndices.length <= ASSISTANT_VISIBLE_TOOL_CALL_LIMIT) {
    return { startIndex: -1, hiddenToolCount: 0 };
  }

  const startIndex =
    toolCallIndices[
      toolCallIndices.length - ASSISTANT_VISIBLE_TOOL_CALL_LIMIT
    ]!;
  return {
    startIndex,
    hiddenToolCount: toolCallIndices.length - ASSISTANT_VISIBLE_TOOL_CALL_LIMIT,
  };
}

function groupAssistantWorkParts(
  part: {
    type?: string;
    toolCallId?: string;
    toolName?: string;
    args?: Record<string, unknown>;
    chatUI?: unknown;
    mcpApp?: unknown;
  },
  index: number,
  parts: readonly {
    type?: string;
    toolCallId?: string;
    toolName?: string;
    args?: Record<string, unknown>;
    chatUI?: unknown;
    mcpApp?: unknown;
  }[],
): ["group-work"] | ["group-work", "group-ran-tools"] | null {
  if (isCallAgentToolCallShadowed(parts, index)) return null;
  if (isCollapsibleAssistantWorkPart(part)) {
    const { startIndex } = getAssistantToolSummaryInfo(parts);
    if (isAssistantToolSummaryPart(parts, index, startIndex)) {
      return ["group-work", "group-ran-tools"];
    }
    return ["group-work"];
  }
  return null;
}

function isAssistantToolSummaryPart(
  parts: readonly {
    type?: string;
    toolCallId?: string;
    toolName?: string;
    args?: Record<string, unknown>;
    chatUI?: unknown;
    mcpApp?: unknown;
  }[],
  index: number,
  startIndex: number,
): boolean {
  if (startIndex < 0 || index >= startIndex) return false;
  if (
    isCallAgentToolCallShadowed(parts, index) ||
    !isCollapsibleAssistantWorkPart(parts[index]!)
  ) {
    return false;
  }

  let segmentStart = index;
  while (
    segmentStart > 0 &&
    !isCallAgentToolCallShadowed(parts, segmentStart - 1) &&
    isCollapsibleAssistantWorkPart(parts[segmentStart - 1]!)
  ) {
    segmentStart--;
  }

  let segmentEnd = index + 1;
  while (
    segmentEnd < startIndex &&
    !isCallAgentToolCallShadowed(parts, segmentEnd) &&
    isCollapsibleAssistantWorkPart(parts[segmentEnd]!)
  ) {
    segmentEnd++;
  }

  return parts
    .slice(segmentStart, segmentEnd)
    .some((candidate) => candidate.type === "tool-call");
}

export function AssistantMessage() {
  const [restoreState, setRestoreState] = useState<
    "idle" | "confirming" | "restoring" | "error"
  >("idle");
  const [restoreError, setRestoreError] = useState<string | null>(null);
  const messageRuntime = useMessageRuntime();
  const thread = useThread();
  const chatRunning = React.useContext(ChatRunningContext);
  const lastRunDurationMs = React.useContext(ChatRunDurationContext);
  const msg = messageRuntime.getState();
  const persistedDurationMs = getAssistantRunDurationMs(msg);
  const timestamp = formatMessageTimestamp(msg.createdAt);
  const isLast =
    thread.messages.length > 0 &&
    thread.messages[thread.messages.length - 1].id === msg.id;
  const hasRenderableContent = assistantMessageHasRenderableContent(msg);
  const hasUnresolvedTool = assistantMessageHasUnresolvedTool(msg.content);
  const responseConnectionText = messageTextFromContent(msg.content);
  const statusIsTerminal = assistantMessageStatusIsTerminal(msg);
  const hasCompletedCustomUi = assistantMessageHasCompletedCustomUi(
    msg.content,
  );
  const hasCustomUi = assistantMessageHasCustomUi(msg.content);
  const serverRunActive = React.useContext(ServerRunActiveContext);
  const showMissingFinalResponse = useSettledFlag(
    shouldShowMissingFinalResponse({
      isCurrentTurnRunning: isLast && chatRunning,
      serverRunActive: isLast && serverRunActive,
      statusIsTerminal,
      hasAssistantText: responseConnectionText.trim().length > 0,
      hasUnresolvedTool,
      hasCompletedCustomUi,
    }),
    isLast ? MISSING_FINAL_RESPONSE_SETTLE_MS : 0,
  );
  const responseConnectionContext = userMessageTextBeforeAssistant(
    thread.messages,
    msg.id,
  );
  const isComplete = shouldShowAssistantMessageFooter({
    isLast,
    chatRunning,
    hasRenderableContent,
    statusIsTerminal,
    hasUnresolvedTool,
  });
  const cpCtx = React.useContext(CheckpointContext);

  // Capture live run duration when this message finishes streaming.
  const runStartedAtRef = useRef<number | null>(null);
  const [capturedDurationMs, setCapturedDurationMs] = useState<number | null>(
    null,
  );
  useEffect(() => {
    if (chatRunning && isLast) {
      if (runStartedAtRef.current == null) {
        runStartedAtRef.current = Date.now();
      }
      return;
    }
    if (!chatRunning && isLast && capturedDurationMs == null) {
      const durationMs =
        lastRunDurationMs ??
        (runStartedAtRef.current == null
          ? null
          : Date.now() - runStartedAtRef.current);
      if (durationMs == null) return;
      setCapturedDurationMs(durationMs);
      runStartedAtRef.current = null;
    }
  }, [chatRunning, isLast, capturedDurationMs, lastRunDurationMs]);

  // Animate collapse only when this message just finished running in-session.
  const wasRunningRef = useRef(false);
  const [animateCollapse, setAnimateCollapse] = useState(false);
  useEffect(() => {
    if (wasRunningRef.current && !chatRunning && isComplete && isLast) {
      setAnimateCollapse(true);
    }
    wasRunningRef.current = chatRunning && isLast;
  }, [chatRunning, isComplete, isLast]);

  const messageRunId = assistantMessageRunId(msg);

  const handleRestore = useCallback(async () => {
    if (restoreState === "idle" || restoreState === "error") {
      setRestoreError(null);
      setRestoreState("confirming");
      return;
    }
    if (restoreState !== "confirming" || !cpCtx) return;
    if (!messageRunId) {
      setRestoreError("This message has no run to restore to.");
      setRestoreState("error");
      return;
    }
    setRestoreState("restoring");
    try {
      const restoreRes = await fetch(`${cpCtx.apiUrl}/checkpoints/restore`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ runId: messageRunId }),
      });
      if (restoreRes.ok) {
        window.location.reload();
        return;
      }
      const payload = (await restoreRes.json().catch(() => null)) as {
        error?: unknown;
      } | null;
      setRestoreError(
        typeof payload?.error === "string"
          ? payload.error
          : `Restore failed (${restoreRes.status}).`,
      );
      setRestoreState("error");
    } catch (err) {
      setRestoreError(
        err instanceof Error ? err.message : "Restore request failed.",
      );
      setRestoreState("error");
    }
  }, [restoreState, cpCtx, messageRunId]);

  const cancelRestore = useCallback(() => {
    setRestoreError(null);
    setRestoreState("idle");
  }, []);

  const showRestore = shouldOfferRestore({
    devMode: cpCtx?.devMode,
    isComplete,
    isLast,
    runId: messageRunId,
    checkpointRunIds: cpCtx?.checkpointRunIds,
  });

  // Collect parts for the files-changed summary (code-agent turns only).
  const msgContent = msg.content as ContentPart[] | undefined;
  const hasCodeAgentTools =
    Array.isArray(msgContent) &&
    msgContent.some(
      (p) =>
        p.type === "tool-call" &&
        p.structuredMeta &&
        (p.structuredMeta.toolKind === "edit" ||
          p.structuredMeta.toolKind === "write"),
    );
  const hasCollapsibleWork =
    Array.isArray(msgContent) &&
    msgContent.some(
      (p, index) =>
        !isCallAgentToolCallShadowed(msgContent, index) &&
        (p.type !== "tool-call" || p.activity !== true) &&
        isCollapsibleAssistantWorkPart(p),
    );
  const shadowedToolCallIds = Array.isArray(msgContent)
    ? shadowedCallAgentToolCallIds(msgContent)
    : new Set<string>();
  const activeTailToolCallId = computeActiveTailToolCallId(msgContent, {
    chatRunning,
    isLast,
  });

  if (!hasRenderableContent) return null;

  return (
    <div
      className="group relative"
      style={{ contentVisibility: isComplete ? "auto" : "visible" }}
    >
      <div className="w-full max-w-[95%] text-sm leading-relaxed text-foreground">
        <MessagePrimitive.GroupedParts groupBy={groupAssistantWorkParts}>
          {({ part, children }) => {
            switch (part.type) {
              case "group-work": {
                const showSummary = shouldShowAssistantWorkSummary({
                  isLast,
                  isComplete,
                  hasCollapsibleWork,
                  hasUnresolvedTool,
                });
                if (!showSummary) return <>{children}</>;
                return (
                  <WorkedForSummary
                    durationMs={capturedDurationMs ?? persistedDurationMs}
                    defaultOpen={hasCustomUi}
                    autoCollapse={animateCollapse && !hasCustomUi}
                  >
                    {children}
                  </WorkedForSummary>
                );
              }
              case "group-ran-tools": {
                const toolCount = part.indices.filter(
                  (index) => msgContent?.[index]?.type === "tool-call",
                ).length;
                return (
                  <RanToolsSummary toolCount={toolCount}>
                    {children}
                  </RanToolsSummary>
                );
              }
              case "text":
                return <MarkdownText />;
              case "reasoning":
                return <ReasoningMessagePart />;
              case "tool-call":
                if (shadowedToolCallIds.has(part.toolCallId)) return null;
                return part.toolUI ? (
                  <ToolActivityPresentation
                    toolName={part.toolName}
                    isRunning={part.status?.type === "running"}
                    isActiveTail={part.toolCallId === activeTailToolCallId}
                  >
                    {part.toolUI}
                  </ToolActivityPresentation>
                ) : (
                  <ToolCallFallback
                    {...part}
                    isActiveTail={part.toolCallId === activeTailToolCallId}
                  />
                );
              default:
                return null;
            }
          }}
        </MessagePrimitive.GroupedParts>
        {showMissingFinalResponse && (
          <p role="status" className="text-muted-foreground">
            The agent stopped without sending a final message. Ask it to
            continue or retry.
          </p>
        )}
        {isComplete && hasCodeAgentTools && msgContent && (
          <FilesChangedSummary parts={msgContent} />
        )}
        {isComplete && (
          <McpConnectionSuggestion
            text={responseConnectionText}
            contextText={responseConnectionContext}
            variant="response"
          />
        )}
        {isLast && hasUnresolvedTool && !chatRunning && (
          <RunningActivityStatus label="Thinking" />
        )}
      </div>
      {isComplete && (
        <div className="mt-1 flex items-center justify-between">
          <div className="flex min-w-0 items-center gap-1">
            <MessageActionsMenu
              showRevert={showRestore && restoreState === "idle"}
              onRevert={handleRestore}
            />
            {/* Regenerate button — only on the last assistant message, auto-disabled while running */}
            {isLast && (
              <TooltipProvider delayDuration={400}>
                <Tooltip>
                  <TooltipTrigger asChild>
                    <ActionBarPrimitive.Reload asChild>
                      <button
                        type="button"
                        aria-label="Regenerate response"
                        className={`flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground/70 hover:bg-accent hover:text-foreground ${messageFooterFadeClassName} disabled:cursor-not-allowed disabled:opacity-40`}
                      >
                        <IconRefresh className="h-3.5 w-3.5" />
                      </button>
                    </ActionBarPrimitive.Reload>
                  </TooltipTrigger>
                  <TooltipContent side="top" className="text-xs">
                    Regenerate response
                  </TooltipContent>
                </Tooltip>
              </TooltipProvider>
            )}
            <MessageBranchPicker />
            {timestamp && (
              <MessageTimestamp
                timestamp={timestamp}
                className={messageFooterFadeClassName}
              />
            )}
          </div>
          {showRestore && restoreState === "confirming" ? (
            <div className="flex items-center gap-1 text-xs">
              <button
                onClick={handleRestore}
                className="rounded-md bg-destructive px-1.5 py-0.5 text-destructive-foreground hover:bg-destructive/90"
              >
                Restore to here?
              </button>
              <button
                onClick={cancelRestore}
                className="rounded-md px-1.5 py-0.5 text-muted-foreground hover:bg-accent"
              >
                Cancel
              </button>
            </div>
          ) : showRestore && restoreState === "restoring" ? (
            <span className="flex items-center gap-1 text-xs text-muted-foreground">
              <IconLoader2 className="h-3 w-3 animate-spin" />
              Restoring...
            </span>
          ) : restoreState === "error" ? (
            <span className="flex items-center gap-1 text-xs text-destructive">
              <IconAlertTriangle className="h-3 w-3 shrink-0" />
              <span className="truncate">{restoreError}</span>
              <button
                onClick={cancelRestore}
                className="cursor-pointer rounded-md px-1.5 py-0.5 text-muted-foreground hover:bg-accent"
              >
                Dismiss
              </button>
            </span>
          ) : (
            <ThumbsFeedback
              threadId={cpCtx?.threadId ?? ""}
              runId={messageRunId ?? ""}
              messageSeq={thread.messages.findIndex((m) => m.id === msg.id)}
            />
          )}
        </div>
      )}
    </div>
  );
}

// ─── RunningActivityStatus / ThinkingIndicator ────────────────────────────────

export function RunningActivityStatus({ label }: { label: string }) {
  return (
    <div className="agent-running-activity">
      <ThinkingIndicator label={label} />
    </div>
  );
}

export function ThinkingIndicator({
  label = "Thinking",
}: { label?: string } = {}) {
  return (
    <div
      className="agent-thinking-indicator"
      role="status"
      aria-live="polite"
      aria-label={label}
    >
      <span className="agent-thinking-indicator__text">{label}</span>
    </div>
  );
}
