// Thread (conversation) view
// ----- Toast system (shared across a single ConvoApp instance via window event bus) -----
const showToast = (msg) => {
  const ev = new CustomEvent("convo:toast", { detail: { msg } });
  window.dispatchEvent(ev);
};

// ----- AnimatedMount: keeps children mounted through an exit animation, then removes.
// Renders <div className="`${baseClass} ${show ? 'in' : 'out'}`"> and unmounts after
// `duration` ms when toggled off. Pair with CSS keyframes on .baseClass.in / .out.
const AnimatedMount = ({ show, baseClass, duration = 220, style, children }) => {
  const [render, setRender] = useState(show);
  useEffect(() => {
    if (show) {
      setRender(true);
      return undefined;
    }
    const t = setTimeout(() => setRender(false), duration);
    return () => clearTimeout(t);
  }, [show, duration]);
  if (!render) return null;
  return (
    <div className={`${baseClass} ${show ? "in" : "out"}`} style={style}>
      {children}
    </div>);

};

const ToastHost = () => {
  const [toasts, setToasts] = useState([]);
  useEffect(() => {
    const onToast = (e) => {
      const id = Math.random().toString(36).slice(2);
      setToasts((t) => [...t, { id, msg: e.detail.msg }]);
      setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600);
    };
    window.addEventListener("convo:toast", onToast);
    return () => window.removeEventListener("convo:toast", onToast);
  }, []);
  return (
    <div style={{
      position: "absolute", bottom: 16, left: "50%", transform: "translateX(-50%)",
      display: "flex", flexDirection: "column", gap: 6, zIndex: 50, pointerEvents: "none"
    }}>
      {toasts.map((t) =>
      <div key={t.id} style={{
        background: "#101828", color: "#FFFFFF", padding: "8px 14px", borderRadius: 6,
        fontSize: 12, fontWeight: 500, boxShadow: "0 4px 12px rgba(16,24,40,.15)",
        display: "flex", alignItems: "center", gap: 8, animation: "convoToastIn .18s ease-out"
      }}>
          <I.check size={13} stroke="#12B76A" />
          {t.msg}
        </div>
      )}
    </div>);

};
window.ToastHost = ToastHost;

// ----- Header with 3-dot menu -----
// The first item flips with the conversation's own state — you can only mark
// an unread conversation read, and vice versa.
const headerMenuItems = (isUnread) => [
{ id: isUnread ? "markRead" : "markUnread",
  label: isUnread ? "Mark as read" : "Mark as unread",
  icon: I.email,
  toast: isUnread ? "Marked as read" : "Marked as unread" },
{ id: "export", label: "Export Chat", icon: I.download, toast: "Chat exported" },
{ id: "archive", label: "Archive", icon: I.archive, toast: "Conversation archived" },
{ divider: true },
{ id: "dnm", label: "Add to DNM", icon: I.messageSquareOff, toast: "Added to DNM list" },
{ id: "dnc", label: "Add to DNC", icon: I.phoneOff, toast: "Added to DNC list" },
{ id: "blacklist", label: "Add to Blacklist", icon: I.chatBan, toast: "Added to blacklist" },
{ id: "delete", label: "Delete", icon: I.trash, toast: "Conversation deleted", destructive: true }];

// Contact-detail header 3-dot menu — quieter, contact-scoped actions
const CONTACT_MENU_ITEMS = [
{ id: "dialer", label: "Add to Sales Dialer", icon: I.dialer, toast: "Added to Sales Dialer" },
{ id: "dnm", label: "Mark as DNM", icon: I.ban, toast: "Marked as DNM (do not message)" },
{ id: "blacklist", label: "Blacklist contact", icon: I.userX, toast: "Contact blacklisted", destructive: true }];
window.CONTACT_MENU_ITEMS = CONTACT_MENU_ITEMS;


// Header AI button — replaces the old header call button. Click recalls
// the floating AI summary widget when it's been dismissed; while visible,
// the button shows an "active" purple state and clicking it does nothing
// (the widget is already on screen).
const HeaderAIButton = ({ thread }) => {
  const [hover, setHover] = useState(false);
  const [visible, setVisible] = useState(
    typeof window !== "undefined" ? !!window.__aiSummaryWidgetVisible : true
  );
  useEffect(() => {
    const onState = (e) => setVisible(!!(e.detail && e.detail.visible));
    window.addEventListener("aiSummaryWidget:state", onState);
    setVisible(!!window.__aiSummaryWidgetVisible);
    return () => window.removeEventListener("aiSummaryWidget:state", onState);
  }, [thread.id]);

  const onClick = () => {
    if (visible) {
      window.dispatchEvent(new CustomEvent("convo:hideAISummary"));
    } else {
      window.dispatchEvent(new CustomEvent("convo:showAISummary"));
    }
  };

  return (
    <div style={{ position: "relative" }}
    onMouseEnter={() => setHover(true)}
    onMouseLeave={() => setHover(false)}>
      <button
        onClick={onClick}
        style={{
          ...headerIconBtn,
          background: visible ? "#F4EBFF" : "#FFFFFF",
          border: `1px solid ${visible ? "#E9D7FE" : "#D6BBFB"}`,
          cursor: "pointer", borderRadius: "8px"
        }}>
        <I.sparkle size={15} stroke="#7F56D9" />
      </button>
      {hover &&
      <div style={{
        position: "absolute", top: "calc(100% + 8px)", right: 0, zIndex: 30,
        background: "#101828", color: "#FFFFFF", borderRadius: 6,
        padding: "8px 10px", boxShadow: "0 6px 18px rgba(16,24,40,.16)",
        whiteSpace: "nowrap", pointerEvents: "none",
        display: "flex", flexDirection: "column", gap: 2
      }}>
          <div style={{ fontSize: 12, fontWeight: 600 }}>
            {visible ? "Hide AI summary" : "Show AI summary"}
          </div>
          <div style={{ fontSize: 11, color: "#D0D5DD" }}>
            Toggle the floating insights widget
          </div>
        </div>
      }
    </div>);

};

/* `chrome` says which of the header's controls this host wants. Everything is
   on unless said otherwise, so the Conversations app passes nothing and is
   unchanged. The campaign queue's thread column asks for almost none of it:
   the agent is inside a call, not triaging an inbox, so there is nothing to
   assign, nothing to close, and no summary to recall — and the one control it
   does want is the contact sheet, which takes the 3-dot menu's place because
   that is where a control for "what else is on screen" belongs. */
const ThreadHeader = ({ thread, permission, onCloseConversation, onToggleContact, showContact, currentUser, onToggleRead, chrome }) => {
  const canEdit = permission === "full";
  const isClosed = thread.status === "closed";
  const isUnread = thread.unread > 0 || !!thread.__dotUnread;
  const [menuOpen, setMenuOpen] = useState(false);
  const menuRef = useRef(null);
  const [assignOpen, setAssignOpen] = useState(false);
  const [assignQuery, setAssignQuery] = useState("");
  const assignRef = useRef(null);

  const ch = chrome || {};
  const show = (k) => ch[k] !== false;

  const agents = window.AGENTS || [];
  const initialAssignee = thread.assignee ?
  agents.find((a) => a.id === thread.assignee.id) || { id: thread.assignee.id, name: thread.assignee.name, initials: (thread.assignee.name || "?").split(" ").map((w) => w[0]).join("").slice(0, 2), color: "#667085" } :
  null;
  const [assignee, setAssignee] = useState(initialAssignee);
  useEffect(() => {
    const next = thread.assignee ? agents.find((a) => a.id === thread.assignee.id) || null : null;
    setAssignee(next);
  }, [thread.id]);

  useEffect(() => {
    if (!menuOpen) return;
    const close = (e) => {if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);};
    document.addEventListener("mousedown", close);
    return () => document.removeEventListener("mousedown", close);
  }, [menuOpen]);
  useEffect(() => {
    if (!assignOpen) return;
    const close = (e) => {if (assignRef.current && !assignRef.current.contains(e.target)) {setAssignOpen(false);setAssignQuery("");}};
    document.addEventListener("mousedown", close);
    return () => document.removeEventListener("mousedown", close);
  }, [assignOpen]);

  const filteredAgents = agents.filter((a) => a.name.toLowerCase().includes(assignQuery.toLowerCase()));

  const assigneeLabel = assignee ?
  currentUser && assignee.id === currentUser.id ? `${assignee.name.split(" ")[0]} (You)` : assignee.name :
  "Unassigned";

  return (
    <div style={{
      padding: "12px 20px", borderBottom: "1px solid #E4E7EC", background: "#FFFFFF",
      display: "flex", alignItems: "center", gap: 12, flexShrink: 0, position: "relative"
    }}>
      <div onClick={onToggleContact} title="Show contact details"
      style={{
        display: "flex", alignItems: "center", gap: 12, cursor: "pointer", flex: 1, minWidth: 0,
        padding: "4px 8px 4px 4px", marginLeft: -4, borderRadius: 6,
        background: "transparent", transition: "background 0.15s"
      }}
      onMouseEnter={(e) => {e.currentTarget.style.background = "#F9FAFB";}}
      onMouseLeave={(e) => {e.currentTarget.style.background = "transparent";}}>
        <div style={{
          width: 36, height: 36, borderRadius: 500, background: thread.color, color: "#FFFFFF",
          display: "flex", alignItems: "center", justifyContent: "center", fontSize: 13, fontWeight: 600, flexShrink: 0
        }}>{thread.avatar}</div>

        <div style={{ flex: 1, minWidth: 0, overflow: "hidden" }}>
          {thread.isContact ?
          <>
              <div style={{
              fontSize: 15, fontWeight: 600, color: "#101828",
              whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"
            }}>{thread.name}</div>
              <div style={{ fontSize: 11, color: "#667085", marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
                {thread.phone}
              </div>
            </> :

          <div style={{
            fontSize: 15, fontWeight: 600, color: "#101828",
            whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"
          }}>{thread.phone || thread.name}</div>
          }
        </div>
      </div>

      <div style={{ display: "flex", gap: 6, alignItems: "center", flexShrink: 0 }}>
        {/* 1. AI sparkle button — recalls the floating AI summary widget */}
        {show("ai") && <HeaderAIButton thread={thread} />}

        {/* 2. Assign dropdown */}
        {show("assign") &&
        <div ref={assignRef} style={{ position: "relative" }}>
          <button disabled={!canEdit}
          onClick={() => canEdit && setAssignOpen((o) => !o)}
          style={{
            display: "inline-flex", alignItems: "center", gap: 6, padding: "6px 8px 6px 6px",
            border: `1px solid ${assignOpen ? "#D0D5DD" : "#E4E7EC"}`,
            background: assignOpen ? "#F9FAFB" : "#FFFFFF",
            cursor: canEdit ? "pointer" : "not-allowed", fontSize: 14, fontWeight: 500, color: "#344054",
            maxWidth: 180, borderRadius: "8px"
          }}>
            {assignee ?
            <span style={{
              width: 20, height: 20, borderRadius: 500, background: assignee.color, color: "#FFFFFF",
              display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: 9, fontWeight: 700, flexShrink: 0
            }}>{assignee.initials}</span> :

            <I.user size={14} stroke="#667085" />
            }
            <span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontSize: "13px" }}>{assigneeLabel}</span>
            <I.chevDown size={12} stroke="#667085" />
          </button>

          {assignOpen &&
          <div style={{
            position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 25,
            width: 280, background: "#FFFFFF", border: "1px solid #E4E7EC", borderRadius: 6,
            boxShadow: "0 8px 24px rgba(16,24,40,.08), 0 2px 6px rgba(16,24,40,.04)",
            padding: 6, display: "flex", flexDirection: "column"
          }}>
              <div style={{
              display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", marginBottom: 4,
              border: "1px solid #E4E7EC", borderRadius: 4, background: "#F9FAFB"
            }}>
                <I.search size={13} stroke="#667085" />
                <input autoFocus value={assignQuery} onChange={(e) => setAssignQuery(e.target.value)}
              placeholder="Search agents..."
              style={{ flex: 1, border: "none", outline: "none", background: "transparent", fontSize: 12, color: "#101828" }} />
              </div>
              <div style={{ maxHeight: 260, overflowY: "auto" }}>
                <button
                onClick={() => {
                  setAssignOpen(false);setAssignQuery("");
                  // Re-picking what's already set isn't a change worth recording.
                  if (!assignee) return;
                  setAssignee(null);
                  appendEvent(thread.id, { event: "unassigned" });
                  showToast("Conversation unassigned");
                }}
                style={{
                  width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "8px 10px",
                  borderRadius: 4, border: "none", background: !assignee ? "#EFF8FF" : "transparent",
                  cursor: "pointer", textAlign: "left", fontSize: 12, color: "#344054"
                }}
                onMouseEnter={(e) => {if (assignee) e.currentTarget.style.background = "#F9FAFB";}}
                onMouseLeave={(e) => {if (assignee) e.currentTarget.style.background = "transparent";}}>
                  <span style={{
                  width: 24, height: 24, borderRadius: 500, background: "#F2F4F7",
                  display: "inline-flex", alignItems: "center", justifyContent: "center"
                }}>
                    <I.userX size={12} stroke="#667085" />
                  </span>
                  <span style={{ flex: 1 }}>Unassigned</span>
                  {!assignee && <I.check size={13} stroke="#004CE6" />}
                </button>
                {filteredAgents.map((a) => {
                const active = assignee && a.id === assignee.id;
                const isYou = currentUser && a.id === currentUser.id;
                const statusColors = { available: "#12B76A", oncall: "#004CE6", away: "#F79009", offline: "#98A2B3" };
                return (
                  <button key={a.id}
                  onClick={() => {
                    setAssignOpen(false);setAssignQuery("");
                    if (active) return;
                    setAssignee(a);
                    appendEvent(thread.id, { event: "assigned", actor: a.name });
                    showToast(`Assigned to ${a.name}${isYou ? " (you)" : ""}`);
                  }}
                  style={{
                    width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "8px 10px",
                    borderRadius: 4, border: "none", background: active ? "#EFF8FF" : "transparent",
                    cursor: "pointer", textAlign: "left", fontSize: 12, color: "#344054"
                  }}
                  onMouseEnter={(e) => {if (!active) e.currentTarget.style.background = "#F9FAFB";}}
                  onMouseLeave={(e) => {if (!active) e.currentTarget.style.background = "transparent";}}>
                      <span style={{ position: "relative", flexShrink: 0 }}>
                        <span style={{
                        width: 24, height: 24, borderRadius: 500, background: a.color, color: "#FFFFFF",
                        display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 600
                      }}>{a.initials}</span>
                        <span style={{
                        position: "absolute", bottom: -1, right: -1, width: 8, height: 8, borderRadius: 500,
                        background: statusColors[a.status] || "#98A2B3", border: "1.5px solid #FFFFFF"
                      }} />
                      </span>
                      <div style={{ flex: 1, minWidth: 0 }}>
                        <div style={{ fontSize: 12, fontWeight: active ? 600 : 500, color: "#101828", display: "flex", alignItems: "center", gap: 4 }}>
                          <span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{a.name}</span>
                          {isYou && <span style={{ fontSize: 10, color: "#98A2B3", fontWeight: 500 }}>(You)</span>}
                        </div>
                        <div style={{ fontSize: 10, color: "#98A2B3" }}>{a.role} · {a.status}</div>
                      </div>
                      {active && <I.check size={13} stroke="#004CE6" />}
                    </button>);

              })}
                {filteredAgents.length === 0 &&
              <div style={{ padding: 16, textAlign: "center", color: "#98A2B3", fontSize: 12 }}>
                    No agents match
                  </div>
              }
              </div>
            </div>
          }
        </div>
        }

        {/* 3. Close / Closed — the same control both ways round. Once closed it
            reads its new state and offers the reverse action on hover. */}
        {show("close") && (canEdit ?
        <button
          onClick={() => {
            // Read the state before the toggle — the marker records the action
            // just taken, not the one the button will offer next.
            appendEvent(thread.id, {
              event: isClosed ? "reopened" : "closed",
              actor: currentUser ? currentUser.name : "You"
            });
            if (onCloseConversation) onCloseConversation();
          }}
          className={isClosed ? "th-close-btn is-closed" : "th-close-btn"}
          data-jc-tip={isClosed ? "Open this conversation" : "Mark as closed"}
          aria-pressed={isClosed}
          style={{
            display: "inline-flex", alignItems: "center", gap: 6, padding: "7px 10px",
            cursor: "pointer",
            whiteSpace: "nowrap", flexShrink: 0, borderRadius: "8px", fontWeight: "500",
            fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontSize: "13px",
            background: isClosed ? "#ECFDF3" : "#FFFFFF",
            border: `1px solid ${isClosed ? "#A6F4C5" : "#E4E7EC"}`,
            color: isClosed ? "#067647" : "rgb(16, 24, 40)"
          }} data-comment-anchor="35f0dd8e6c-button-316-9">
            <I.checkCircle size={13} stroke={isClosed ? "#067647" : "#101828"} />
            {isClosed ? "Closed" : "Close"}
          </button> :

        <div style={{
          display: "inline-flex", alignItems: "center", gap: 5, padding: "5px 9px", borderRadius: 500,
          border: "1px solid #E4E7EC", background: "#F9FAFB", color: "#667085", fontSize: 14, fontWeight: 500, flexShrink: 0
        }}>
            <I.lock size={11} stroke="#667085" /> View only
          </div>
        )}

        {/* 4. The 3-dot menu — or, where a host asks for it, the control that
            shows and hides the contact sheet beside this column. One slot:
            both answer "what else can be on screen", and a host that wants
            the sheet toggle has no use for mark-as-unread and delete. */}
        {ch.contactToggle ?
        <button
          onClick={onToggleContact}
          data-jc-tip={showContact ? "Hide contact info" : "Show contact info"}
          aria-pressed={!!showContact}
          aria-label={showContact ? "Hide contact info" : "Show contact info"}
          style={{
            ...headerIconBtn,
            background: showContact ? "#EFF4FF" : "#FFFFFF",
            borderColor: showContact ? "#B2CCFF" : "#E4E7EC", borderRadius: "8px"
          }}>
          <I.contact size={15} stroke={showContact ? "#004CE6" : "#667085"} />
        </button> :
        show("menu") &&
        <div ref={menuRef} style={{ position: "relative" }}>
          <button
            onClick={() => setMenuOpen((o) => !o)}
            title="More"
            style={{
              ...headerIconBtn,
              background: menuOpen ? "#F2F4F7" : "#FFFFFF",
              borderColor: menuOpen ? "#D0D5DD" : "#E4E7EC", borderRadius: "8px"
            }}>
            <I.more size={15} stroke="#667085" />
          </button>
          {menuOpen &&
          <div style={{
            position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 20,
            minWidth: 220, background: "#FFFFFF", border: "1px solid #E4E7EC",
            borderRadius: 6, boxShadow: "0 8px 24px rgba(16,24,40,.08), 0 2px 6px rgba(16,24,40,.04)",
            padding: 4
          }}>
              {headerMenuItems(isUnread).map((item, i) => {
              if (item.divider) return (
                <div key={"d" + i} style={{ height: 1, background: "#E4E7EC", margin: "4px 0" }} />);

              const IconC = item.icon;
              return (
                <button key={item.id}
                onClick={() => {
                  setMenuOpen(false);
                  if (item.id === "markRead" || item.id === "markUnread") {
                    if (onToggleRead) onToggleRead(item.id === "markRead");
                  }
                  showToast(item.toast);
                }}
                style={{
                  width: "100%", display: "flex", alignItems: "center", gap: 10,
                  padding: "8px 10px", borderRadius: 4, border: "none",
                  background: "transparent", cursor: "pointer",
                  fontWeight: 500, color: item.destructive ? "#B42318" : "#344054",
                  textAlign: "left", fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontSize: "14px", height: "36px"
                }}
                onMouseEnter={(e) => e.currentTarget.style.background = item.destructive ? "#FEF3F2" : "#F9FAFB"}
                onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
                    <IconC size={14} stroke={item.destructive ? "#B42318" : "#667085"} />
                    {item.label}
                  </button>);

            })}
            </div>
          }
        </div>
        }
      </div>
    </div>);

};
const headerIconBtn = {
  width: 32, height: 32, display: "inline-flex", alignItems: "center", justifyContent: "center",
  borderRadius: 4, border: "1px solid #E4E7EC", background: "#FFFFFF", cursor: "pointer", padding: 0
};

// Thread event markers — what happened to the conversation, as opposed to what
// was said in it. Assignment, open/close and campaign membership all land here.
//
// `m.at` is the full timestamp; it is shown on hover through the shared
// [data-jc-tip] tooltip rather than printed on the marker, so the resting state
// stays to one quiet line. Styling lives in styles/event-marker.css.
const EVENT_ICON = {
  assigned: I.user,
  unassigned: I.userX,
  closed: I.checkCircle,
  reopened: I.refresh,
  campaign: I.megaphone
};

const eventBody = (m) => {
  const who = <span className="ev-who">{m.actor}</span>;
  switch (m.event) {
    case "assigned":return <>Conversation assigned to {who}</>;
    case "unassigned":return <>Conversation unassigned</>;
    case "closed":return <>Conversation closed by {who}</>;
    case "reopened":return <>Conversation reopened by {who}</>;
    case "campaign":return <>Contact added to campaign <span className="ev-camp">{m.campaign}</span></>;
    default:return null;}

};

// Markers raised by a live action carry a real timestamp, in the same shape as
// the seeded ones ("Apr 28, 2026 7:33 PM").
const _EV_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const eventStamp = () => {
  const d = new Date();
  const mins = String(d.getMinutes()).padStart(2, "0");
  const ampm = d.getHours() >= 12 ? "PM" : "AM";
  const h = d.getHours() % 12 || 12;
  return `${_EV_MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()} ${h}:${mins} ${ampm}`;
};

// Drops a marker into a thread through the same channel messages use, so it
// lands in the stream and scrolls into view like anything else.
const appendEvent = (threadId, ev) => {
  if (!threadId) return;
  window.dispatchEvent(new CustomEvent("convo:appendMessage", {
    detail: { threadId, message: { kind: "event", at: eventStamp(), ...ev } }
  }));
};

const EventMarker = ({ m }) => {
  const Ic = EVENT_ICON[m.event] || I.info;
  const body = eventBody(m);
  if (!body) return null;
  return (
    <div className="ev-row">
      <span className="ev-marker" tabIndex={0}
      data-jc-tip={m.at || ""} data-jc-tip-above="">
        <span className="ev-ic"><Ic size={14} stroke="currentColor" /></span>
        <span>{body}</span>
      </span>
    </div>);

};

const DateChip = ({ label }) =>
<div style={{ display: "flex", alignItems: "center", gap: 8, margin: "20px 0 4px" }}>
    <div style={{ flex: 1, height: 1, background: "#E4E7EC" }} />
    <span style={{ fontSize: 11, color: "#667085", fontWeight: 500, padding: "2px 10px", background: "#F2F4F7", borderRadius: 500 }}>{label}</span>
    <div style={{ flex: 1, height: 1, background: "#E4E7EC" }} />
  </div>;


// Renders message text with real URLs turned into links — brand blue, with a
// dotted underline on hover. Validated with the URL constructor so stray words
// like "e.g." or "3.5" don't become links.
const URL_RE = /((?:https?:\/\/|www\.)[^\s<>()]+[^\s<>().,!?;:'"])/gi;
const LinkedText = ({ text }) => {
  if (!text || typeof text !== "string") return text || null;
  const parts = text.split(URL_RE);
  return (
    <>
      {parts.map((part, i) => {
        if (i % 2 === 0) return part;
        const href = part.startsWith("http") ? part : `https://${part}`;
        let valid = false;
        try {const u = new URL(href);valid = !!u.hostname && u.hostname.includes(".");} catch (_) {valid = false;}
        if (!valid) return part;
        return (
          <a
            key={i}
            href={href}
            target="_blank"
            rel="noreferrer"
            style={{ color: "#004CE6", textDecoration: "none", wordBreak: "break-word" }}
            onMouseEnter={(e) => {e.currentTarget.style.textDecoration = "underline dotted";e.currentTarget.style.textUnderlineOffset = "2px";}}
            onMouseLeave={(e) => {e.currentTarget.style.textDecoration = "none";}}>
            {part}
          </a>);

      })}
    </>);

};

// The channel mark that opens the bubble footer.
const MsgChanIcon = ({ isWA }) => isWA ?
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
    <path d="M12 2a10 10 0 00-8.6 15.1L2 22l5.05-1.35A10 10 0 1012 2zm0 2a8 8 0 11-4.1 14.86l-.32-.19-2.68.72.72-2.6-.2-.33A8 8 0 0112 4zm-3.2 4.1c-.16 0-.42.06-.64.3-.22.24-.85.83-.85 2.02s.87 2.34.99 2.5c.12.16 1.7 2.7 4.2 3.68 2.08.82 2.5.66 2.95.62.45-.04 1.46-.6 1.66-1.17.2-.58.2-1.07.14-1.17-.06-.1-.22-.16-.46-.28-.24-.12-1.46-.72-1.68-.8-.22-.08-.39-.12-.55.12-.16.24-.63.8-.77.96-.14.16-.28.18-.52.06-.24-.12-1.04-.38-1.98-1.22-.73-.65-1.23-1.46-1.37-1.7-.14-.24-.02-.37.1-.49.11-.11.24-.28.36-.42.12-.14.16-.24.24-.4.08-.16.04-.3-.02-.42-.06-.12-.54-1.33-.74-1.82-.19-.47-.39-.4-.54-.41z" />
  </svg> :
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <path d="M20 14.5a2.5 2.5 0 01-2.5 2.5H8l-4 3V5.5A2.5 2.5 0 016.5 3h11A2.5 2.5 0 0120 5.5z" />
  </svg>;


// Where a message came from when nobody typed it: an automation, a campaign
// blast, or the scheduler. Raw SVGs rather than I.* because the shared Icon
// wrapper pins itself to 16px inline, which a stylesheet can't scale down.
const SRC_ICONS = {
  workflow:
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
  strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
    </svg>,
  campaign:
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
  strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M3 11v2a2 2 0 0 0 2 2h1l14 4V5L6 9H5a2 2 0 0 0-2 2z" />
      <path d="M11.5 19.4a3 3 0 0 1-5.6-1.5" />
    </svg>,
  scheduled:
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
  strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
    </svg>
};
const SRC_ARROW =
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <line x1="7" y1="17" x2="17" y2="7" /><polyline points="8 7 17 7 17 16" />
  </svg>;
// `nav` is the sidebar id the name links to. A source with no destination has
// no tip and no `nav`, and renders as plain text.
const SRC_META = {
  workflow: { label: "Workflow", tip: "View workflow", nav: "wf-voice" },
  campaign: { label: "Campaign", tip: "View campaign", nav: "wf-bulk" },
  // Nowhere to send a scheduled message — the thread is where it lives.
  scheduled: { label: "Scheduled" }
};

// The header strip above a source-tagged bubble: icon, type, then the thing it
// came from. A workflow or a campaign is somewhere you can go, so its name is a
// link out; a scheduled send has no destination — the thread is the only place
// it exists — so its stamp is plain text with nothing to click.
const MsgSource = ({ source, bg, ink }) => {
  const meta = SRC_META[source.type];
  if (!meta) return null;
  return (
    <div className="msg-src" style={{ "--msg-src-bg": bg, "--msg-src-ink": ink }}>
      <span className="msg-src-icon">{SRC_ICONS[source.type]}</span>
      <span className="msg-src-label">{meta.label}</span>
      <span className="msg-src-sep">·</span>
      {meta.tip ?
      <button type="button" className="msg-src-link" data-jc-tip={meta.tip} data-jc-tip-above
      onClick={() => {
        // Named listings, not the individual record: neither Campaigns nor
        // Workflows has a detail page in the prototype yet.
        if (!(window.jcGoToPage && window.jcGoToPage(meta.nav))) {
          showToast(`${meta.label} · ${source.name}`);
        }
      }}>
          <span className="msg-src-name">{source.name}</span>
          {SRC_ARROW}
        </button> :
      <span className="msg-src-name">{source.name}</span>
      }
    </div>);

};

const Bubble = ({ m, user, thread }) => {
  const out = m.dir === "out";
  const isWA = m.channel === "whatsapp";
  const initials = out ? user.initials : (m.author || "?").split(" ").map((w) => w[0]).join("").slice(0, 2);
  // WhatsApp messages get a green-tinted bubble + a small brand mark in the
  // time row so the channel is legible without a heavy channel header.
  // SMS keeps the original light blue (out) / light grey (in) combo.
  const bubbleBg = isWA ? out ? "#E9F4E0" : "#F2F6EF" : out ? "#EAF2FE" : "#F4F5F8";
  // Meta ink is tuned per tint so the line name sits on the bubble rather than
  // floating on it. The body colour and timestamp are left alone.
  const metaInk = isWA ? out ? "#4E7141" : "#5B7C4F" : out ? "#4B6490" : "#646F7E";
  // Source header tints — one step deeper than the body so the strip reads as
  // part of the same bubble rather than a card stuck on top of it.
  const srcBg = isWA ? out ? "#DAECC6" : "#E4EEDB" : out ? "#DAE8FC" : "#E7EAF0";
  const srcInk = isWA ? out ? "#3E5F31" : "#4A6A3E" : out ? "#39548A" : "#4A5566";
  const isScheduled = !!m.scheduled || !!(m.source && m.source.type === "scheduled");

  // Which number the message went out on / came in on.
  const lineName = m.line || thread && thread.line || "";
  const lineNumber = (window.PHONE_LINES || []).find((l) => l.name === lineName);
  const channelLabel = isWA ? "WhatsApp" : "SMS";
  const tip = `${out ? "Sent over" : "Received over"} ${channelLabel}` + (
  lineNumber ? ` ${out ? "from" : "on"} ${lineNumber.phone}` : "");
  return (
    <div style={{ display: "flex", gap: 8, margin: "10px 0", flexDirection: out ? "row-reverse" : "row", alignItems: "flex-end" }}>
      <div style={{
        width: 26, height: 26, borderRadius: 500, flexShrink: 0,
        background: out ? "#EEF4FF" : "#EEF1F6",
        color: out ? "#3538CD" : "#475467",
        display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 600,
        letterSpacing: ".02em"
      }}>
        {initials}
      </div>
      <div style={{ maxWidth: "72%", display: "flex", flexDirection: "column", alignItems: out ? "flex-end" : "flex-start" }}>
        <div style={{
          padding: "10px 14px 8px", borderRadius: 14,
          borderBottomRightRadius: out ? 4 : 14, borderBottomLeftRadius: out ? 14 : 4,
          background: bubbleBg,
          color: "#101828",
          fontSize: 14, lineHeight: 1.5, whiteSpace: "pre-wrap",
          display: "flex", flexDirection: "column", gap: 4
        }}>
          {m.source && <MsgSource source={m.source} bg={srcBg} ink={srcInk} />}
          <span><LinkedText text={m.text} /></span>
          {m.attachment &&
          <a href={m.attachment.url || "#"} onClick={(e) => {if (!m.attachment.url) e.preventDefault();}} style={{
            display: "flex", alignItems: "center", gap: 10, textDecoration: "none",
            background: "#FFFFFF", border: "1px solid #D0D5DD", borderRadius: 10,
            padding: "8px 10px", marginTop: 2, width: 250, height: 50, boxSizing: "border-box"
          }}>
            <span style={{
              width: 28, height: 34, borderRadius: 4, flexShrink: 0,
              background: "#FEF3F2", border: "1px solid #FECDCA", color: "#B42318",
              display: "inline-flex", alignItems: "center", justifyContent: "center",
              fontSize: 9, fontWeight: 700, letterSpacing: ".04em"
            }}>PDF</span>
            <span style={{ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: 2 }}>
              <span style={{
                fontSize: 12, fontWeight: 600, color: "#101828", lineHeight: 1.35,
                overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"
              }}>{m.attachment.name}</span>
              <span style={{ fontSize: 11, color: "#667085" }}>{m.attachment.size}</span>
            </span>
          </a>
          }
          {/* Footer — channel + phone line on the left, timestamp on the right.
              The timestamp keeps exactly the treatment it had. */}
          <div className="msg-meta" style={{ "--msg-muted": metaInk }}>
            <span className="msg-meta-left" data-jc-tip={tip} tabIndex={0}>
              <span className="msg-chan"><MsgChanIcon isWA={isWA} /></span>
              {lineName &&
              <>
                  <span className="msg-sep">|</span>
                  <span className="msg-line">{lineName}</span>
                </>
              }
            </span>
            {/* A scheduled message hasn't been sent, so there is no send time
                to stamp and nothing to tick — the header strip already carries
                the only time it has. */}
            {!isScheduled &&
            <span style={{
              display: "inline-flex", alignItems: "center", gap: 4,
              fontSize: 11, color: "#98A2B3", fontWeight: 500, flexShrink: 0
            }}>
              {m.time}
              {out && <I.checkDouble size={13} stroke={isWA ? "#25D366" : "#98A2B3"} />}
            </span>
            }
          </div>
        </div>
      </div>
    </div>);

};

// Topic auto-extraction for connected calls. Picks 2–4 topics deterministically
// per call message — keyword-matches against the summary first, then falls back
// to a hash of m.id so each card has a stable, varied tag cloud.
const CALL_TOPIC_LIBRARY = [
{ label: "Pricing", match: /pric|cost|quote|tier|plan/i },
{ label: "Renewal", match: /renew|contract|term/i },
{ label: "Cancellation", match: /cancel|churn/i },
{ label: "Billing issue", match: /bill|invoic|charge|refund/i },
{ label: "Onboarding", match: /onboard|setup|get started|kick.?off/i },
{ label: "Integration help", match: /integrat|webhook|api|sso/i },
{ label: "Feature request", match: /feature|request|wishlist/i },
{ label: "Bug report", match: /bug|broken|error|issue/i },
{ label: "Demo request", match: /demo|walk.?through|trial/i },
{ label: "Competitor", match: /competitor|vendor|alternative|compar/i },
{ label: "Transcription", match: /transcrip|recording|note/i },
{ label: "Pooled minutes", match: /pooled|minute|usage/i },
{ label: "CTO review", match: /cto|tech review/i },
{ label: "Procurement", match: /procurement|legal|sign.?off/i }];

const pickCallTopics = (m) => {
  const text = `${m.summary || ""} ${m.title || ""}`;
  const matched = CALL_TOPIC_LIBRARY.
  filter((t) => t.match.test(text)).
  map((t) => t.label);
  if (matched.length >= 2) return matched.slice(0, 4);
  // Fall back to deterministic pick from id so every card still gets a cloud
  const id = String(m.id || m.time || "");
  let seed = 0;
  for (let i = 0; i < id.length; i++) seed = seed * 31 + id.charCodeAt(i) >>> 0;
  const pool = ["Pricing", "Renewal", "Onboarding", "Integration help", "Feature request",
  "Demo request", "Billing issue", "Competitor", "Transcription"];
  const out = new Set(matched);
  let i = 0;
  while (out.size < 3 && i < 12) {
    out.add(pool[(seed + i) % pool.length]);
    i++;
  }
  return [...out].slice(0, 4);
};

// ----- Call card with alignment, collapsible details + AI summary -----
// ----- Call card — three rows, no expanded drawer -----
// Ported from the Layout B prototype: row 1 says what happened, row 2 whether
// you can hear it and where the record lives, row 3 what was said. Colours and
// type sizes are mapped onto this project's palette and scale.
const CC_PHONE_PATH = "M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .4 1.9.7 2.8a2 2 0 0 1-.5 2.1L8.1 9.9a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.8.7a2 2 0 0 1 1.7 2Z";

const CallGlyph = ({ variant }) =>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <path d={CC_PHONE_PATH} />
    {variant === "in" && <><polyline points="16 2 16 8 22 8" /><line x1="23" y1="1" x2="16" y2="8" /></>}
    {variant === "out" && <><polyline points="23 7 23 1 17 1" /><line x1="16" y1="8" x2="23" y2="1" /></>}
    {variant === "missed" && <><line x1="23" y1="1" x2="17" y2="7" /><line x1="17" y1="1" x2="23" y2="7" /></>}
    {variant === "fwd" && <><polyline points="19 1 23 5 19 9" /><line x1="15" y1="5" x2="23" y2="5" /></>}
  </svg>;


const CC_ICONS = {
  play: <svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 1.2v7.6a.4.4 0 0 0 .61.34l6.1-3.8a.4.4 0 0 0 0-.68L2.61.86A.4.4 0 0 0 2 1.2Z" /></svg>,
  pause: <svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><rect x="2" y="1.5" width="2.2" height="7" rx=".7" /><rect x="5.8" y="1.5" width="2.2" height="7" rx=".7" /></svg>,
  chev: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9" /></svg>,
  ext: <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" /></svg>,
  bldg: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="4" y="2" width="16" height="20" rx="2" /><line x1="9" y1="7" x2="15" y2="7" /><line x1="9" y1="12" x2="15" y2="12" /><line x1="9" y1="17" x2="13" y2="17" /></svg>,
  spark: <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.5 13.7 8 19 9.8 13.7 11.6 12 17l-1.7-5.4L5 9.8 10.3 8 12 2.5ZM18.5 14l.8 2.4 2.4.8-2.4.8-.8 2.4-.8-2.4-2.4-.8 2.4-.8.8-2.4Z" /></svg>,
  lock: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>,
  doc: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><polyline points="14 2 14 8 20 8" /><line x1="8" y1="13" x2="15" y2="13" /><line x1="8" y1="17" x2="13" y2="17" /></svg>,
  dots: <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.7" /><circle cx="12" cy="12" r="1.7" /><circle cx="19" cy="12" r="1.7" /></svg>
};

// Only one recording plays at a time across the whole thread — starting a
// second stops the first, which the source prototype handled with a module
// global. Same idea here, via a tiny subscription.
const ccPlayers = new Set();
const ccStopOthers = (me) => ccPlayers.forEach((stop) => stop !== me && stop());

const CallCard = ({ m, thread, user }) => {
  const missed = m.status === "missed";
  const transferred = m.status === "transferred" || !!m.transferTo;
  /* A call that has not finished. It is the outbound card with three things
     taken out, because none of them exist yet: a length, a recording to play
     and a summary of what was said. What it gains is a clock that runs. The
     campaign queue puts one of these at the foot of the thread for the call
     the agent is on while they read it. */
  const ongoing = m.status === "ongoing";
  const out = m.dir === "out";

  // The glyph carries the state at full strength; the duration badge echoes it
  // in the matching soft tint. The player stays neutral either way.
  const tone = transferred ?
  { accent: "#7A5AF8", bg: "#F4F3FF", fg: "#5925DC" } :
  missed ?
  { accent: "#D92D20", bg: "#FEF3F2", fg: "#B42318" } :
  ongoing ?
  { accent: "#12B76A", bg: "#ECFDF3", fg: "#067647" } :
  out ?
  { accent: "#004CE6", bg: "#EFF8FF", fg: "#175CD3" } :
  { accent: "#12B76A", bg: "#ECFDF3", fg: "#067647" };
  const accent = tone.accent;
  const variant = transferred ? "fwd" : missed ? "missed" : out ? "out" : "in";
  const kind = transferred ? "Transferred call" : missed ? "Missed call" :
  ongoing ? "Ongoing call" : out ? "Outbound call" : "Inbound call";

  /* The clock the card runs while the call does. It counts from the second
     the card was mounted rather than from a timestamp, so it agrees with the
     dock's own timer to within the tick it started on. */
  const [live, setLive] = useState(0);
  useEffect(() => {
    if (!ongoing) return;
    const id = setInterval(() => setLive((n) => n + 1), 1000);
    return () => clearInterval(id);
  }, [ongoing]);

  // The subtitle carries the outcome, so the status rarely repeats itself.
  const author = m.author || (user && user.name) || "—";
  const who = transferred ? `${author} → ${m.transferTo || "Priya Shah"}` :
  missed ? "No one answered" :
  // Same line the finished outbound card carries: the author is the agent, so
  // "on the line with You" was addressing them about themselves.
  ongoing ? `Dialled by ${author}` :
  out ? `Dialled by ${author}` :
  `${author} answered`;

  const [open, setOpen] = useState(false);
  const [playing, setPlaying] = useState(false);
  const [pos, setPos] = useState(0); // seconds
  const [speed, setSpeed] = useState(1);
  const [speedOpen, setSpeedOpen] = useState(false);
  const [speedAt, setSpeedAt] = useState({ x: 0, y: 0 });
  const trackRef = useRef(null);
  const speedRef = useRef(null);

  const totalSec = (() => {
    if (!m.duration) return 0;
    const [mm, ss] = String(m.duration).split(":").map(Number);
    return Math.max(1, (mm || 0) * 60 + (ss || 0));
  })();
  const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.round(s) % 60).padStart(2, "0")}`;
  // Normalise "03:58" to "3:58" so the badge and playhead read the same way.
  const durLabel = m.duration ? fmt(totalSec) : null;

  // One time value only: total at rest, playhead while playing.
  const timeLabel = playing || pos > 0 ? fmt(pos) : durLabel || "0:00";

  const stop = () => {setPlaying(false);setPos(0);};
  useEffect(() => {
    ccPlayers.add(stop);
    return () => ccPlayers.delete(stop);
  }, []);

  useEffect(() => {
    if (!playing) return;
    const id = setInterval(() => {
      setPos((p) => {
        const next = p + totalSec / 180 * speed;
        if (next >= totalSec) {setPlaying(false);return 0;}
        return next;
      });
    }, 100);
    return () => clearInterval(id);
  }, [playing, speed, totalSec]);

  useEffect(() => {
    if (!speedOpen) return;
    const away = (e) => {if (speedRef.current && !speedRef.current.contains(e.target)) setSpeedOpen(false);};
    const esc = (e) => {if (e.key === "Escape") setSpeedOpen(false);};
    document.addEventListener("mousedown", away);
    document.addEventListener("keydown", esc);
    return () => {
      document.removeEventListener("mousedown", away);
      document.removeEventListener("keydown", esc);
    };
  }, [speedOpen]);

  const togglePlay = () => {
    if (playing) {setPlaying(false);return;}
    ccStopOthers(stop);
    setPlaying(true);
  };
  const seek = (e) => {
    if (!trackRef.current) return;
    const r = trackRef.current.getBoundingClientRect();
    setPos(Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)) * totalSec);
  };

  const pct = totalSec ? Math.min(100, pos / totalSec * 100) : 0;
  const lines = m.lines || [thread && thread.line].filter(Boolean);
  // Deterministic so a card keeps the same reference between renders.
  const logId = m.log || (() => {
    const src = `${thread && thread.id || ""}${m.time || ""}${m.duration || ""}`;
    let s = 0;
    for (let i = 0; i < src.length; i++) s = s * 31 + src.charCodeAt(i) >>> 0;
    return 600000 + s % 400000;
  })();

  const topics = pickCallTopics(m);
  const brief = m.summary || (missed ?
  "No voicemail left. Nothing was spoken on this call." :
  "No summary generated for this call.");
  // Free plan gets one faded line and no topics.
  const freePlan = !!(window.__TWEAKS && window.__TWEAKS.callSummaryPlan === "free");

  // Side and avatar follow whoever initiated the call, matching the message
  // bubbles either side of it.
  const initials = out ?
  user && user.initials || "ME" :
  thread && thread.avatar || (m.author || "?").split(" ").map((w) => w[0]).join("").slice(0, 2);

  return (
    <div className="cc-wrap" style={{ flexDirection: out ? "row-reverse" : "row" }}>
      <div className="cc-avatar" style={{
        background: out ? "#EEF4FF" : "#EEF1F6",
        color: out ? "#3538CD" : "#475467"
      }}>{initials}</div>
    <div className="cc" style={{ "--cc-accent": accent, "--cc-tint-bg": tone.bg, "--cc-tint-fg": tone.fg }}>
      {/* row 1 — what happened, how long, who took it */}
      <div className="cc-r1">
        <span className="cc-glyph"><CallGlyph variant={variant} /></span>
        <span className="cc-txt">
          <div className="cc-ttlrow">
            <span className="cc-ttl">{kind}</span>
            {/* Ring time on a missed call stays in the log entry, not here —
                nothing was spoken, so there's no length to report. An ongoing
                call has no final length either; it has a clock. */}
            {ongoing ?
            <span className="cc-dur cc-live"><i className="cc-live-dot" />{fmt(live)}</span> :
            durLabel && !missed && <span className="cc-dur">{durLabel}</span>}
          </div>
          <div className="cc-who">{who}</div>
        </span>
        <span className="cc-time">{m.time}</span>
      </div>

      {/* row 2 — playback, or a plain rule when there's nothing to hear */}
      <div className="cc-r2">
        {missed || ongoing ?
        <div className="cc-divider" /> :

        <div className="cc-shell">
            <div className="cc-player">
              <button className="cc-pp" onClick={togglePlay}
              aria-label={playing ? "Pause recording" : "Play recording"}>
                {playing ? CC_ICONS.pause : CC_ICONS.play}
              </button>
              <div className="cc-track" ref={trackRef} onClick={seek}
              role="slider" tabIndex={0} aria-label="Seek recording"
              aria-valuemin={0} aria-valuemax={totalSec} aria-valuenow={Math.round(pos)}>
                <div className="cc-rail" />
                <div className="cc-fill" style={{ width: `${pct}%` }} />
                <div className="cc-knob" style={{ left: `${pct}%` }} />
              </div>
              <span className="cc-tc">{timeLabel}</span>
              <button className="cc-icnbtn" data-jc-tip="View transcription" aria-label="View transcription"
              onClick={() => showToast("Transcription — coming soon")}>
                {CC_ICONS.doc}
              </button>
              <span className="cc-speed" ref={speedRef}>
                <button className="cc-rate" aria-haspopup="true" aria-expanded={speedOpen}
                onClick={(e) => {
                  const r = e.currentTarget.getBoundingClientRect();
                  setSpeedAt({ x: r.left + r.width / 2, y: r.top });
                  setSpeedOpen((o) => !o);
                }}>{speed}×</button>
                {/* Fixed to the viewport: the card clips its own overflow, so an
                    absolutely-positioned menu would be cut off inside it. */}
                {speedOpen &&
                <span className="cc-speed-menu" role="menu"
                style={{ left: speedAt.x, top: speedAt.y - 8 }}>
                    {[0.5, 0.75, 1, 1.25, 2].map((r) =>
                  <button key={r} role="menuitemradio" aria-checked={r === speed}
                  onClick={() => {setSpeed(r);setSpeedOpen(false);}}>{r}×</button>
                  )}
                  </span>
                }
              </span>
              <button className="cc-icnbtn" aria-label="Recording options"
              onClick={() => showToast("Recording options — coming soon")}>
                {CC_ICONS.dots}
              </button>
            </div>
          </div>
        }

        {/* where the record lives — muted until hover, which is the only
            signal that they're clickable */}
        <div className="cc-below">
          <span className="cc-lines">
            {lines.map((l, i) =>
            <React.Fragment key={l}>
                {i > 0 && <span className="cc-to">→</span>}
                <button className="cc-meta" data-jc-tip="View in Phone Numbers"
                onClick={() => {
                  if (!(window.jcOpenSettings && window.jcOpenSettings("phone-numbers"))) {
                    showToast(`Opening ${l}…`);
                  }
                }}>
                  {CC_ICONS.bldg}{l}
                </button>
              </React.Fragment>
            )}
          </span>
          <button className="cc-meta" data-jc-tip="Open in Call Logs"
          onClick={() => {
            // The table speaks in its own directions; translate the card's.
            const dir = missed ? "miss" : m.dir === "in" ? "in" : "out";
            if (window.jcOpenCallLog) window.jcOpenCallLog(dir);
            else showToast(`Opening call log #${logId}…`);
          }}>
            Call log #{logId}{CC_ICONS.ext}
          </button>
        </div>
      </div>

      {/* row 3 — what was said, collapsed so a run of calls stays scannable.
          Nothing has been said yet on a call still running. */}
      {!ongoing &&
      <div className="cc-r3">
        <button className="cc-sumhead" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
          <span className="cc-spark">{CC_ICONS.spark}</span>
          Call summary
          <span className="cc-chev">{CC_ICONS.chev}</span>
        </button>
        {open &&
        <div className="cc-sumbody">
            {freePlan ?
          <>
                <div className="cc-fade"><p className="cc-sum">{brief}</p></div>
                <button className="cc-unlock" onClick={() => showToast("Upgrade to see full summaries")}>
                  {CC_ICONS.lock} Unlock the full summary
                </button>
              </> :

          <>
                <p className="cc-sum cc-sum-clamp">{brief}</p>
                {topics.length > 0 &&
            <div className="cc-topics">
                    {topics.map((t) => <span key={t} className="cc-chip">{t}</span>)}
                  </div>
            }
              </>
          }
          </div>
        }
      </div>
      }
    </div>
    </div>);

};

// ----- Voice note — chat-bubble shaped, with waveform + duration -----
const VoiceNote = ({ m, thread, user }) => {
  const out = m.dir === "out";
  const [open, setOpen] = useState(false);
  const initials = out ?
  user && user.initials || "ME" :
  thread && thread.avatar || (m.author || "?").split(" ").map((w) => w[0]).join("").slice(0, 2);
  const avatarBg = out ? "#EEF4FF" : "#EEF1F6";
  const avatarFg = out ? "#3538CD" : "#475467";
  const bubbleBg = out ? "#EAF2FE" : "#F4F5F8";
  const accent = out ? "#3538CD" : "#475467";

  // Deterministic waveform bars from id/time so it stays stable across renders.
  const seedSrc = String(m.time || m.author || "v") + (out ? "o" : "i");
  let s = 0;for (let i = 0; i < seedSrc.length; i++) s = s * 31 + seedSrc.charCodeAt(i) >>> 0;
  const bars = Array.from({ length: 28 }, (_, i) => {
    s = s * 1664525 + 1013904223 >>> 0;
    return 4 + s % 16; // 4–19px
  });

  return (
    <div style={{
      display: "flex", gap: 8, margin: "10px 0",
      flexDirection: out ? "row-reverse" : "row", alignItems: "flex-end"
    }}>
      <div style={{
        width: 26, height: 26, borderRadius: 500, flexShrink: 0,
        background: avatarBg, color: avatarFg,
        display: "flex", alignItems: "center", justifyContent: "center",
        fontSize: 10, fontWeight: 600, letterSpacing: ".02em"
      }}>
        {initials}
      </div>
      <div style={{ maxWidth: "72%", display: "flex", flexDirection: "column", alignItems: out ? "flex-end" : "flex-start", gap: 4 }}>
        <div style={{
          padding: "8px 10px 8px 8px", borderRadius: 14,
          borderBottomRightRadius: out ? 4 : 14, borderBottomLeftRadius: out ? 14 : 4,
          background: bubbleBg, display: "flex", alignItems: "center", gap: 10, minWidth: 220
        }}>
          <button
            onClick={() => setOpen((o) => !o)}
            title="Play voice note"
            style={{
              width: 32, height: 32, borderRadius: 500, flexShrink: 0,
              border: "none", background: accent, color: "#FFFFFF",
              display: "inline-flex", alignItems: "center", justifyContent: "center",
              cursor: "pointer", padding: 0
            }}>
            <I.play size={13} stroke="#FFFFFF" />
          </button>
          <div style={{ display: "flex", alignItems: "center", gap: 2, height: 24, flex: 1 }}>
            {bars.map((h, i) =>
            <span key={i} style={{
              display: "inline-block", width: 2, height: h, borderRadius: 2,
              background: accent, opacity: 0.55
            }} />
            )}
          </div>
          <span style={{
            fontSize: 11, color: "#667085", fontWeight: 500, fontVariantNumeric: "tabular-nums",
            display: "inline-flex", alignItems: "center", gap: 4
          }}>
            <I.mic size={10} stroke="#667085" /> {m.duration}
          </span>
        </div>
        {m.transcript &&
        <button
          onClick={() => setOpen((o) => !o)}
          style={{
            alignSelf: out ? "flex-end" : "flex-start",
            border: "none", background: "transparent", padding: "0 4px",
            fontSize: 11, color: "#667085", fontFamily: "inherit", cursor: "pointer",
            display: "inline-flex", alignItems: "center", gap: 4
          }}>
            <I.sparkle size={10} stroke="#667085" />
            {open ? "Hide transcript" : "Show transcript"}
          </button>
        }
        {open && m.transcript &&
        <div style={{
          maxWidth: "100%", padding: "8px 12px", borderRadius: 8,
          background: "#FFFFFF", border: "1px dashed #E4E7EC",
          fontSize: 12, color: "#475467", lineHeight: 1.5, fontStyle: "italic"
        }}>
            "{m.transcript}"
          </div>
        }
        <span style={{
          fontSize: 11, color: "#98A2B3", fontWeight: 500,
          padding: "0 4px"
        }}>{m.time}</span>
      </div>
    </div>);

};

const NoteCard = ({ m, user }) => {
  // Notes sit on the right like any outbound bubble — they're written by the
  // team, not the contact. The head names the author; the footer says who can
  // see it.
  const mine = m.author === user.name;
  const author = mine ? user.name || "You" : m.author || "Teammate";
  const initials = (author || "?").split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase();
  return (
    <div style={{ display: "flex", gap: 8, margin: "12px 0", flexDirection: "row-reverse", alignItems: "flex-end" }}>
      {/* Same avatar treatment as every other bubble — a note is still a
          message from the team, not a different species. */}
      <div style={{
        width: 26, height: 26, borderRadius: 500, flexShrink: 0,
        background: "#EEF4FF", color: "#3538CD",
        display: "flex", alignItems: "center", justifyContent: "center",
        fontSize: 10, fontWeight: 600, letterSpacing: ".02em"
      }}>{initials}</div>
      <div style={{
        maxWidth: "64%", background: "#FFFAEB",
        border: "1px solid #FEDF89",
        borderRadius: 14, borderBottomRightRadius: 4,
        padding: "8px 12px 7px", display: "flex", flexDirection: "column"
      }}>
        <div style={{
          fontSize: 12, fontWeight: 600, letterSpacing: ".015em",
          color: "#B54708", marginBottom: 3
        }}>{author}</div>
        {/* Body matches the SMS bubble exactly — same size, same leading. */}
        <div style={{ fontSize: 14, color: "#344054", lineHeight: 1.5, whiteSpace: "pre-wrap" }}>
          <LinkedText text={m.text} />
        </div>
        <div className="msg-meta msg-meta--note" style={{ "--msg-muted": "#B54708" }}>
          <span className="msg-meta-left" data-jc-tip="Internal — visible to your team only" tabIndex={0}>
            <span className="msg-chan">
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
              strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z" />
                <path d="M14 3v5h5" />
              </svg>
            </span>
            <span className="msg-line" style={{ marginLeft: 5 }}>Internal Note</span>
          </span>
          <span style={{ fontSize: 11, color: "#B54708", fontWeight: 500, flexShrink: 0 }}>{m.time}</span>
        </div>
      </div>
    </div>);

};

// ----- From-line pop-out picker -----
const FromLinePicker = ({ thread }) => {
  const lines = window.PHONE_LINES || [];
  const initial = lines.find((l) => l.name === thread.line) || lines[0] || { name: thread.line, phone: "", flag: "🇺🇸" };
  const [selected, setSelected] = useState(initial);
  const [open, setOpen] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    if (!open) return;
    const close = (e) => {if (ref.current && !ref.current.contains(e.target)) setOpen(false);};
    document.addEventListener("mousedown", close);
    return () => document.removeEventListener("mousedown", close);
  }, [open]);

  // Re-sync when thread changes
  useEffect(() => {
    const next = lines.find((l) => l.name === thread.line);
    if (next) setSelected(next);
  }, [thread.id]);

  return (
    <div ref={ref} style={{ position: "relative", alignSelf: "center" }}>
      <button
        onClick={() => setOpen((o) => !o)}
        style={{
          display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 8px", borderRadius: 4,
          border: `1px solid ${open ? "#D0D5DD" : "transparent"}`,
          background: open ? "#F9FAFB" : "transparent",
          cursor: "pointer", fontSize: 11, color: "#667085"
        }}>
        <span style={{ fontSize: "13px" }}>From:</span>
        <span style={{ fontSize: "13px" }}>{selected.flag}</span>
        <span style={{ color: "#101828", fontWeight: 500, fontSize: "13px" }}>{selected.name}</span>
        <I.chevDown size={11} stroke="#667085" />
      </button>
      {open &&
      <div style={{
        position: "absolute", bottom: "calc(100% + 6px)", right: 0, zIndex: 25,
        width: 280, background: "#FFFFFF", border: "1px solid #E4E7EC", borderRadius: 6,
        boxShadow: "0 8px 24px rgba(16,24,40,.08), 0 2px 6px rgba(16,24,40,.04)",
        padding: 4, display: "flex", flexDirection: "column"
      }}>
          <div style={{
          padding: "8px 10px 6px", fontSize: 10, fontWeight: 600, color: "#667085",
          textTransform: "uppercase", letterSpacing: ".04em"
        }}>Send from</div>
          <div style={{
          // Show max 4 rows at a time (each row ~48px), then scroll
          maxHeight: 48 * 4, overflowY: "auto"
        }}>
            {lines.map((l) => {
            const active = l.id === selected.id;
            return (
              <button key={l.id}
              onClick={() => {setSelected(l);setOpen(false);}}
              style={{
                width: "100%", display: "flex", alignItems: "center", gap: 10,
                padding: "8px 10px", borderRadius: 4, border: "none",
                background: active ? "#EFF8FF" : "transparent", cursor: "pointer",
                textAlign: "left"
              }}
              onMouseEnter={(e) => {if (!active) e.currentTarget.style.background = "#F9FAFB";}}
              onMouseLeave={(e) => {if (!active) e.currentTarget.style.background = "transparent";}}>
                  <span style={{ fontSize: 16, lineHeight: 1 }}>{l.flag}</span>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 13, fontWeight: 500, color: "#101828", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{l.name}</div>
                    <div style={{ fontSize: 11, color: "#667085" }}>{l.phone}</div>
                  </div>
                  {active && <I.check size={13} stroke="#004CE6" />}
                </button>);

          })}
          </div>
        </div>
      }
    </div>);

};

const Composer = ({ permission, thread, persona }) => {
  // tab: null (idle — neither Message nor Note selected, primary Call shown)
  //      | "message" | "note"
  const [tab, setTab] = useState(null);
  // Sub-channel for the Message tab
  const [msgChannel, setMsgChannel] = useState("sms");
  const [msgOpen, setMsgOpen] = useState(false);
  const msgRef = useRef(null);
  const [text, setText] = useState("");
  const canEdit = permission === "full";
  const canNote = canEdit || persona === "admin";

  // AI Summary widget visibility — when the user dismisses the floating widget
  // via its "x", the composer's sparkle button morphs into an "AI Summary"
  // pill so they can bring it back.
  const [aiSummaryDismissed, setAiSummaryDismissed] = useState(false);
  useEffect(() => {
    const onState = (e) => setAiSummaryDismissed(!!(e.detail && e.detail.dismissed));
    window.addEventListener("convo:aiSummaryDismissed", onState);
    return () => window.removeEventListener("convo:aiSummaryDismissed", onState);
  }, []);
  // Reset on thread change (the widget itself resets, so this stays in sync)
  useEffect(() => {setAiSummaryDismissed(false);}, [thread.id]);

  // Listen for AI-generated draft injections from the conversation summary widget
  useEffect(() => {
    const onDraft = (e) => {
      const { text: draftText, channel } = e.detail || {};
      if (!draftText) return;
      if (channel === "note") {
        if (!canNote) return;
        setTab("note");
      } else {
        if (!canEdit) return;
        setTab("message");
        if (channel === "sms" || channel === "whatsapp" || channel === "email") {
          setMsgChannel(channel);
        }
      }
      setText(draftText);
      if (window.showAppToast) window.showAppToast("Draft ready — review before sending");
    };
    window.addEventListener("convo:draft", onDraft);
    return () => window.removeEventListener("convo:draft", onDraft);
  }, [canEdit, canNote]);

  useEffect(() => {
    if (!msgOpen) return;
    const onDown = (e) => {if (msgRef.current && !msgRef.current.contains(e.target)) setMsgOpen(false);};
    document.addEventListener("mousedown", onDown);
    return () => document.removeEventListener("mousedown", onDown);
  }, [msgOpen]);

  const MSG_CHANNELS = [
  { id: "sms", label: "SMS", icon: I.sms, desc: "Standard text message" },
  { id: "whatsapp", label: "WhatsApp", icon: I.whatsapp, desc: "Via WhatsApp Business" },
  { id: "email", label: "Email", icon: I.email, desc: "Send via connected mailbox" }];

  const currentMsg = MSG_CHANNELS.find((c) => c.id === msgChannel) || MSG_CHANNELS[0];

  if (!canEdit && !canNote) {
    return (
      <div style={{
        padding: 14, borderTop: "1px solid #E4E7EC", background: "#F9FAFB",
        display: "flex", alignItems: "center", gap: 8, justifyContent: "center", color: "#667085", fontSize: 13
      }}>
        <I.lock size={14} stroke="#667085" /> You have read-only access to this conversation
      </div>);

  }

  const isNote = tab === "note";
  const MsgIcon = currentMsg.icon;

  // (Call-only threads previously used a voice-only composer; we now
  //  show the same interaction container for every thread for consistency.)

  const isMessageTab = tab === "message";
  const showEditor = tab === "message" || tab === "note";

  // Pill button styles for the new layout
  const pillBase = {
    display: "inline-flex", alignItems: "center", gap: 6,
    height: 36, padding: "0 14px", borderRadius: 8, border: "1px solid #E4E7EC",
    background: "#FFFFFF", cursor: "pointer", fontSize: 13, fontWeight: 500,
    color: "#344054", fontFamily: "inherit", lineHeight: 1
  };
  const pillActive = {
    ...pillBase,
    background: "#EFF8FF", border: "1px solid #B2DDFF", color: "#004CE6", fontWeight: 600
  };
  const pillDisabled = {
    ...pillBase, color: "#D0D5DD", cursor: "not-allowed", borderColor: "#F2F4F7"
  };

  return (
    <div style={{ borderTop: "1px solid #E4E7EC", background: "#FFFFFF", flexShrink: 0, padding: "16px 20px" }}>
      {/* Top bar: from-line | divider | Message + chevron | Note | spacer | Call */}
      <div style={{
        display: "flex", alignItems: "center", gap: 10,
        border: "1px solid #E4E7EC", borderRadius: 10,
        padding: "8px 8px 8px 8px", background: "#FFFFFF"
      }}>
        {/* From-line picker (left) */}
        <FromLinePicker thread={thread} compact />

        {/* Vertical divider */}
        <div style={{ width: 1, height: 22, background: "#E4E7EC", flexShrink: 0 }} />

        {/* Message button — sub-channel chevron in same pill when active */}
        <div ref={msgRef} style={{ position: "relative", display: "inline-flex" }}>
          <button
            disabled={!canEdit}
            onClick={() => canEdit && setTab(isMessageTab ? null : "message")}
            style={{
              ...(isMessageTab ? pillActive : !canEdit ? pillDisabled : pillBase),
              paddingRight: isMessageTab ? 8 : 14,
              borderRadius: isMessageTab ? "8px 0 0 8px" : 8,
              borderRight: isMessageTab ? "none" : isMessageTab ? "1px solid #B2DDFF" : "1px solid #E4E7EC"
            }}>
            <MsgIcon size={14} stroke={!canEdit ? "#D0D5DD" : isMessageTab ? "#004CE6" : "#667085"} />
            {currentMsg.label}
          </button>
          {isMessageTab &&
          <button
            disabled={!canEdit}
            onClick={() => canEdit && setMsgOpen((o) => !o)}
            title="Choose channel"
            style={{
              ...pillActive,
              padding: "0 8px",
              borderRadius: "0 8px 8px 0",
              borderLeft: "1px solid #B2DDFF",
              marginLeft: -1
            }}>
            <I.chevDown size={13} stroke="#004CE6" style={{ transform: msgOpen ? "rotate(180deg)" : "none", transition: "transform .15s" }} />
          </button>
          }
          {msgOpen &&
          <div style={{
            position: "absolute", bottom: "calc(100% + 8px)", left: 0, zIndex: 30,
            minWidth: 220, background: "#FFFFFF", border: "1px solid #E4E7EC", borderRadius: 8,
            boxShadow: "0 8px 24px rgba(16,24,40,.10), 0 2px 6px rgba(16,24,40,.04)", padding: 6,
            display: "flex", flexDirection: "column", gap: 2
          }}>
              <div style={{
              padding: "6px 12px 8px", fontSize: 10, fontWeight: 600, color: "#98A2B3",
              textTransform: "uppercase", letterSpacing: ".06em"
            }}>Channel</div>
              {MSG_CHANNELS.map((c) => {
              const active = c.id === msgChannel;
              const IconC = c.icon;
              return (
                <button key={c.id}
                onClick={() => {setMsgChannel(c.id);setMsgOpen(false);}}
                style={{
                  width: "100%", height: 32, display: "flex", alignItems: "center", gap: 12,
                  padding: "10px 12px", borderRadius: 6, border: "none",
                  background: active ? "#EFF8FF" : "transparent",
                  cursor: "pointer", textAlign: "left", fontFamily: "inherit"
                }}
                onMouseEnter={(e) => {if (!active) e.currentTarget.style.background = "#F9FAFB";}}
                onMouseLeave={(e) => {if (!active) e.currentTarget.style.background = "transparent";}}>
                    <IconC size={16} stroke={active ? "#004CE6" : "#101828"} />
                    <div style={{ flex: 1, fontSize: 13, fontWeight: active ? 600 : 500, color: active ? "#004CE6" : "#101828" }}>
                      {c.label}
                    </div>
                    {active && <I.check size={14} stroke="#004CE6" />}
                  </button>);

            })}
            </div>
          }
        </div>

        {/* Note button */}
        <button
          disabled={!canNote}
          onClick={() => canNote && setTab(tab === "note" ? null : "note")}
          style={tab === "note" ?
          { ...pillBase, background: "#FFFAEB", border: "1px solid #FEDF89", color: "#B54708", fontWeight: 600 } :
          !canNote ? pillDisabled : pillBase}>
          <I.note size={14} stroke={!canNote ? "#D0D5DD" : tab === "note" ? "#B54708" : "#667085"} />
          Note
        </button>

        <div style={{ flex: 1 }} />

        {/* Call button — primary green, right-aligned */}
        <button
          disabled={!canEdit}
          onClick={() => canEdit && window.startCall && window.startCall(thread)}
          style={{ ...{
              display: "inline-flex", alignItems: "center", gap: 8,
              height: 36, padding: "0 18px", borderRadius: 8, border: "none",
              background: canEdit ? "#12B76A" : "#E4E7EC",
              color: canEdit ? "#FFFFFF" : "#98A2B3",
              fontSize: 14, fontWeight: 600, cursor: canEdit ? "pointer" : "not-allowed",
              fontFamily: "inherit", lineHeight: 1,
              boxShadow: canEdit ? "0 1px 2px rgba(16,24,40,0.08)" : "none"
            }, background: "rgb(32, 180, 133)" }}
          onMouseEnter={(e) => {if (canEdit) e.currentTarget.style.background = "#0E9F6E";}}
          onMouseLeave={(e) => {if (canEdit) e.currentTarget.style.background = "#12B76A";}}>
          <I.phone size={15} stroke={canEdit ? "#FFFFFF" : "#98A2B3"} />
          Call
        </button>
      </div>

      {/* Editor — only mounted when Message or Note tab is active */}
      <AnimatedMount show={showEditor} baseClass="composerEditor" duration={200} style={{
        marginTop: showEditor ? 10 : 0,
        border: `1px solid ${isNote ? "#FEF0C7" : "#E4E7EC"}`, borderRadius: 10,
        background: isNote ? "#FFFAEB" : "#FFFFFF",
        padding: "12px 14px", display: "flex", flexDirection: "column", gap: 10, minHeight: 96
      }}>
        <textarea
          autoFocus
          value={text} onChange={(e) => setText(e.target.value)}
          placeholder={isNote ? "Add an internal note (not visible to the customer)..." : `Write a ${currentMsg.label} message...`}
          style={{
            flex: 1, border: "none", outline: "none", resize: "none", background: "transparent",
            fontFamily: "inherit", fontSize: 14, color: "#101828", minHeight: 52
          }} />

        <div style={{
          display: "flex", alignItems: "center", gap: 6,
          paddingTop: 8, borderTop: `1px solid ${isNote ? "#FEF0C7" : "#F2F4F7"}`
        }}>
          <button style={composerIcon} title="Attach"><I.paperclip size={16} stroke="#667085" /></button>
          <button style={composerIcon} title="Emoji"><I.smile size={16} stroke="#667085" /></button>
          <button style={composerIcon} title="Template"><I.template size={16} stroke="#667085" /></button>
          <div style={{ flex: 1 }} />
          <span style={{ fontSize: 11, color: "#98A2B3" }}>{text.length}/1600</span>
          <button style={{
            display: "inline-flex", alignItems: "center", gap: 6, padding: "7px 16px", borderRadius: 6,
            border: "none", cursor: text.trim() ? "pointer" : "not-allowed",
            background: text.trim() ? isNote ? "#F79009" : "#5925DC" : "#F2F4F7",
            color: text.trim() ? "#FFFFFF" : "#98A2B3", fontSize: 13, fontWeight: 600,
            fontFamily: "inherit"
          }}>
            {isNote ? "Save note" : "Send"} <I.send size={13} stroke={text.trim() ? "#FFFFFF" : "#98A2B3"} />
          </button>
        </div>
      </AnimatedMount>
    </div>);

};
const composerIcon = {
  width: 28, height: 28, display: "inline-flex", alignItems: "center", justifyContent: "center",
  borderRadius: 4, border: "none", background: "transparent", cursor: "pointer", padding: 0
};

// ----- Voice-only composer: Call back · Schedule call · Add note ·························
// Used by call-only threads (Marcus, James). Notes expand a textarea upward.
const CallOnlyComposer = ({ thread, canEdit, canNote }) => {
  const [noteOpen, setNoteOpen] = useState(false);
  const [noteText, setNoteText] = useState("");

  const callBack = () => {
    if (!canEdit) return;
    if (window.startCall) window.startCall(thread);
  };

  const scheduleCall = () => {
    if (!canEdit) return;
    if (window.showAppToast) window.showAppToast("Schedule call — coming soon");
  };

  const toggleNote = () => {
    if (!canNote) return;
    setNoteOpen((o) => !o);
  };

  const saveNote = () => {
    if (!noteText.trim()) return;
    setNoteText("");
    setNoteOpen(false);
    if (window.showAppToast) window.showAppToast("Internal note saved");
  };

  if (!canEdit && !canNote) {
    return (
      <div style={{
        padding: 14, borderTop: "1px solid #E4E7EC", background: "#F9FAFB",
        display: "flex", alignItems: "center", gap: 8, justifyContent: "center", color: "#667085", fontSize: 13
      }}>
        <I.lock size={14} stroke="#667085" /> You have read-only access to this conversation
      </div>);
  }

  const pillBtn = {
    display: "inline-flex", alignItems: "center", gap: 8,
    height: 40, padding: "0 16px", borderRadius: 999,
    border: "1px solid #E4E7EC", background: "#FFFFFF",
    color: "#101828", fontSize: 13, fontWeight: 600,
    cursor: canEdit ? "pointer" : "not-allowed",
    fontFamily: "inherit"
  };

  return (
    <div style={{ borderTop: "1px solid #E4E7EC", background: "#FFFFFF", padding: "12px 20px 14px", flexShrink: 0 }}>
      {/* Note expansion area — appears above the action row */}
      {noteOpen &&
      <div style={{
        marginBottom: 10,
        border: "1px solid #FEF0C7", borderRadius: 8,
        background: "#FFFAEB",
        padding: 12, display: "flex", flexDirection: "column", gap: 8
      }}>
          <textarea
          autoFocus
          value={noteText}
          onChange={(e) => setNoteText(e.target.value)}
          placeholder="Add an internal note about this caller (not visible to the customer)..."
          style={{
            border: "none", outline: "none", resize: "none", background: "transparent",
            fontFamily: "inherit", fontSize: 14, color: "#101828", minHeight: 64
          }} />
          <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
            <button style={composerIcon}><I.paperclip size={16} stroke="#667085" /></button>
            <button style={composerIcon}><I.smile size={16} stroke="#667085" /></button>
            <button style={composerIcon}><I.template size={16} stroke="#667085" /></button>
            <div style={{ flex: 1 }} />
            <button
            onClick={() => {setNoteOpen(false);setNoteText("");}}
            style={{
              padding: "7px 12px", borderRadius: 4,
              border: "1px solid #E4E7EC", background: "#FFFFFF",
              color: "#475467", fontSize: 13, fontWeight: 500, cursor: "pointer"
            }}>
              Cancel
            </button>
            <button
            onClick={saveNote}
            style={{
              display: "inline-flex", alignItems: "center", gap: 6,
              padding: "7px 14px", borderRadius: 4,
              border: "none", cursor: noteText.trim() ? "pointer" : "not-allowed",
              background: noteText.trim() ? "#F79009" : "#F2F4F7",
              color: noteText.trim() ? "#FFFFFF" : "#98A2B3",
              fontSize: 13, fontWeight: 600
            }}>
              Save note
            </button>
          </div>
        </div>
      }

      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
        {/* Primary: Call back */}
        <button
          onClick={callBack}
          disabled={!canEdit}
          style={{
            ...pillBtn,
            background: canEdit ? "#12B76A" : "#A6F4C5",
            border: "1px solid transparent",
            color: "#FFFFFF",
            paddingLeft: 18, paddingRight: 20
          }}>
          <I.phone size={16} stroke="#FFFFFF" />
          Call back
        </button>

        {/* Schedule — icon only */}
        <button
          onClick={scheduleCall}
          disabled={!canEdit}
          title="Schedule call"
          style={{
            display: "inline-flex", alignItems: "center", justifyContent: "center",
            width: 40, height: 40, borderRadius: 999,
            border: "1px solid #E4E7EC", background: "#FFFFFF",
            cursor: canEdit ? "pointer" : "not-allowed",
            color: "#475467"
          }}>
          <I.calendar size={16} stroke="#475467" />
        </button>

        {/* Add note — toggles textarea */}
        <button
          onClick={toggleNote}
          disabled={!canNote}
          style={{
            ...pillBtn,
            background: noteOpen ? "#FFFAEB" : "#FFFFFF",
            border: `1px solid ${noteOpen ? "#FEDF89" : "#E4E7EC"}`,
            color: noteOpen ? "#B54708" : "#101828",
            cursor: canNote ? "pointer" : "not-allowed"
          }}>
          <I.note size={16} stroke={noteOpen ? "#B54708" : "#475467"} />
          {noteOpen ? "Close note" : "Add note"}
        </button>

        <div style={{ flex: 1 }} />

        <FromLinePicker thread={thread} />
      </div>
    </div>);

};

// ----- Inline AI insights bubble — rendered as a right-aligned message bubble
// inside the message stream (Natural focus + Inline placement)
const InlineAIBubble = ({ thread, onOpenAgent }) => {
  const [open, setOpen] = useState(false);
  const ins = useMemo(() => buildInsights(thread), [thread.id]);
  const openBrief = () => {if (onOpenAgent) onOpenAgent();else setOpen(true);};

  return (
    <>
      <div style={{ display: "flex", gap: 8, margin: "10px 0", flexDirection: "row-reverse", alignItems: "flex-end" }}>
        <div style={{
          width: 26, height: 26, borderRadius: 500, flexShrink: 0,
          background: "linear-gradient(135deg, #F4EBFF 0%, #E9D7FE 100%)",
          color: "#7F56D9", border: "1px solid #E9D7FE",
          display: "flex", alignItems: "center", justifyContent: "center"
        }}>
          <I.sparkle size={13} stroke="#7F56D9" />
        </div>
        <button
          onClick={openBrief}
          style={{
            maxWidth: "72%", textAlign: "left", cursor: "pointer", fontFamily: "inherit",
            padding: "10px 14px 10px", borderRadius: 14, borderBottomRightRadius: 4,
            background: ins.empty ?
            "linear-gradient(135deg, #FAFAFB 0%, #F5F4F7 100%)" :
            "linear-gradient(135deg, #FAF5FF 0%, #F4EBFF 100%)",
            border: ins.empty ? "1px dashed #D0D5DD" : "1px solid #E9D7FE",
            display: "flex", flexDirection: "column", gap: 6,
            transition: "border-color .15s, box-shadow .15s"
          }}
          onMouseEnter={(e) => {
            if (!ins.empty) {
              e.currentTarget.style.borderColor = "#B692F6";
              e.currentTarget.style.boxShadow = "0 1px 2px rgba(16,24,40,0.04), 0 4px 12px rgba(127,86,217,0.10)";
            }
          }}
          onMouseLeave={(e) => {
            e.currentTarget.style.borderColor = ins.empty ? "#D0D5DD" : "#E9D7FE";
            e.currentTarget.style.boxShadow = "none";
          }}>
          <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
            <span style={{
              fontSize: 10, fontWeight: 700, color: ins.empty ? "#667085" : "#5925DC",
              textTransform: "uppercase", letterSpacing: ".06em"
            }}>AI insights</span>
            <span style={{
              fontSize: 9, fontWeight: 600, color: ins.empty ? "#667085" : "#7F56D9",
              background: "#FFFFFF", border: `1px solid ${ins.empty ? "#EAECF0" : "#E9D7FE"}`,
              padding: "1px 5px", borderRadius: 500, letterSpacing: ".04em"
            }}>BETA</span>
          </div>
          {ins.empty ?
          <div style={{ fontSize: 13, color: "#667085", lineHeight: 1.45 }}>
              Not enough conversation yet to summarize.
            </div> :

          <>
              <div style={{
              fontSize: 13, color: "#42307D", lineHeight: 1.45, fontWeight: 500,
              display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden"
            }}>{ins.headline}</div>
              <div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
                <span style={{
                fontSize: 10, fontWeight: 500, color: "#5925DC",
                background: "#FFFFFF", border: "1px solid #E9D7FE",
                padding: "1px 6px", borderRadius: 500
              }}>{ins.sentiment}</span>
                <span style={{
                marginLeft: "auto",
                fontSize: 11, color: "#7F56D9", fontWeight: 600,
                display: "inline-flex", alignItems: "center", gap: 2
              }}>
                  View brief <I.chevRight size={12} stroke="#7F56D9" />
                </span>
              </div>
            </>
          }
          <span style={{
            alignSelf: "flex-end",
            fontSize: 11, color: "#98A2B3", fontWeight: 500, marginTop: 2
          }}>
            Just now
          </span>
        </button>
      </div>
      {open && <AIInsightsModal thread={thread} ins={ins} onClose={() => setOpen(false)} />}
    </>);

};

// ----- Floating AI insights widget — appears just below the thread header ----
const FloatingAIInsights = ({ thread, onOpenAgent }) => {
  const [open, setOpen] = useState(false);
  const ins = useMemo(() => buildInsights(thread), [thread.id]);

  if (ins.empty) {
    return (
      <div style={{
        position: "sticky", top: 8, zIndex: 5, marginBottom: 12,
        background: "linear-gradient(135deg, #FAFAFB 0%, #F5F4F7 100%)",
        border: "1px dashed #D0D5DD", borderRadius: 10,
        padding: "10px 12px",
        display: "flex", alignItems: "center", gap: 10
      }}>
        <div style={{
          width: 28, height: 28, borderRadius: 8, background: "#FFFFFF",
          border: "1px solid #EAECF0", display: "inline-flex",
          alignItems: "center", justifyContent: "center", flexShrink: 0
        }}>
          <I.sparkle size={14} stroke="#98A2B3" />
        </div>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{
            fontSize: 10, fontWeight: 700, color: "#667085",
            textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 2
          }}>AI insights</div>
          <div style={{
            fontSize: 12, color: "#667085", lineHeight: 1.4, fontWeight: 500,
            whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"
          }}>Not enough conversation yet to summarize this thread.</div>
        </div>
      </div>);

  }

  return (
    <>
      <div style={{
        position: "sticky", top: 8, zIndex: 5, marginBottom: 12,
        background: "linear-gradient(135deg, #FAF5FF 0%, #F4EBFF 100%)",
        border: "1px solid #E9D7FE", borderRadius: 10,
        boxShadow: "0 1px 2px rgba(16,24,40,0.04), 0 8px 24px rgba(127,86,217,0.12)",
        padding: "10px 12px",
        display: "flex", alignItems: "center", gap: 10
      }}>
        <div style={{
          width: 28, height: 28, borderRadius: 8, background: "#FFFFFF",
          border: "1px solid #E9D7FE", display: "inline-flex",
          alignItems: "center", justifyContent: "center", flexShrink: 0
        }}>
          <I.sparkle size={14} stroke="#7F56D9" />
        </div>

        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 2 }}>
            <div style={{
              fontSize: 10, fontWeight: 700, color: "#5925DC",
              textTransform: "uppercase", letterSpacing: ".06em"
            }}>AI insights</div>
            <span style={{
              fontSize: 9, fontWeight: 600, color: "#7F56D9",
              background: "#FFFFFF", border: "1px solid #E9D7FE",
              padding: "1px 5px", borderRadius: 500, letterSpacing: ".04em"
            }}>BETA</span>
            <span style={{
              fontSize: 10, fontWeight: 500, color: "#5925DC",
              background: "#FFFFFF", border: "1px solid #E9D7FE",
              padding: "1px 6px", borderRadius: 500
            }}>{ins.sentiment}</span>
          </div>
          <div style={{
            fontSize: 12, color: "#42307D", lineHeight: 1.4, fontWeight: 500,
            whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"
          }}>{ins.headline}</div>
        </div>

        <button
          onClick={() => {if (onOpenAgent) onOpenAgent();else setOpen(true);}}
          style={{
            flexShrink: 0, display: "inline-flex", alignItems: "center", gap: 4,
            background: "#FFFFFF", border: "1px solid #E9D7FE", borderRadius: 6,
            padding: "5px 9px", cursor: "pointer", fontFamily: "inherit",
            fontSize: 11, fontWeight: 600, color: "#5925DC"
          }}
          onMouseEnter={(e) => e.currentTarget.style.borderColor = "#B692F6"}
          onMouseLeave={(e) => e.currentTarget.style.borderColor = "#E9D7FE"}>
          View brief <I.chevRight size={12} stroke="#5925DC" />
        </button>
      </div>
      {open && <AIInsightsModal thread={thread} ins={ins} onClose={() => setOpen(false)} />}
    </>);

};

// Floating banner shown to a peer agent who just received a hand-over.
// Sits just above the composer, dismissible.
const HandoffBanner = ({ from, onOpenBriefing, onDismiss }) => {
  return (
    <div style={{
      margin: "0 20px 8px", padding: "10px 12px",
      background: "#F4F0FF", border: "1px solid #E0D7FF",
      borderRadius: 8, display: "flex", alignItems: "center", gap: 10,
      boxShadow: "0 4px 12px rgba(103,59,255,0.08)"
    }}>
      <div style={{
        width: 28, height: 28, borderRadius: "50%", background: "#673BFF",
        display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0
      }}>
        <I.sparkle size={14} stroke="#FFFFFF" />
      </div>
      <div style={{ flex: 1, minWidth: 0, fontSize: 13, color: "#3E2A99", lineHeight: 1.4 }}>
        <strong style={{ fontWeight: 600 }}>Handed over from {from.name}</strong>
        <span style={{ color: "#5F4DAA" }}> · {from.summary}</span>
      </div>
      <button
        onClick={onOpenBriefing}
        style={{
          padding: "6px 10px", borderRadius: 6, border: "1px solid #673BFF",
          background: "#673BFF", color: "#FFFFFF", fontSize: 12, fontWeight: 600,
          cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 5, flexShrink: 0
        }}>
        <I.sparkle size={12} stroke="#FFFFFF" /> Open briefing
      </button>
      <button
        onClick={onDismiss}
        title="Dismiss"
        style={{
          width: 24, height: 24, borderRadius: 4, border: "none",
          background: "transparent", cursor: "pointer", display: "inline-flex",
          alignItems: "center", justifyContent: "center", flexShrink: 0
        }}>
        <I.x size={12} stroke="#5F4DAA" />
      </button>
    </div>);

};

// ----- Floating AI summary widget — pinned to the bottom of the
// scrollable thread area. Acts as a fall-back navigation to the Justy AI panel.
//
// Scroll behavior: when the thread is scrolled to the bottom, the widget sits
// in flow as the last item (no shadow, no border lift). When the user scrolls
// up, the widget lifts into a sticky position at the bottom of the viewport
// with an elevated shadow so it always stays in reach.
//
// Admin-only signal sections shown inside the floating Conversation Summary
// widget on convo-admin.html — replaces the agent's "Next actions" pills with
// two compact pill rows (2 pills each) for coaching: agent behavior signals
// and conversation health signals, grouped horizontally side-by-side.
const AdminSignalSections = ({ thread, only = null }) => {
  // Athlete-to-Athlete coaching signals — keyed off the actual thread.
  // PACKS map per-thread coaching observations; everything else falls back
  // to a sensible tag/channel-driven default.
  const PACKS = {
    // Mia → strong post-intro conversion moment
    t_mia: {
      agent: [
      { tone: "good", icon: I.zap, label: "Replied within 5 min" },
      { tone: "good", icon: I.checkCircle, label: "Logged 5★ feedback cleanly" }],

      health: [
      { tone: "good", icon: I.sparkle, label: "Sentiment 5★" },
      { tone: "info", icon: I.chart, label: "High purchase intent" }]

    },
    // Marcus → recovery from broken meet link
    t_marcus: {
      agent: [
      { tone: "good", icon: I.zap, label: "2-min callback on voicemail" },
      { tone: "warn", icon: I.alert, label: "Apology not yet in writing" }],

      health: [
      { tone: "warn", icon: I.alert, label: "Parent frustrated" },
      { tone: "info", icon: I.bell, label: "Trust-recovery window" }]

    },
    // Landon → re-engaged mentor
    t_landon: {
      agent: [
      { tone: "good", icon: I.checkCircle, label: "Re-engagement script worked" },
      { tone: "warn", icon: I.alert, label: "Booking not yet confirmed" }],

      health: [
      { tone: "good", icon: I.zap, label: "Replied in 12 min" },
      { tone: "info", icon: I.bell, label: "Book within 24h" }]

    },
    // Tyler → onboarding complete
    t_tyler: {
      agent: [
      { tone: "good", icon: I.checkCircle, label: "Strong onboarding walkthrough" },
      { tone: "info", icon: I.bell, label: "First match pending" }],

      health: [
      { tone: "good", icon: I.checkCircle, label: "Mentor warm + ready" },
      { tone: "info", icon: I.bell, label: "Match within 7 days" }]

    },
    // Sofia → reschedule confirmed
    t_sofia: {
      agent: [
      { tone: "good", icon: I.checkCircle, label: "Reschedule handled cleanly" },
      { tone: "good", icon: I.zap, label: "New link sent same hour" }],

      health: [
      { tone: "good", icon: I.checkCircle, label: "Both sides confirmed" },
      { tone: "info", icon: I.bell, label: "24h reminder due" }]

    },
    // Jared → recurring conversion
    t_jared: {
      agent: [
      { tone: "good", icon: I.checkCircle, label: "Closed the loop on payouts" },
      { tone: "good", icon: I.zap, label: "Pitched recurring at the right moment" }],

      health: [
      { tone: "good", icon: I.checkCircle, label: "Session went well" },
      { tone: "info", icon: I.bell, label: "Recurring booking pending" }]

    },
    // Dominic → reminders going out
    t_dominic: {
      agent: [
      { tone: "good", icon: I.checkCircle, label: "Reminders firing on schedule" },
      { tone: "info", icon: I.bell, label: "No agent action needed" }],

      health: [
      { tone: "good", icon: I.checkCircle, label: "On track for tonight" },
      { tone: "info", icon: I.bell, label: "Call in 2h" }]

    },
    // Jenna → recovery success
    t_jenna: {
      agent: [
      { tone: "good", icon: I.checkCircle, label: "Personal callback recovered trust" },
      { tone: "good", icon: I.zap, label: "Mentor locked on same call" }],

      health: [
      { tone: "good", icon: I.checkCircle, label: "Sentiment turned positive" },
      { tone: "info", icon: I.bell, label: "Post-intro check-in due" }]

    },
    // Aaliyah → seasonal pause
    t_aaliyah: {
      agent: [
      { tone: "good", icon: I.checkCircle, label: "Closed warmly" },
      { tone: "info", icon: I.bell, label: "Re-engagement queued" }],

      health: [
      { tone: "good", icon: I.checkCircle, label: "Relationship preserved" },
      { tone: "info", icon: I.bell, label: "Seasonal pause" }]

    }
  };

  const pack = PACKS[thread.id] || {
    agent: [
    { tone: "good", icon: I.checkCircle, label: "Clear next step" },
    { tone: "info", icon: I.bell, label: "Awaiting reply" }],

    health: [
    { tone: "good", icon: I.checkCircle, label: "Sentiment stable" },
    { tone: "info", icon: I.bell, label: "Within SLA" }]

  };

  const agentSignals = pack.agent;

  // Thread sentiment, in stars — drives the first Conversation-signals label
  // (icon + color track the rating; everything else is a hue-varied pastel).
  const SENTIMENT = {
    t_mia: 5, t_marcus: 2, t_landon: 4, t_tyler: 4, t_sofia: 4,
    t_jared: 5, t_dominic: 4, t_jenna: 4, t_aaliyah: 3
  };
  const stars = SENTIMENT[thread.id] || 4;
  const sentimentHue =
  stars >= 5 ? { bg: "#EFFCF8", border: "#A7E8D8", color: "#0E7C6B", icon: "#12A594" } :
  stars === 4 ? { bg: "#F0F7FF", border: "#C2DDFB", color: "#175CD3", icon: "#2E90FA" } :
  stars === 3 ? { bg: "#FFF8EC", border: "#FBE0B0", color: "#B54708", icon: "#F79009" } :
  { bg: "#FFF3F5", border: "#FBCDD5", color: "#B42342", icon: "#E3567A" };
  const sentimentSignal = {
    label: `Sentiment ${stars}★`,
    hue: sentimentHue,
    icon: stars >= 5 ? I.laugh : stars >= 3 ? I.smile : I.frown
  };
  const healthSignals = [sentimentSignal, ...pack.health.slice(1)];

  const palette = (tone) => {
    if (tone === "warn") return { bg: "#FFFAEB", border: "#FEDF89", color: "#B54708", icon: "#DC6803" };
    if (tone === "good") return { bg: "#ECFDF3", border: "#ABEFC6", color: "#067647", icon: "#079455" };
    return { bg: "#F4EBFF", border: "#E9D7FE", color: "#5925DC", icon: "#7F56D9" };
  };

  // Pastel label palettes — cycled per position, offset per conversation so
  // different threads read in different hues.
  const PASTELS = [
  { bg: "#EFFCF8", border: "#B7EBDF", color: "#0E7C6B", icon: "#12A594" }, // light teal
  { bg: "#F0F7FF", border: "#C2DDFB", color: "#175CD3", icon: "#2E90FA" }, // sky blue
  { bg: "#F7F2FF", border: "#DDD0FA", color: "#5925DC", icon: "#7F56D9" }, // lavender
  { bg: "#FFF3F7", border: "#FBD0DF", color: "#B93B6B", icon: "#E255A0" }, // light pink
  { bg: "#FFF8EC", border: "#FBE0B0", color: "#B54708", icon: "#F79009" }, // peach
  { bg: "#F2FBEF", border: "#CBEBBE", color: "#3B7A2A", icon: "#66B04A" }, // sage
  { bg: "#F1F4FF", border: "#CBD5FB", color: "#3538CD", icon: "#6172F3" }] // periwinkle
  ;
  const hueOffset = String(thread.id).split("").reduce((a, c) => a + c.charCodeAt(0), 0);

  const Pill = ({ s, idx = 0 }) => {
    const p = s.hue || PASTELS[(hueOffset + idx) % PASTELS.length];
    const IconC = s.icon;
    return (
      <div style={{
        display: "inline-flex", alignItems: "center", gap: 6,
        padding: "4px 10px 4px 8px", borderRadius: 8,
        background: p.bg, border: `1px solid ${p.border}`,
        whiteSpace: "nowrap"
      }}>
        {IconC && <IconC size={13} stroke={p.icon} />}
        <span style={{ fontSize: 13, fontWeight: 500, color: p.color, lineHeight: 1.4 }}>{s.label}</span>
      </div>);

  };

  const Group = ({ label, items }) =>
  <div style={{ display: "flex", flexDirection: "column", gap: 12, minWidth: 0 }}>
      <div style={{
      fontSize: 10, fontWeight: 700, color: "#98A2B3",
      textTransform: "uppercase", letterSpacing: ".06em"
    }}>{label}</div>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
        {items.map((s, i) => <Pill key={i} s={s} idx={i} />)}
      </div>
    </div>;


  return (
    <div style={{
      display: "flex", flexWrap: "wrap",
      marginBottom: 4, gap: "32px"
    }}>
      {only !== "health" && <Group label="Agent signals" items={agentSignals} />}
      {only !== "agent" && <Group label="Conversation signals" items={healthSignals} />}
    </div>);

};

// Broadcasts its visible/dismissed state via the global `aiSummaryWidget:state`
// event so the contact panel can hide its inline AI insights card while the
// widget is on screen (avoids duplicating the same content twice).
// Pill style — white bg, purple stroke, purple text. On hover: 20% purple bg.
// Shared by SummaryPills (Next actions row) and RepliesPills (Suggested replies row).
// JC 2.0 ghost-button style: 8px radius, neutral gray stroke, 500 weight.
// Text color is preserved as the brand purple to keep the AI-suggestion read.
const widgetPillBase = {
  display: "inline-flex", alignItems: "center", gap: 6,
  padding: "8px 12px", borderRadius: 8,
  border: "1px solid #D0D5DD", background: "#FFFFFF",
  color: "#5925DC",
  fontSize: 13, fontWeight: 500, cursor: "pointer", fontFamily: "inherit",
  whiteSpace: "nowrap",
  transition: "background .15s, border-color .15s"
};
const widgetPillHover = "#F9FAFB"; // subtle gray hover
const onPillEnter = (e) => {e.currentTarget.style.background = widgetPillHover;};
const onPillLeave = (e) => {e.currentTarget.style.background = "#FFFFFF";};

const SummaryPills = ({ thread, ctas, onLaunchDraft, onShowReplies, onOpenMore }) => {
  return (
    <>
      <div style={{
        fontSize: 10, fontWeight: 700, color: "#98A2B3",
        textTransform: "uppercase", letterSpacing: ".06em",
        marginBottom: 8
      }}>Suggested actions</div>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }} data-comment-anchor="2b60a6774b-div-1708-7">
        {ctas.slice(0, 2).map((s, i) =>
        <button
          key={i}
          onClick={() => onLaunchDraft(s)}
          title={s.description || s.subtitle || ""}
          style={{ ...widgetPillBase, fontWeight: "500" }}
          onMouseEnter={onPillEnter}
          onMouseLeave={onPillLeave}>
            {s.title}
          </button>
        )}
        {ctas.length > 2 &&
        <button
          onClick={onOpenMore}
          title="See all next actions in AI insights"
          style={{ ...widgetPillBase, padding: "8px 12px" }}
          onMouseEnter={onPillEnter}
          onMouseLeave={onPillLeave}>{`+${ctas.length - 2}`}</button>
        }
      </div>
    </>);

};

const RepliesPills = ({ thread, onBack }) => {
  const a = window.buildAgentActivity ? window.buildAgentActivity(thread) : null;
  const replies = (a && a.replies || []).slice(0, 3);
  const useReply = (text) => {
    window.dispatchEvent(new CustomEvent("convo:draft", { detail: { text, channel: "sms" } }));
  };

  return (
    <>
      <div style={{
        display: "flex", alignItems: "center", gap: 8, marginBottom: 8
      }}>
        <button
          onClick={onBack}
          title="Back to summary"
          style={{
            width: 22, height: 22, borderRadius: 4, border: "none",
            background: "transparent", cursor: "pointer", padding: 0,
            display: "inline-flex", alignItems: "center", justifyContent: "center"
          }}
          onMouseEnter={(e) => e.currentTarget.style.background = "#F4EBFF"}
          onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
          <I.chevLeft size={13} stroke="#5925DC" />
        </button>
        <div style={{
          fontSize: 10, fontWeight: 700, color: "#98A2B3",
          textTransform: "uppercase", letterSpacing: ".06em"
        }}>Suggested replies</div>
      </div>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
        {replies.length === 0 ?
        <div style={{ fontSize: 12, color: "#98A2B3", fontStyle: "italic" }}>
            No reply suggestions yet
          </div> :
        replies.map((r, i) =>
        <button
          key={i}
          onClick={() => useReply(r.text)}
          title={r.text}
          style={widgetPillBase}
          onMouseEnter={onPillEnter}
          onMouseLeave={onPillLeave}>
            {r.tone}
          </button>
        )}
      </div>
    </>);

};

const ConversationSummaryWidget = ({ thread, onOpenAgent, persona = "agent" }) => {
  const ins = useMemo(() => buildInsights(thread), [thread.id]);
  const [dismissed, setDismissed] = useState(false);
  // mode = "summary" (default) or "replies" (showing suggested-reply pills)
  const [mode, setMode] = useState("summary");
  useEffect(() => {setMode("summary");}, [thread.id]);
  // atBottom = the thread scroll container is at (or very near) its lowest position
  const [atBottom, setAtBottom] = useState(true);
  const ref = useRef(null);

  useEffect(() => {
    // Opening a conversation no longer pops the summary open — the user asks
    // for it from the composer's AI Summary button. A host can opt back into
    // the old behaviour with __TWEAKS.autoShowAISummary = true.
    const auto = !!(window.__TWEAKS && window.__TWEAKS.autoShowAISummary === true);
    setDismissed(!auto);
  }, [thread.id]);

  // Listen for global "show AI summary widget" requests (fired from the
  // composer's AI Summary button after user dismissed the widget).
  useEffect(() => {
    const onShow = () => setDismissed(false);
    const onHide = () => setDismissed(true);
    window.addEventListener("convo:showAISummary", onShow);
    window.addEventListener("convo:hideAISummary", onHide);
    return () => {
      window.removeEventListener("convo:showAISummary", onShow);
      window.removeEventListener("convo:hideAISummary", onHide);
    };
  }, []);

  // Notify other components (composer button) when dismissed state changes
  useEffect(() => {
    window.dispatchEvent(new CustomEvent("convo:aiSummaryDismissed", {
      detail: { dismissed: dismissed && !ins.empty }
    }));
  }, [dismissed, ins.empty]);

  // Watch the nearest scrolling ancestor and update atBottom in real time.
  useEffect(() => {
    if (!ref.current) return;
    let scroller = ref.current.parentElement;
    while (scroller) {
      const oy = getComputedStyle(scroller).overflowY;
      if (oy === "auto" || oy === "scroll") break;
      scroller = scroller.parentElement;
    }
    if (!scroller) return;
    const check = () => {
      const dist = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
      setAtBottom(dist < 24);
    };
    check();
    scroller.addEventListener("scroll", check, { passive: true });
    const ro = new ResizeObserver(check);
    ro.observe(scroller);
    return () => {
      scroller.removeEventListener("scroll", check);
      ro.disconnect();
    };
  }, [thread.id]);

  // Broadcast whether this widget is currently rendered, so the contact
  // panel's AI insights card can stay hidden while we're visible.
  const visible = !ins.empty && !dismissed;
  useEffect(() => {
    window.__aiSummaryWidgetVisible = visible;
    window.dispatchEvent(new CustomEvent("aiSummaryWidget:state", { detail: { visible } }));
    return () => {
      window.__aiSummaryWidgetVisible = false;
      window.dispatchEvent(new CustomEvent("aiSummaryWidget:state", { detail: { visible: false } }));
    };
  }, [visible, thread.id]);

  const ctas = ins.nextSteps || [];

  const launchDraft = (step) => {
    const detail = { text: step.draft || "", channel: step.channel || "sms", title: step.title, attachment: step.attachment || null };
    window.dispatchEvent(new CustomEvent("convo:draft", { detail }));
  };

  const openInsights = () => {
    setDismissed(true); // hide the floating widget so the side panel takes over
    // Admins → open the contact panel directly to the Coaching tab
    // Agents → open the contact panel to the AI insights tab
    if (persona === "admin") {
      window.dispatchEvent(new CustomEvent("convo:openContactAI", {
        detail: { focus: "ai" }
      }));
    } else {
      window.dispatchEvent(new CustomEvent("convo:openContactAI", {
        detail: { focus: "ai" }
      }));
    }
  };

  // "+N more" pill — opens the contact side panel with the AI insights tab
  // selected, then pulses the Next actions section to draw the eye to it.
  const openMoreActions = () => {
    window.dispatchEvent(new CustomEvent("convo:openContactAI", {
      detail: { focus: "nextActions" }
    }));
  };

  const lifted = !atBottom;
  return (
    <AnimatedMount show={visible} baseClass="aiSummaryWidget" duration={280} style={{
      position: "sticky", bottom: 8, zIndex: 5, marginTop: 24
    }}>
      <div
        ref={ref}
        style={{
          background: "#FFFFFF",
          border: `1px solid ${lifted ? "#E9D7FE" : "#EAECF0"}`,
          borderRadius: 12,
          boxShadow: lifted ?
          "0 1px 2px rgba(16,24,40,0.04), 0 16px 32px rgba(127,86,217,0.20)" :
          "0 1px 2px rgba(16,24,40,0.04)",
          padding: "12px 14px",
          transition: "box-shadow .2s ease, border-color .2s ease"
        }} data-comment-anchor="f16814f2b0-div-1989-7">
      {/* Top row — icon, label, dismiss */}
      <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
        <div style={{
            width: 26, height: 26, borderRadius: 8, flexShrink: 0,
            background: "linear-gradient(135deg, #FAF5FF 0%, #F4EBFF 100%)",
            border: "1px solid #E9D7FE",
            display: "inline-flex", alignItems: "center", justifyContent: "center"
          }}>
          <I.sparkle size={13} stroke="#7F56D9" />
        </div>
        <div style={{
            fontSize: 10, fontWeight: 700, color: "#5925DC",
            textTransform: "uppercase", letterSpacing: ".06em"
          }}>AI summary</div>
        <div style={{ flex: 1 }} />
        {onOpenAgent &&
          <button
            onClick={openInsights}
            style={{
              display: "inline-flex", alignItems: "center", gap: 4,
              padding: "4px 10px", borderRadius: 6, border: "1px solid transparent",
              background: "transparent", color: "#5925DC",
              fontSize: 12, fontWeight: 600, cursor: "pointer", fontFamily: "inherit"
            }}
            onMouseEnter={(e) => e.currentTarget.style.background = "#F4EBFF"}
            onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
            View Insights <I.chevRight size={12} stroke="#5925DC" />
          </button>
          }
        <button
            onClick={() => setDismissed(true)}
            title="Dismiss"
            style={{
              width: 22, height: 22, borderRadius: 4, border: "none",
              background: "transparent", cursor: "pointer", padding: 0,
              display: "inline-flex", alignItems: "center", justifyContent: "center"
            }}>
          <I.x size={12} stroke="#98A2B3" />
        </button>
      </div>

      {/* Summary — max 2 lines (this widget is a fall-back nav to the full Justy panel) */}
      <div style={{
          fontSize: 13, color: "#344054", lineHeight: 1.55, fontWeight: 400,
          marginBottom: 16,
          display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden"
        }}>{ins.summary}</div>

      {/* Next actions — show 2 pills + "+N more" pill that opens the contact
                                                                                                                                                                                panel with the AI insights tab focused and a pulse highlight.
                                                                                                                                                                                Admins (convo-admin.html) see two coaching-flavored signal sections
                                                                                                                                                                                here instead of action pills. */}
      {persona === "admin" ?
        <AdminSignalSections thread={thread} /> :
        <div style={{
          display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
          gap: "16px 32px", alignItems: "start"
        }}>
          <div style={{ minWidth: 0, minHeight: 56 }}>
      {mode === "summary" ?
          <SummaryPills
            thread={thread}
            ctas={ctas}
            onLaunchDraft={launchDraft}
            onShowReplies={() => setMode("replies")}
            onOpenMore={openMoreActions} /> :

          <RepliesPills
            thread={thread}
            onBack={() => setMode("summary")} />
          }
          </div>
          <AdminSignalSections thread={thread} only="health" />
        </div>}

      {/* Bottom CTA removed — View Insights now lives in top-right */}
      {false && onOpenAgent &&
        <div style={{
          paddingTop: 10, borderTop: "1px solid #F2F4F7",
          display: "flex", justifyContent: "flex-end"
        }}>
          <button
            onClick={openInsights}
            style={{
              display: "inline-flex", alignItems: "center", gap: 4,
              padding: "6px 10px", borderRadius: 6, border: "1px solid transparent",
              background: "transparent", color: "#5925DC",
              fontSize: 12, fontWeight: 600, cursor: "pointer", fontFamily: "inherit"
            }}
            onMouseEnter={(e) => e.currentTarget.style.background = "#F4EBFF"}
            onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
            view all insights <I.chevRight size={12} stroke="#5925DC" />
          </button>
        </div>
        }
      </div>
    </AnimatedMount>);

};

const ThreadView = ({ thread, messages, permission, persona, user, onToggleContact, showContact, showAIWidget = false, showInlineAI = false, aiFocus = "natural", onOpenAgent, handoffFrom = null, onToggleClosed, onToggleRead, chrome, omitModes = [] }) => {
  const [handoffDismissed, setHandoffDismissed] = useState(false);
  // Reset dismissed flag when thread changes
  useEffect(() => {setHandoffDismissed(false);}, [thread.id]);

  // ── Locally-appended messages (sends from the composer) ──
  // Keyed by thread.id so each conversation keeps its own appended drafts in
  // the current session.
  const [appended, setAppended] = useState({});
  useEffect(() => {
    const onAppend = (e) => {
      const { threadId, message } = e.detail || {};
      if (!threadId || !message) return;
      setAppended((prev) => {
        const next = {
          ...prev,
          [threadId]: [...(prev[threadId] || []), message]
        };
        // Mirror to window so the inbox-row preview can read it.
        window.__appendedMessages = next;
        return next;
      });
    };
    window.addEventListener("convo:appendMessage", onAppend);
    return () => window.removeEventListener("convo:appendMessage", onAppend);
  }, []);
  const liveMessages = useMemo(
    () => [...messages, ...(appended[thread.id] || [])],
    [messages, appended, thread.id]
  );

  // Auto-scroll to the bottom whenever a message is appended in this thread
  const lastAppendedCount = useRef(0);
  useEffect(() => {
    const n = (appended[thread.id] || []).length;
    if (n > lastAppendedCount.current && scrollerRef.current) {
      requestAnimationFrame(() => {
        const sc = scrollerRef.current;
        if (sc) sc.scrollTo({ top: sc.scrollHeight, behavior: "smooth" });
      });
    }
    lastAppendedCount.current = n;
  }, [appended, thread.id]);

  /* The ongoing call card arrives as a change to `messages` — it is derived
     from the live call rather than sent by anyone — so the append-driven
     scroll above never sees it. Bring it into view the same way, or a card
     about right now sits below the fold of a long thread.

     Set directly rather than through the smooth scrollTo the append handler
     uses, and without the frame it waits for: this effect already runs after
     the commit that added the card, so the height is final, and the call has
     just connected — a two-second glide down the thread is time the agent
     spends not seeing the call they are on. */
  const hadOngoing = useRef(false);
  useEffect(() => {
    const on = liveMessages.some((m) => m.status === "ongoing");
    if (on && !hadOngoing.current) {
      const sc = scrollerRef.current;
      if (sc) sc.scrollTop = sc.scrollHeight;
    }
    hadOngoing.current = on;
  }, [liveMessages]);
  const [boldTab, setBoldTab] = useState("thread"); // "thread" | "ai"
  const ins = useMemo(() => buildInsights(thread), [thread.id]);

  // Reset to thread tab when switching threads
  useEffect(() => {setBoldTab("thread");}, [thread.id]);

  // Listen for "scroll to message" requests (from AI tag suggestion links)
  const scrollerRef = useRef(null);
  useEffect(() => {
    const onScrollToMsg = (e) => {
      const idx = e.detail && e.detail.msgIdx;
      if (idx == null) return;
      // If we're in Bold AI tab, jump to thread tab first
      if (showAITab) setBoldTab("thread");
      // Defer to next frame to ensure tab swap rendered
      requestAnimationFrame(() => {
        const sc = scrollerRef.current;
        if (!sc) return;
        const node = sc.querySelector(`[data-msg-idx="${idx}"]`);
        if (!node) return;
        const top = node.offsetTop - 24;
        sc.scrollTo({ top, behavior: "smooth" });
        node.classList.add("convo-msg-flash");
        setTimeout(() => node.classList.remove("convo-msg-flash"), 1800);
      });
    };
    window.addEventListener("convo:scrollToMsg", onScrollToMsg);
    return () => window.removeEventListener("convo:scrollToMsg", onScrollToMsg);
  });

  const isBold = aiFocus === "bold";
  const showAITab = isBold && boldTab === "ai";

  // Inline placement: insert an AI bubble after the first ~60% of messages so
  // it lands organically inside the conversation, not at the very top or end.
  const inlineIndex = useMemo(() => {
    if (!showInlineAI || liveMessages.length === 0) return -1;
    const nonDate = liveMessages.filter((m) => m.kind !== "date").length;
    if (nonDate <= 1) return liveMessages.length;
    let count = 0;
    const target = Math.max(1, Math.floor(nonDate * 0.6));
    for (let i = 0; i < liveMessages.length; i++) {
      if (liveMessages[i].kind !== "date") count++;
      if (count >= target) return i + 1;
    }
    return liveMessages.length;
  }, [liveMessages, showInlineAI]);

  return (
    <div style={{ flex: 1, display: "flex", flexDirection: "column", background: "#FFFFFF", minWidth: 0, position: "relative" }}>
      {/* `chrome.header` false: the host is titling this column from outside
          it. The campaign queue puts one header over the thread and the
          contact sheet together, so this one would be the second. */}
      {(!chrome || chrome.header !== false) &&
      <ThreadHeader thread={thread} permission={permission} onCloseConversation={onToggleClosed} onToggleContact={onToggleContact} showContact={showContact} currentUser={user} onToggleRead={onToggleRead} chrome={chrome} />
      }
      {isBold &&
      <BoldAITabs active={boldTab} onChange={setBoldTab} hasInsights={!ins.empty} />
      }
      {showAITab ?
      <div style={{ flex: 1, overflowY: "auto", background: "#FAFBFC", display: "flex", flexDirection: "column" }}>
          <AIInsightsBrief ins={ins} />
        </div> :

      <div ref={scrollerRef} style={{ flex: 1, overflowY: "auto", padding: "8px 20px", background: "#FAFBFC", position: "relative" }}>
          {showAIWidget && <FloatingAIInsights thread={thread} onOpenAgent={onOpenAgent} />}
          {liveMessages.map((m, i) => {
          const inner = m.kind === "date" ? <DateChip label={m.label} /> :
          m.kind === "sms" ? <Bubble m={m} user={user} thread={thread} /> :
          m.kind === "call" ? <CallCard m={m} thread={thread} user={user} /> :
          m.kind === "voice" ? <VoiceNote m={m} thread={thread} user={user} /> :
          m.kind === "note" ? <NoteCard m={m} user={user} /> :
          m.kind === "event" ? <EventMarker m={m} /> :
          null;
          const node =
          <div key={i} data-msg-idx={i} className="convo-msg-anchor">{inner}</div>;

          if (showInlineAI && i === inlineIndex - 1) {
            return (
              <React.Fragment key={i}>
                  {node}
                  <InlineAIBubble thread={thread} onOpenAgent={onOpenAgent} />
                </React.Fragment>);

          }
          return node;
        })}
          {showInlineAI && liveMessages.length === 0 && <InlineAIBubble thread={thread} onOpenAgent={onOpenAgent} />}
          <ConversationSummaryWidget thread={thread} onOpenAgent={onOpenAgent} persona={persona} />
        </div>
      }
      {handoffFrom && !handoffDismissed &&
      <HandoffBanner
        from={handoffFrom}
        onOpenBriefing={() => onOpenAgent && onOpenAgent("handoff")}
        onDismiss={() => setHandoffDismissed(true)} />

      }
      {window.ComposerV2 ?
      <window.ComposerV2 thread={thread} permission={permission} persona={persona} omitModes={omitModes} /> :
      <Composer permission={permission} thread={thread} persona={persona} />
      }
      <ToastHost />
    </div>);

};

// Tabs for Bold AI focus mode — sits between header and message stream
const BoldAITabs = ({ active, onChange, hasInsights }) => {
  const tabBase = {
    padding: "10px 16px", border: "none", background: "transparent", cursor: "pointer",
    fontFamily: "inherit", fontSize: 13, fontWeight: 500, color: "#667085",
    display: "inline-flex", alignItems: "center", gap: 6,
    borderBottom: "2px solid transparent", marginBottom: -1, position: "relative"
  };
  const activeStyle = (k) => k === active ? {
    color: "#5925DC", fontWeight: 600, borderBottomColor: "#7F56D9"
  } : {};
  return (
    <div style={{
      display: "flex", alignItems: "center", gap: 4,
      padding: "0 20px", borderBottom: "1px solid #E4E7EC", background: "#FFFFFF",
      flexShrink: 0
    }}>
      <button style={{ ...tabBase, ...activeStyle("ai") }} onClick={() => onChange("ai")}>
        <I.sparkle size={13} stroke={active === "ai" ? "#7F56D9" : "#98A2B3"} />
        AI insights
        {!hasInsights &&
        <span style={{
          fontSize: 9, fontWeight: 600, color: "#667085",
          background: "#F2F4F7", padding: "1px 5px", borderRadius: 500
        }}>EMPTY</span>
        }
        {hasInsights &&
        <span style={{
          fontSize: 9, fontWeight: 600, color: "#7F56D9",
          background: "#F4EBFF", padding: "1px 5px", borderRadius: 500, letterSpacing: ".04em"
        }}>BETA</span>
        }
      </button>
      <button style={{ ...tabBase, ...activeStyle("thread") }} onClick={() => onChange("thread")}>
        <I.chat size={13} stroke={active === "thread" ? "#7F56D9" : "#98A2B3"} />
        Thread
      </button>
    </div>);

};

// Shown in the thread column when nothing is selected — after switching views,
// or before the user has picked a conversation.
// NOTE: the illustration is an inline SVG stand-in. The PNG asset for this
// empty state isn't in the repo yet; drop it in and swap the <svg> for an <img>.
const EmptyThreadView = () => (
  <div style={{
    flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
    background: "#FFFFFF", minWidth: 0, gap: 18, padding: 24
  }}>
    <svg width="120" height="120" viewBox="0 0 120 120" fill="none" aria-hidden="true">
      <circle cx="60" cy="60" r="60" fill="#F7F9FC" />
      <rect x="24" y="30" width="72" height="20" rx="6" fill="#FFFFFF" stroke="#EEF1F6" />
      <circle cx="37" cy="40" r="5" fill="#E7EBF1" />
      <rect x="48" y="37" width="30" height="6" rx="3" fill="#E7EBF1" />
      <rect x="23" y="54" width="74" height="22" rx="7" fill="#FFFFFF" stroke="#2563EB" strokeWidth="1.6" />
      <circle cx="37" cy="65" r="5" fill="#2563EB" />
      <rect x="48" y="62" width="36" height="6" rx="3" fill="#DBE7FE" />
      <rect x="24" y="80" width="72" height="20" rx="6" fill="#FFFFFF" stroke="#EEF1F6" />
      <circle cx="37" cy="90" r="5" fill="#E7EBF1" />
      <rect x="48" y="87" width="26" height="6" rx="3" fill="#E7EBF1" />
      <path d="M84 68 L84 86 L89 81.5 L92.5 89 L96 87 L92.5 80 L98 79 Z"
            fill="#FFFFFF" stroke="#1F2A37" strokeWidth="1.6" strokeLinejoin="round" />
    </svg>
    {/* "Select a conversation" is only true when there are some. On an empty
        account the list beside this is empty too, so it names the state
        instead of asking for something impossible. */}
    <div style={{ fontSize: 15, color: "#667085", fontWeight: 400, textAlign: "center", maxWidth: 320 }}>
      {window.jcIsEmptyProfile && window.jcIsEmptyProfile() ?
      "Your conversations will show up here once you start one" :
      "Select a conversation to view messages"}
    </div>
  </div>);


window.EmptyThreadView = EmptyThreadView;
window.ThreadView = ThreadView;