// Contact / right pane
/* `showHeader` false leaves out the panel's own "Contact Info" bar, for a host
   that titles the sheet from outside it. The campaign queue puts one header
   over the thread and the sheet together — one contact, named once — so a
   second title inside the sheet would name them twice and give them two
   close controls. */
const ContactPane = ({ thread, permission, persona, showAIInsights = true, forceAI = false, layout = "current", infoLayout = "single", onClose, onOpenAgent, focus = null, showCoachingTab = false, showHeader = true }) => {
  const canEdit = permission === "full";
  const canViewBilling = persona === "admin";
  const [moreOpen, setMoreOpen] = useState(false);
  const moreRef = useRef(null);
  // Hover/focus state for the contact name, which links out to the record.
  const [nameHover, setNameHover] = useState(false);
  // Drill-down view (current layout) — when true, AI insights take over the panel
  // and the contact sections are hidden behind a back button.
  const [aiDrillDown, setAiDrillDown] = useState(false);
  // Tab state — AI insights or Contact details (current layout only)
  const [activeTab, setActiveTab] = useState("details");
  // The 2-tabs info panel splits this sheet at the seam the single pane only
  // draws a divider across: the AI brief above, the contact's own fields below.
  const tabbed = infoLayout === "tabs";
  // Flipping the dimension is effectively re-entering the sheet, so land on its
  // first tab — the brief, which is what the single pane also shows first —
  // rather than on whatever the other layout was last scrolled to.
  useEffect(() => {setActiveTab(tabbed ? "ai" : "details");}, [tabbed]);
  // Reset drill-down when switching threads or layout
  useEffect(() => {setAiDrillDown(false);}, [thread.id, layout]);
  // Switch to the right tab when an external focus signal arrives
  useEffect(() => {
    if (focus && focus.key && focus.ts) {
      if (focus.key === "coaching") setActiveTab("coaching");else
      setActiveTab("ai");
    }
  }, [focus && focus.ts]);
  // Accordion state — ABOUT defaults open. The previous layout has AI as an
  // accordion (open by default when forced); the current layout has it as a
  // 2-line summary above the stack instead.
  const [openSections, setOpenSections] = useState({
    about: true,
    ai: forceAI,
    tags: false,
    integrations: false,
    activity: false,
    past: true,
    sdCampaigns: false,
    admin: false,
    lines: false
  });
  const toggleSection = (key) => setOpenSections((s) => ({ ...s, [key]: !s[key] }));
  // Auto-tag accept/dismiss state — lifted up so it persists across tab switches
  // (was previously local to ContactAIInsightsBrief). Reset when thread changes.
  // AI tag suggestions sit one click deep behind the Auto Tags toggle.
  const [autoTagsOpen, setAutoTagsOpen] = useState(false);
  /* Which integration cards are expanded, by product id. Nothing open to
     start with: the section is a list of the products this contact is in, and
     which of them an agent wants to read is theirs to say. Reset with the
     thread, since the answer is about that contact. */
  const [openCrms, setOpenCrms] = useState({});
  useEffect(() => {setOpenCrms({});}, [thread.id]);
  const linkedCrms = useMemo(() => crmsLinkedTo(thread), [thread.id]);
  const [acceptedTags, setAcceptedTags] = useState([]);
  const [dismissedTags, setDismissedTags] = useState([]);
  useEffect(() => {setAcceptedTags([]);setDismissedTags([]);}, [thread.id]);
  const acceptTag = (label) => {
    setAcceptedTags((prev) => prev.includes(label) ? prev : [...prev, label]);
    if (window.showAppToast) window.showAppToast(`Tag "${label}" added`);
  };
  const dismissTag = (label) => {
    setDismissedTags((prev) => prev.includes(label) ? prev : [...prev, label]);
  };
  const showToast = (msg, kind = "ok") => {if (window.showAppToast) window.showAppToast(msg, kind);};

  // Leave Conversations for this contact's record page. The bridge lives in
  // index.html because it has to drive the sidebar and read JC_CONTACTS, both
  // of which belong to the host page rather than to the messaging tree.
  const openContactRecord = () => {
    const opened = window.jcOpenContactRecord && window.jcOpenContactRecord({
      name: thread.name, phone: thread.phone,
      company: thread.company, tags: thread.tags
    });
    if (!opened) showToast("Couldn't open the contact record", "warn");
  };
  useEffect(() => {
    if (!moreOpen) return;
    const close = (e) => {if (moreRef.current && !moreRef.current.contains(e.target)) setMoreOpen(false);};
    document.addEventListener("mousedown", close);
    return () => document.removeEventListener("mousedown", close);
  }, [moreOpen]);

  // Show AI insights in this panel when forced or enabled.
  const showAI = forceAI || showAIInsights;
  const ins = useMemo(() => buildInsights(thread), [thread.id]);
  const tagCount = (thread.tags || []).length;
  const isCurrent = layout === "current";
  // Auto-tag suggestions from AI activity (mirrors what was in the AI tab).
  // Filter out any that match an existing tag, were dismissed, or were accepted.
  const aActivity = useMemo(() => window.buildAgentActivity ? window.buildAgentActivity(thread) : null, [thread.id]);
  const allAutoTags = aActivity && aActivity.tags || [];
  const existingTags = thread.tags || [];

  // Active integrations — deterministic random subset per thread.
  // Each conversation shows 0–4 of these; consistent across renders for a given thread.
  const activeIntegrations = useMemo(() => {
    const company = (thread.company || "Acme").toLowerCase().replace(/\s+/g, "");
    const slug = thread.name.split(" ")[0].toLowerCase();
    const ALL = [
    { key: "pipedrive", name: "Pipedrive", glyph: "P", bg: "linear-gradient(135deg, #12B76A 0%, #0F9659 100%)", detail: `Deal · ${thread.name}` },
    { key: "hubspot", name: "HubSpot", glyph: "H", bg: "linear-gradient(135deg, #FF7A59 0%, #E8593E 100%)", detail: `${slug}@${company}.com` },
    { key: "salesforce", name: "Salesforce", glyph: "S", bg: "linear-gradient(135deg, #00A1E0 0%, #0079B5 100%)", detail: `Account · ${thread.company || "—"}` },
    { key: "intercom", name: "Intercom", glyph: "I", bg: "linear-gradient(135deg, #1F8DED 0%, #1568B5 100%)", detail: `2 open conversations` },
    { key: "zendesk", name: "Zendesk", glyph: "Z", bg: "linear-gradient(135deg, #03363D 0%, #021C20 100%)", detail: `1 ticket open` },
    { key: "gong", name: "Gong", glyph: "G", bg: "linear-gradient(135deg, #8C52FF 0%, #6932E0 100%)", detail: `4 calls recorded` },
    { key: "slack", name: "Slack", glyph: "#", bg: "linear-gradient(135deg, #4A154B 0%, #350D36 100%)", detail: `#cs-${slug}` },
    { key: "stripe", name: "Stripe", glyph: "$", bg: "linear-gradient(135deg, #635BFF 0%, #4D45D6 100%)", detail: `Customer · MRR $${(thread.id || "x").charCodeAt(0) * 73 % 9000 + 800}` }];

    // Deterministic hash from thread.id
    const id = String(thread.id || "");
    let seed = 0;
    for (let i = 0; i < id.length; i++) seed = seed * 31 + id.charCodeAt(i) >>> 0;
    return ALL.filter((_, i) => {
      const bit = seed >>> i & 1;
      // Bias roughly: each integration ~50% chance
      return bit === 1;
    });
  }, [thread.id, thread.company, thread.name]);
  const toneStyle = (tone) => {
    if (tone === "warn") return { bg: "#FEF3F2", color: "#B42318", border: "#FECDCA" };
    if (tone === "good") return { bg: "#ECFDF3", color: "#067647", border: "#ABEFC6" };
    return { bg: "#EEF4FF", color: "#3538CD", border: "#C7D7FE" };
  };
  const visibleAutoTags = allAutoTags.
  filter((t) => !dismissedTags.includes(t.label)).
  filter((t) => !acceptedTags.includes(t.label)).
  filter((t) => !existingTags.includes(t.label)).
  slice(0, 2);

  // Drill-down view (current layout only) — replaces contact details with the AI brief + back button
  if (isCurrent && aiDrillDown) {
    return (
      <div style={{
        width: "100%", height: "100%", background: "#FFFFFF",
        display: "flex", flexDirection: "column", overflow: "hidden"
      }}>
        <div style={{
          display: "flex", alignItems: "center", gap: 8,
          padding: "10px 12px", borderBottom: "1px solid #E9D7FE",
          background: "linear-gradient(135deg, #FAF5FF 0%, #F4EBFF 100%)",
          flexShrink: 0
        }}>
          <button
            onClick={() => setAiDrillDown(false)}
            title="Back to contact details"
            style={{
              display: "inline-flex", alignItems: "center", gap: 4,
              padding: "5px 8px 5px 6px", borderRadius: 6,
              border: "1px solid transparent", background: "transparent",
              cursor: "pointer", fontFamily: "inherit",
              fontSize: 12, fontWeight: 500, color: "#6941C6"
            }}
            onMouseEnter={(e) => e.currentTarget.style.background = "rgba(127,86,217,0.1)"}
            onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
            <I.chevRight size={14} stroke="#6941C6" style={{ transform: "rotate(180deg)" }} />
            Back
          </button>
          <I.sparkle size={13} stroke="#7F56D9" style={{ marginLeft: 2 }} />
          <div style={{ fontSize: 13, fontWeight: 600, color: "#42307D", flex: 1 }}>AI insights</div>
          <span style={{
            fontSize: 9, fontWeight: 600, color: "#7F56D9",
            background: "#FFFFFF", padding: "2px 6px", borderRadius: 500, letterSpacing: ".04em"
          }}>BETA</span>
          {onClose &&
          <button onClick={onClose} title="Close"
          style={{
            width: 26, height: 26, borderRadius: 6, border: "none", background: "transparent",
            cursor: "pointer", padding: 0, display: "inline-flex", alignItems: "center", justifyContent: "center"
          }}
          onMouseEnter={(e) => e.currentTarget.style.background = "rgba(127,86,217,0.1)"}
          onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
              <I.x size={14} stroke="#7F56D9" />
            </button>
          }
        </div>
        <div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column" }}>
          <AIInsightsBrief ins={ins} />
        </div>
      </div>);

  }

  return (
    <div style={{
      width: "100%", height: "100%", background: "#FFFFFF",
      display: "flex", flexDirection: "column", overflow: "hidden", position: "relative"
    }}>
      {/* Sheet header — titles the panel and owns the close control, which used
          to float over the contact card.

          Not in the tabbed sheet. There the strip below already has a tab
          called Contact Info, so the header was naming the panel the same
          thing one line above it, and the two together cost 49px of the sheet
          before a word about the contact. The close control it owns moves onto
          the contact card rather than going with it. */}
      {showHeader && !tabbed &&
      <div style={{
        display: "flex", alignItems: "center", justifyContent: "space-between",
        padding: "12px 12px 12px 20px", borderBottom: "1px solid #E4E7EC", flexShrink: 0
      }}>
        <span style={{ fontSize: 15, fontWeight: 600, color: "#101828" }}>Contact Info</span>
        {onClose &&
        <button onClick={onClose} title="Close" aria-label="Close contact info"
        style={{
          width: 28, height: 28, borderRadius: 6, border: "none", background: "transparent",
          cursor: "pointer", padding: 0, display: "inline-flex", alignItems: "center", justifyContent: "center"
        }}
        onMouseEnter={(e) => e.currentTarget.style.background = "#F2F4F7"}
        onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
            <I.x size={15} stroke="#667085" />
          </button>
        }
      </div>
      }

      {/* Contact identity — name and primary number only, with the CRMs this
          contact is connected to on the right. */}
      <div style={{
        borderBottom: "1px solid #E4E7EC", flexShrink: 0, borderWidth: "0px", borderBottomStyle: "solid", borderBottomColor: "rgb(228, 231, 236)", padding: "20px 20px 3px"
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: 14, marginBottom: 16 }}>
          <div style={{
            width: 56, height: 56, borderRadius: 500,
            color: "#175CD3",
            display: "flex", alignItems: "center", justifyContent: "center",
            fontSize: 18, fontWeight: 600, flexShrink: 0,
            letterSpacing: ".02em", background: "#EFF8FF"
          }}>{thread.avatar}</div>
          <div style={{ flex: 1, minWidth: 0 }}>
            {/* The name is the way through to the contact's own record. It
                stays a name rather than gaining a button or a chevron — an
                icon here would eat into the width the name truncates at, and
                on hover — so it takes the link idiom instead: brand blue and
                underlined, with the app's tooltip saying where it goes. */}
            <button
              type="button"
              onClick={openContactRecord}
              data-jc-tip="View contact details"
              aria-label={`View contact details for ${thread.name}`}
              style={{
                display: "block", maxWidth: "100%", padding: 0, margin: 0,
                border: 0, background: "transparent", cursor: "pointer",
                fontFamily: "inherit", textAlign: "left",
                fontSize: 16, fontWeight: 600, lineHeight: 1.3,
                color: nameHover ? "#004CE6" : "#101828",
                textDecoration: nameHover ? "underline" : "none",
                textDecorationThickness: 1, textUnderlineOffset: 2,
                whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
                transition: "color .12s"
              }}
              onMouseEnter={() => setNameHover(true)}
              onMouseLeave={() => setNameHover(false)}
              onFocus={() => setNameHover(true)}
              onBlur={() => setNameHover(false)}>
              {thread.name}
            </button>
            <div style={{
              fontSize: 13, color: "#667085", marginTop: 2, lineHeight: 1.35,
              whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"
            }}>{thread.phone}</div>
          </div>
          {/* Which products the contact is linked to. Left out of the tabbed
              sheet: the Contact Info tab carries an Integrations section that
              names every one of them and says what is in it, so a row of
              marks up here would be the same list twice — and the shorter,
              less useful of the two. */}
          {!tabbed && <CrmStack crms={crmsForThread(thread)} />}

          {/* With no header there is nowhere else for it, and the tabbed sheet
              left this end of the row free when the CRM stack came out. Top of
              the row rather than centred on the 56px avatar, which is where a
              close control belongs and where the one in the header sat; pulled
              right by half its own padding so the glyph lines up with the
              card's right edge instead of being inset from it by its hit
              area. */}
          {tabbed && onClose &&
          <button onClick={onClose} title="Close" aria-label="Close contact info"
          style={{
            width: 28, height: 28, marginRight: -6, alignSelf: "flex-start",
            borderRadius: 6, flexShrink: 0,
            border: "none", background: "transparent", cursor: "pointer", padding: 0,
            display: "inline-flex", alignItems: "center", justifyContent: "center"
          }}
          onMouseEnter={(e) => e.currentTarget.style.background = "#F2F4F7"}
          onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
            <I.x size={15} stroke="#667085" />
          </button>
          }
        </div>

        {/* Action buttons — outlined blue Call / Message + square more — hidden per latest review */}
        <div style={{ display: "none", gap: 8, height: "32px", padding: "12px 0px 0px" }} data-comment-anchor="80a7efbb56-div-206-9">
          <button title="Call" disabled={!canEdit}
          onClick={() => canEdit && window.startCall && window.startCall(thread)}
          style={{ ...contactActionBtn(canEdit), height: "32px", borderRadius: "8px" }}
          onMouseEnter={(e) => {if (canEdit) e.currentTarget.style.background = "#EFF4FF";}}
          onMouseLeave={(e) => {if (canEdit) e.currentTarget.style.background = "#FFFFFF";}}>
            <I.phone size={15} stroke={canEdit ? "#004CE6" : "#98A2B3"} />
            <span style={{ fontSize: 14, fontWeight: 500, color: canEdit ? "#004CE6" : "#98A2B3" }}>Call</span>
          </button>
          <button title="Message" disabled={!canEdit}
          style={{ ...contactActionBtn(canEdit), height: "32px", borderRadius: "8px" }}
          onMouseEnter={(e) => {if (canEdit) e.currentTarget.style.background = "#EFF4FF";}}
          onMouseLeave={(e) => {if (canEdit) e.currentTarget.style.background = "#FFFFFF";}}>
            <I.chat size={15} stroke={canEdit ? "#004CE6" : "#98A2B3"} />
            <span style={{ fontSize: 14, fontWeight: 500, color: canEdit ? "#004CE6" : "#98A2B3" }}>Message</span>
          </button>
          <div ref={moreRef} style={{ position: "relative", flex: "0 0 auto" }}>
            <button title="More" onClick={() => canEdit && setMoreOpen((o) => !o)} disabled={!canEdit}
            style={{
              display: "inline-flex", alignItems: "center", justifyContent: "center",
              padding: 0,
              border: `1px solid ${moreOpen ? "#004CE6" : "#B2DDFF"}`,
              background: moreOpen ? "#EFF4FF" : canEdit ? "#FFFFFF" : "#F9FAFB",
              cursor: canEdit ? "pointer" : "not-allowed", fontFamily: "inherit", width: "32px", height: "32px", borderRadius: "8px"
            }}
            onMouseEnter={(e) => {if (canEdit && !moreOpen) e.currentTarget.style.background = "#EFF4FF";}}
            onMouseLeave={(e) => {if (canEdit && !moreOpen) e.currentTarget.style.background = "#FFFFFF";}}>
              <svg width="3" height="15" viewBox="0 0 3 15" fill="none" xmlns="http://www.w3.org/2000/svg">
                <circle cx="1.5" cy="1.5" r="1.5" fill={canEdit ? "#004CE6" : "#98A2B3"} />
                <circle cx="1.5" cy="7.5" r="1.5" fill={canEdit ? "#004CE6" : "#98A2B3"} />
                <circle cx="1.5" cy="13.5" r="1.5" fill={canEdit ? "#004CE6" : "#98A2B3"} />
              </svg>
            </button>
            {moreOpen &&
            <div style={{
              position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 30,
              minWidth: 200, 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,
              textAlign: "left"
            }}>
                {(window.CONTACT_MENU_ITEMS || []).map((item) => {
                const IconC = item.icon;
                return (
                  <button key={item.id}
                  onClick={() => {setMoreOpen(false);showToast(item.toast, item.destructive ? "warn" : "ok");}}
                  style={{
                    display: "flex", alignItems: "center", gap: 10, padding: "8px 10px", borderRadius: 4,
                    background: "transparent", border: "none", cursor: "pointer",
                    fontSize: 13, fontWeight: 500, color: item.destructive ? "#B42318" : "#344054",
                    width: "100%", fontFamily: "inherit", textAlign: "left"
                  }}
                  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>

      {/* A strip only when there is more than one thing to switch between. In
          the single pane that means the admin-only Quality tab and everything
          else; in the 2-tabs panel the sheet's own two halves come first, and
          Quality is a third beside them. "Contact" in the single pane stays
          active for any non-Quality tab, since the pane behind it is the whole
          sheet either way. */}
      {(tabbed || showCoachingTab) &&
      <div style={{
        display: "flex", padding: "0 16px",
        borderBottom: "1px solid #E4E7EC", flexShrink: 0, gap: "28px"
      }}>
        {tabbed &&
        <TabBtn
          active={activeTab === "ai"}
          onClick={() => setActiveTab("ai")}
          icon={<I.sparkle size={12} stroke={activeTab === "ai" ? "#004CE6" : "#667085"} />}
          label="AI insights"
          accent="#004CE6" />
        }
        <TabBtn
          active={tabbed ? activeTab === "details" : activeTab !== "coaching"}
          onClick={() => setActiveTab("details")}
          icon={<I.user size={12} stroke={(tabbed ? activeTab === "details" : activeTab !== "coaching") ? "#101828" : "#667085"} />}
          label={tabbed ? "Contact Info" : "Contact"}
          accent="#101828" />
        {showCoachingTab &&
        <TabBtn
          active={activeTab === "coaching"}
          onClick={() => setActiveTab("coaching")}
          icon={<I.zap size={12} stroke={activeTab === "coaching" ? "#B54708" : "#667085"} />}
          label="Quality"
          accent="#B54708" />
        }
      </div>
      }

      <div style={{ flex: 1, overflowY: "auto" }}>
      {/* Single pane: the AI brief (summary + suggested actions) runs straight
          into the contact's own fields. Tabbed: the same two blocks, one at a
          time. Both halves keep their own markup either way — the only things
          that move are the divider between them, which has nothing to divide
          once they are on separate tabs, and the top padding the strip now
          provides. */}
      {activeTab !== "coaching" &&
        <div>
        {(!tabbed || activeTab === "ai") &&
        <ContactAIInsightsBrief
          thread={thread} ins={ins} onOpenAgent={onOpenAgent} focus={focus}
          acceptedTags={acceptedTags} dismissedTags={dismissedTags}
          onAcceptTag={acceptTag} onDismissTag={dismissTag} />
        }

        {(!tabbed || activeTab === "details") &&
        <div style={{ padding: tabbed ? "18px 20px 20px" : "0 20px 20px", display: "flex", flexDirection: "column", gap: 18 }}>
        {!tabbed && <SheetDivider />}

        {/* Tags — a plain block rather than an accordion: the tags themselves
            are the point and shouldn't need a click to see. Only the AI
            suggestions sit one click deep, behind the Auto Tags toggle. */}
        <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
          <SheetSectionHeading
            count={tagCount + acceptedTags.filter((l) => !existingTags.includes(l)).length}
            action={canEdit &&
            <button onClick={() => showToast("Add tag — coming soon")} data-jc-tip="Add tag"
            style={{
              width: 24, height: 24, borderRadius: 6, border: "1px solid #E4E7EC",
              background: "#FFFFFF", display: "inline-flex", alignItems: "center",
              justifyContent: "center", cursor: "pointer", padding: 0
            }}
            onMouseEnter={(e) => e.currentTarget.style.background = "#F9FAFB"}
            onMouseLeave={(e) => e.currentTarget.style.background = "#FFFFFF"}>
                <I.plus size={12} stroke="#667085" />
              </button>
            }>Tags</SheetSectionHeading>
          <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
            {/* Existing + accepted tags */}
            <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
              {tagCount === 0 && acceptedTags.length === 0 ?
                <div style={{ fontSize: 14, color: "#98A2B3", fontStyle: "italic" }}>No tags yet</div> :
                <>
                  {existingTags.map((t) =>
                  <span key={t} style={{
                    fontSize: 12.5, fontWeight: 500, lineHeight: "18px",
                    background: t === "VIP" ? "#FFFAEB" : t === "Churn risk" ? "#FEF3F2" : "#EEF4FF",
                    color: t === "VIP" ? "#B54708" : t === "Churn risk" ? "#D92D20" : "#3538CD",
                    padding: "4px 11px", borderRadius: 500
                  }}>{t}</span>
                  )}
                  {acceptedTags.filter((l) => !existingTags.includes(l)).map((t) =>
                  <span key={t} style={{
                    fontSize: 12.5, fontWeight: 500, lineHeight: "18px",
                    background: "#ECFDF3", color: "#067647",
                    padding: "4px 11px", borderRadius: 500,
                    display: "inline-flex", alignItems: "center", gap: 4
                  }}>
                      <I.check size={10} stroke="#067647" />{t}
                    </span>
                  )}
                </>
                }
            </div>

            {/* Auto Tags — AI suggestions, one click deep so they don't compete
                with the tags actually applied to the contact. */}
            {visibleAutoTags.length > 0 &&
              <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                <button
                  onClick={() => setAutoTagsOpen((o) => !o)}
                  aria-expanded={autoTagsOpen}
                  style={{
                    alignSelf: "flex-start",
                    display: "inline-flex", alignItems: "center", gap: 6,
                    padding: "4px 10px 4px 8px", borderRadius: 500,
                    border: "1px solid #E4DFFE", background: autoTagsOpen ? "#F4F3FF" : "#FFFFFF",
                    fontSize: 11, fontWeight: 600, color: "#5925DC",
                    letterSpacing: ".02em", cursor: "pointer", fontFamily: "inherit"
                  }}
                  onMouseEnter={(e) => e.currentTarget.style.background = "#F4F3FF"}
                  onMouseLeave={(e) => {e.currentTarget.style.background = autoTagsOpen ? "#F4F3FF" : "#FFFFFF";}}>
                  <I.sparkle size={11} stroke="#7F56D9" />
                  Auto Tags
                  <span style={{
                    fontSize: 10, fontWeight: 700, background: "#EDE9FE",
                    borderRadius: 500, padding: "1px 6px", color: "#5925DC"
                  }}>{visibleAutoTags.length}</span>
                  <span style={{
                    display: "inline-flex",
                    transform: autoTagsOpen ? "rotate(180deg)" : "none",
                    transition: "transform .15s ease"
                  }}>
                    <I.chevDown size={12} stroke="#7F56D9" />
                  </span>
                </button>
                <div style={{ display: autoTagsOpen ? "flex" : "none", flexDirection: "column", gap: 6 }}>
                  {visibleAutoTags.map((t, i) => {
                    const ts = toneStyle(t.tone);
                    // Deterministic source-message index: pick a non-date message
                    // index from the thread keyed off the tag label + thread id.
                    const sourceIdx = (() => {
                      const msgs = window.THREAD_MESSAGES && window.THREAD_MESSAGES[thread.id] || [];
                      const candidates = msgs.
                      map((m, idx) => ({ m, idx })).
                      filter(({ m }) => m.kind !== "date");
                      if (!candidates.length) return null;
                      const seedSrc = String(t.label) + "::" + String(thread.id);
                      let s = 0;
                      for (let j = 0; j < seedSrc.length; j++) s = s * 31 + seedSrc.charCodeAt(j) >>> 0;
                      return candidates[s % candidates.length].idx;
                    })();
                    // Same labelling the summary's citations use — see evLabel.
                    const sourceLabel = (() => {
                      if (sourceIdx == null) return null;
                      const msgs = window.THREAD_MESSAGES && window.THREAD_MESSAGES[thread.id] || [];
                      const m = msgs[sourceIdx];
                      if (!m) return null;
                      return m.time ? `${evLabel(m)} · ${m.time}` : evLabel(m);
                    })();
                    return (
                      <div key={i} style={{
                        display: "flex", flexDirection: "column", gap: 6,
                        padding: "8px 8px", borderRadius: 6,
                        background: "#FAFAFF", border: "1px solid #ECECFB"
                      }}>
                        {/* Row 1: tag pill alone */}
                        <div style={{ display: "flex", alignItems: "center" }}>
                          <span style={{
                            fontSize: 11, fontWeight: 600, color: ts.color, background: ts.bg,
                            border: `1px solid ${ts.border}`,
                            padding: "2px 8px", borderRadius: 500, flexShrink: 0
                          }}>{t.label}</span>
                        </div>
                        {/* Row 2: reason text + accept/dismiss actions */}
                        <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0px 0px 0px 4px" }}>
                          <span style={{ fontSize: 11, color: "#667085", lineHeight: 1.4, flex: 1, minWidth: 0 }}>
                            {t.reason}
                          </span>
                          <button
                            onClick={() => acceptTag(t.label)}
                            title="Accept"
                            style={{
                              width: 24, height: 24, borderRadius: 6,
                              border: "1px solid #B2DDFF", background: "#FFFFFF",
                              display: "inline-flex", alignItems: "center", justifyContent: "center",
                              cursor: "pointer", padding: 0, flexShrink: 0
                            }}
                            onMouseEnter={(e) => e.currentTarget.style.background = "#EFF4FF"}
                            onMouseLeave={(e) => e.currentTarget.style.background = "#FFFFFF"}>
                            <I.check size={12} stroke="#004CE6" />
                          </button>
                          <button
                            onClick={() => dismissTag(t.label)}
                            title="Dismiss"
                            style={{
                              width: 24, height: 24, borderRadius: 6,
                              border: "1px solid #E4E7EC", background: "#FFFFFF",
                              display: "inline-flex", alignItems: "center", justifyContent: "center",
                              cursor: "pointer", padding: 0, flexShrink: 0
                            }}
                            onMouseEnter={(e) => e.currentTarget.style.background = "#F9FAFB"}
                            onMouseLeave={(e) => e.currentTarget.style.background = "#FFFFFF"}>
                            <I.x size={12} stroke="#98A2B3" />
                          </button>
                        </div>
                        {sourceIdx != null && sourceLabel &&
                        <button
                          onClick={() => window.dispatchEvent(new CustomEvent("convo:scrollToMsg", { detail: { msgIdx: sourceIdx } }))}
                          style={{
                            alignSelf: "flex-start",
                            display: "inline-flex", alignItems: "center", gap: 4,
                            padding: "2px 6px", marginLeft: 2,
                            border: "none", background: "transparent",
                            fontSize: 11, fontWeight: 500, color: "#5925DC",
                            fontFamily: "inherit", cursor: "pointer", textDecoration: "underline", margin: "0px"
                          }}
                          title="Jump to source message">
                            <I.chevRight size={10} stroke="#5925DC" />
                            View source · {sourceLabel}
                          </button>
                        }
                      </div>);

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

        <SheetDivider />

        {/* Contact fields — icon + label on the left, value on the right, on a
            tight row rhythm rather than a loose stack. */}
        <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
          <SheetSectionHeading>Contact Fields</SheetSectionHeading>
          <div style={{
            display: "grid", gridTemplateColumns: "minmax(104px, 38%) 1fr",
            columnGap: 12, alignItems: "center"
          }}>
            {[
            { label: "Plan", value: "Growth · 40 seats", Icon: I.zap },
            { label: "Contract value", value: "$96,000 ARR", Icon: I.tag },
            { label: "Renewal", value: "in 47 days", color: "#D92D20", Icon: I.clock },
            { label: "CSM owner", value: "You (Jordan R.)", Icon: I.user }].
            map((r) =>
            <React.Fragment key={r.label}>
                <span style={{
                display: "flex", alignItems: "center", gap: 8, minHeight: 34,
                fontSize: 13, color: "#667085"
              }}>
                  <r.Icon size={14} stroke="#98A2B3" />{r.label}
                </span>
                <span style={{
                display: "flex", alignItems: "center", minHeight: 34,
                fontSize: 13, fontWeight: 500, color: r.color || "#101828"
              }}>{r.value}</span>
              </React.Fragment>
            )}
          </div>
        </div>

        {/* Integrations — below the contact's own fields, because that is the
            order of authority: what JustCall knows about this person, then
            what everywhere else does. */}
        {tabbed && linkedCrms.length > 0 &&
        <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
          <SheetSectionHeading count={linkedCrms.length}>Integrations</SheetSectionHeading>
          {linkedCrms.map((app) =>
          <IntegrationCard
            key={app.id}
            app={app}
            record={crmRecordFor(app, thread)}
            open={!!openCrms[app.id]}
            onToggle={() => setOpenCrms((o) => ({ ...o, [app.id]: !o[app.id] }))}
            onOpenIn={() => showToast(`Opening this contact in ${app.name}`)} />
          )}
        </div>
        }

        {canViewBilling &&
          <Accordion
            icon={I.shield}
            iconColor="#5925DC"
            title="Admin"
            open={openSections.admin}
            onToggle={() => toggleSection("admin")}
            tone="admin">
            <Row icon={I.clock} label="First contact" value="Mar 12, 2026" />
            <Row icon={I.tag} label="LTV" value="$42,800" />
            <Row icon={I.bell} label="Last touch" value="2 min ago" />
          </Accordion>
          }

        </div>
        }
      </div>
        }

      {/* Coaching tab — admin-only suggestions for coaching the agent */}
      {activeTab === "coaching" &&
        <CoachingTab thread={thread} />
        }
      </div>
    </div>);

};

// 2-line AI summary card — sits between the contact header and the accordion stack.
// Clicking opens the AI insights drill-down which takes over the entire pane.

// Host that hides the card while the floating thread-summary widget is visible
// (avoids showing the same summary in two places).
const AISummaryCardHost = ({ ins, onOpen }) => {
  const [widgetVisible, setWidgetVisible] = useState(
    typeof window !== "undefined" && !!window.__aiSummaryWidgetVisible
  );
  useEffect(() => {
    const onState = (e) => setWidgetVisible(!!(e.detail && e.detail.visible));
    window.addEventListener("aiSummaryWidget:state", onState);
    setWidgetVisible(!!window.__aiSummaryWidgetVisible);
    return () => window.removeEventListener("aiSummaryWidget:state", onState);
  }, []);
  if (widgetVisible) return null;
  return (
    <div style={{ padding: "14px 16px 4px" }}>
      <AISummaryCard ins={ins} onOpen={onOpen} />
    </div>);

};

const AISummaryCard = ({ ins, onOpen }) => {
  if (ins.empty) {
    return (
      <div style={{
        background: "#FAFAFB", border: "1px dashed #E4E7EC", borderRadius: 8,
        padding: "10px 12px", display: "flex", alignItems: "center", gap: 10
      }}>
        <div style={{
          width: 26, height: 26, borderRadius: 8, background: "#FFFFFF",
          border: "1px solid #EAECF0", display: "inline-flex",
          alignItems: "center", justifyContent: "center", flexShrink: 0
        }}>
          <I.sparkle size={13} stroke="#98A2B3" />
        </div>
        <div style={{ fontSize: 12, color: "#667085", lineHeight: 1.4 }}>
          Not enough conversation yet to summarize.
        </div>
      </div>);

  }
  return (
    <button
      onClick={onOpen}
      style={{
        width: "100%", textAlign: "left", cursor: "pointer", fontFamily: "inherit",
        background: "linear-gradient(135deg, #FAF5FF 0%, #F4EBFF 100%)",
        border: "1px solid #E9D7FE", borderRadius: 8,
        padding: "10px 12px", display: "flex", flexDirection: "column", gap: 6,
        transition: "border-color .15s, box-shadow .15s"
      }}
      onMouseEnter={(e) => {
        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 = "#E9D7FE";
        e.currentTarget.style.boxShadow = "none";
      }}>
      <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
        <I.sparkle size={11} stroke="#7F56D9" />
        <div style={{
          fontSize: 10, fontWeight: 600, color: "#7F56D9",
          textTransform: "uppercase", letterSpacing: ".06em"
        }}>AI insights</div>
        <span style={{
          marginLeft: "auto", fontSize: 11, color: "#7F56D9", fontWeight: 600,
          display: "inline-flex", alignItems: "center", gap: 2
        }}>
          Open <I.chevRight size={11} stroke="#7F56D9" />
        </span>
      </div>
      <div style={{
        fontSize: 12, color: "#42307D", lineHeight: 1.5, fontWeight: 500,
        display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical", overflow: "hidden"
      }}>{ins.summary || ins.headline}</div>
      <div style={{
        fontSize: 10, color: "#7F56D9", opacity: 0.75,
        marginTop: 2, fontWeight: 500
      }}>
        Last updated just now
      </div>
    </button>);

};

// Reusable accordion — header (chevron + icon + uppercase title + count + action) and collapsible body.
// Matches the reference: light grey header with dark uppercase label, optional count badge,
// optional + action on the right. Body sits in a bordered card below when open.
const Accordion = ({ icon: IconC, iconColor = "#004CE6", title, count, badge, open, onToggle, action, children, tone }) => {
  const isAdminTone = tone === "admin";
  return (
    <div style={{ display: "flex", flexDirection: "column" }}>
      {/* Header — clicking anywhere on it toggles the section */}
      <div
        onClick={onToggle}
        style={{ ...{
            display: "flex", alignItems: "center", gap: 8,
            padding: "8px 12px",
            background: isAdminTone ? "#F4EBFF" : "#F9FAFB",
            border: `1px solid ${isAdminTone ? "#E9D7FE" : "#E4E7EC"}`,
            borderRadius: open ? "6px 6px 0 0" : 6,
            cursor: "pointer", userSelect: "none"
          }, borderRadius: "8px 8px 0px 0px" }}>
        <span style={{
          display: "inline-flex", alignItems: "center", justifyContent: "center",
          width: 14, height: 14, transition: "transform .15s",
          transform: open ? "rotate(90deg)" : "rotate(0deg)"
        }}>
          <I.chevRight size={11} stroke="#667085" />
        </span>
        <span style={{
          ...SHEET_HEADING, flex: 1,
          ...(isAdminTone ? { color: "#5925DC" } : null)
        }}>{title}</span>
        {typeof count === "number" &&
        <span style={{
          fontSize: 10, fontWeight: 600, color: "#475467",
          background: "#FFFFFF", border: "1px solid #E4E7EC",
          padding: "1px 7px", borderRadius: 500, minWidth: 18, textAlign: "center"
        }}>{count}</span>
        }
        {badge &&
        <span style={{
          fontSize: 9, fontWeight: 600, color: "#7F56D9",
          background: "#F4EBFF", padding: "2px 6px", borderRadius: 500, letterSpacing: ".04em"
        }}>{badge}</span>
        }
        {action &&
        <button
          onClick={(e) => {e.stopPropagation();action.onClick && action.onClick();}}
          title="Add"
          style={{
            width: 22, height: 22, display: "inline-flex", alignItems: "center", justifyContent: "center",
            borderRadius: 4, border: "none", background: "transparent", cursor: "pointer", padding: 0
          }}
          onMouseEnter={(e) => e.currentTarget.style.background = "#FFFFFF"}
          onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
            <action.icon size={12} stroke="#667085" />
          </button>
        }
      </div>
      {/* Body */}
      {open &&
      <div style={{
        padding: "12px 14px",
        background: "#FFFFFF",
        border: `1px solid ${isAdminTone ? "#E9D7FE" : "#E4E7EC"}`,
        borderTop: "none", borderRadius: "0px 0px 8px 6px"

      }} data-comment-anchor="1824055e83-div-702-7">
          {children}
        </div>
      }
    </div>);

};

// AI insights body — used inside the AI accordion. Shows the same brief that
// AIInsightsBrief renders in the modal, but trimmed for the narrower panel.
const AIInsightsAccordionBody = ({ thread, ins, onOpenAgent }) => {
  if (ins.empty) {
    return (
      <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "4px 2px" }}>
        <div style={{
          width: 28, height: 28, borderRadius: 8, background: "#FAFAFB",
          border: "1px solid #EAECF0", display: "inline-flex",
          alignItems: "center", justifyContent: "center", flexShrink: 0
        }}>
          <I.sparkle size={14} stroke="#98A2B3" />
        </div>
        <div style={{ fontSize: 12, color: "#667085", lineHeight: 1.4 }}>
          Not enough conversation yet to summarize.
        </div>
      </div>);

  }
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
      <div style={{
        background: "linear-gradient(135deg, #FAF5FF 0%, #F4EBFF 100%)",
        border: "1px solid #E9D7FE", borderRadius: 8,
        padding: "10px 12px", fontSize: 12, lineHeight: 1.5, color: "#42307D"
      }}>{ins.headline}</div>
      <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
        <span style={{
          fontSize: 10, fontWeight: 500, color: "#5925DC",
          background: "#FFFFFF", border: "1px solid #E9D7FE",
          padding: "2px 7px", borderRadius: 500
        }}>{ins.sentiment}</span>
        <span style={{
          fontSize: 10, fontWeight: 500, color: "#5925DC",
          background: "#FFFFFF", border: "1px solid #E9D7FE",
          padding: "2px 7px", borderRadius: 500
        }}>{ins.stage}</span>
      </div>
      <div>
        <div className="t-label-xs" style={{ color: "#98A2B3", marginBottom: 6 }}>Key context</div>
        <ul style={{ margin: 0, padding: 0, listStyle: "none", display: "flex", flexDirection: "column", gap: 5 }}>
          {ins.bullets.slice(0, 3).map((b, i) =>
          <li key={i} style={{ display: "flex", gap: 8, fontSize: 12, color: "#344054", lineHeight: 1.45 }}>
              <span style={{
              width: 4, height: 4, borderRadius: 500, background: "#7F56D9",
              marginTop: 7, flexShrink: 0
            }} />
              <span>{b}</span>
            </li>
          )}
        </ul>
      </div>
      {onOpenAgent &&
      <button
        onClick={() => onOpenAgent("brief")}
        style={{
          display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 5,
          width: "100%", padding: "7px 10px",
          borderRadius: 6, border: "1px solid #E9D7FE",
          background: "#FFFFFF", color: "#7F56D9",
          fontSize: 12, fontWeight: 600, cursor: "pointer", fontFamily: "inherit"
        }}
        onMouseEnter={(e) => e.currentTarget.style.background = "#FAF5FF"}
        onMouseLeave={(e) => e.currentTarget.style.background = "#FFFFFF"}>
          View Insights <I.chevRight size={11} stroke="#7F56D9" />
        </button>
      }
    </div>);

};

const Row = ({ icon: IconC, label, value, placeholder, editable, valueColor, labelWidth = 62 }) =>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0" }}>
    <span style={{ width: 12, height: 12, flexShrink: 0, display: "inline-flex" }} className="convoRowIcon"><IconC size={12} stroke="#98A2B3" /></span>
    <span style={{ fontSize: 12, color: "#667085", width: labelWidth, flexShrink: 0 }}>{label}</span>
    <span style={{ fontSize: 12, color: valueColor || (placeholder ? "#98A2B3" : "#101828"), flex: 1, fontWeight: placeholder ? 400 : 500 }}>{value}</span>
  </div>;


const Stat = ({ value, label }) =>
<div style={{
  border: "1px solid #E4E7EC", borderRadius: 6, padding: "8px 10px", background: "#FFFFFF"
}}>
    <div style={{ fontSize: 18, fontWeight: 600, color: "#101828", lineHeight: 1.1 }}>{value}</div>
    <div style={{ fontSize: 10, color: "#667085", marginTop: 2, textTransform: "uppercase", letterSpacing: ".04em", fontWeight: 500 }}>{label}</div>
  </div>;


const quickBtn = (enabled, active = false) => ({
  width: 34, height: 34, display: "inline-flex", alignItems: "center", justifyContent: "center",
  borderRadius: 500,
  border: `1px solid ${active ? "#84CAFF" : "#E4E7EC"}`,
  background: active ? "#EFF8FF" : enabled ? "#FFFFFF" : "#F9FAFB",
  cursor: enabled ? "pointer" : "not-allowed", padding: 0
});

// Full-width labelled action button used in the new minimised contact card
const quickBtnFull = (enabled, active = false) => ({
  flex: 1, height: 34, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6,
  borderRadius: 6, padding: "0 10px",
  border: `1px solid ${active ? "#84CAFF" : "#E4E7EC"}`,
  background: active ? "#EFF8FF" : enabled ? "#FFFFFF" : "#F9FAFB",
  cursor: enabled ? "pointer" : "not-allowed",
  fontFamily: "inherit"
});

// Outlined blue action button used in the mini contact card (Call / Message)
const contactActionBtn = (enabled) => ({
  flex: 1, height: 38, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 8,
  borderRadius: 6, padding: "0 12px",
  border: `1px solid ${enabled ? "#B2DDFF" : "#E4E7EC"}`,
  background: enabled ? "#FFFFFF" : "#F9FAFB",
  cursor: enabled ? "pointer" : "not-allowed",
  fontFamily: "inherit",
  transition: "background .15s"
});

// Tab button for the AI insights / Contact details tab strip
const TabBtn = ({ active, onClick, icon, label, accent = "#004CE6" }) =>
<button
  onClick={onClick}
  style={{
    display: "inline-flex", alignItems: "center", gap: 6,
    padding: "12px 0", marginBottom: -1,
    background: "transparent", border: "none", cursor: "pointer",
    borderBottom: `2px solid ${active ? "#004CE6" : "transparent"}`,
    fontFamily: "inherit",
    fontSize: 14, fontWeight: active ? 600 : 400,
    color: active ? "#101828" : "#667085"
  }}
  onMouseEnter={(e) => {if (!active) e.currentTarget.style.color = "#344054";}}
  onMouseLeave={(e) => {if (!active) e.currentTarget.style.color = "#667085";}}>
    {label}
  </button>;


const addBtn = {
  display: "inline-flex", alignItems: "center", gap: 3, padding: "2px 7px", borderRadius: 4,
  border: "1px dashed #A4BCFD", background: "#FFFFFF", color: "#004CE6",
  fontSize: 11, fontWeight: 500, cursor: "pointer"
};

// ----- AI Insights — minimal card → click to expand into modal ----------------
// Athlete-to-Athlete dataset: youth sports mentoring marketplace. Coordinators
// (Andrew Wallace, Priya Shah) match college/pro athlete mentors with younger
// mentees + parents. Insights are grounded in each thread's actual content.
// ─── Contact-details section chrome ──────────────────────────────────────
// One heading and one divider shared by every section in the tab, so Tags,
// Overview and Integrations read as the same kind of thing.

// Every section label in the sheet — Tags, Conversation Summary, Suggested
// Actions, the accordions. Held in one object because it was previously four
// copies of the same five properties, which is how they drifted apart. Title
// case at 14px in the primary ink rather than 11px grey small-caps: these are
// headings, and at caption size in caption grey they read as labels on the
// content above them instead. 14 rather than 13 so a section heading still
// outranks the 13px card titles that sit inside it.
const SHEET_HEADING = { fontSize: 14, fontWeight: 600, color: "#101828" };

const SheetSectionHeading = ({ children, count, action }) =>
<div style={{ display: "flex", alignItems: "center", gap: 8, minHeight: 24 }}>
    <span style={{ ...SHEET_HEADING, flex: 1 }}>{children}</span>
    {count != null &&
  <span style={{
    fontSize: 10, fontWeight: 600, color: "#667085", background: "#F2F4F7",
    borderRadius: 500, padding: "1px 7px", minWidth: 18, textAlign: "center"
  }}>{count}</span>
  }
    {action}
  </div>;


const SheetDivider = () =>
<div style={{ height: 1, background: "#E4E7EC" }} />;


// ─── Connected CRMs ──────────────────────────────────────────────────────
// Simple glyph marks in each product's brand colour — recognisable without
// shipping real trademarked logos into the prototype.
const CRM_APPS = [
{ id: "salesloft", name: "Salesloft",
  Mark: ({ size }) =>
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M6.5 3.2 17.2 12 6.5 20.8v-4.4L11.9 12 6.5 7.6z" fill="#0B5CFF" />
    </svg> },

{ id: "freshdesk", name: "Freshdesk",
  Mark: ({ size }) =>
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <circle cx="12" cy="12" r="11" fill="#1FC16B" />
      <path d="M7.4 13.4v-1.3a4.6 4.6 0 0 1 9.2 0v1.3" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" />
      <rect x="5.9" y="12.9" width="3" height="4.4" rx="1.5" fill="#fff" />
      <rect x="15.1" y="12.9" width="3" height="4.4" rx="1.5" fill="#fff" />
    </svg> },

{ id: "hubspot", name: "HubSpot",
  Mark: ({ size }) =>
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <circle cx="9.6" cy="15.4" r="4.4" stroke="#FF5C35" strokeWidth="2.5" />
      <path d="M9.6 11V7.2M13.4 12.6l3.6-4.4" stroke="#FF5C35" strokeWidth="2.2" strokeLinecap="round" />
      <circle cx="9.6" cy="5.4" r="2" fill="#FF5C35" />
      <circle cx="18.4" cy="6.4" r="2.4" fill="#FF5C35" />
    </svg> },

{ id: "pipedrive", name: "Pipedrive",
  Mark: ({ size }) =>
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M12 2.8 22 7.6 12 12.4 2 7.6z" fill="#12212E" />
      <path d="M2.6 12.4 12 16.9l9.4-4.5" stroke="#12212E" strokeWidth="2.6" strokeLinejoin="round" />
      <path d="M2.6 17 12 21.5l9.4-4.5" stroke="#12212E" strokeWidth="2.6" strokeLinejoin="round" />
    </svg> }];



// Which CRMs a contact is connected to. Only the top three conversations in the
// current list get them — enough to show the widget without implying every
// contact is wired into a stack. Count and picks are seeded off the thread id
// so they stay put across renders.
const crmsForThread = (thread) => {
  if (!thread || !thread.id) return [];
  const order = window.__visibleThreadOrder || [];
  const rank = order.indexOf(thread.id);
  if (rank < 0 || rank > 2) return [];
  const seed = [...thread.id].reduce((a, c) => a + c.charCodeAt(0), 0);
  // 3–5 by design, but capped at however many marks exist — four today.
  const count = Math.min(3 + seed % 3, CRM_APPS.length);
  const picked = [];
  for (let i = 0; picked.length < count && i < CRM_APPS.length * 2; i++) {
    const app = CRM_APPS[(seed + i * 3) % CRM_APPS.length];
    if (!picked.find((p) => p.id === app.id)) picked.push(app);
  }
  return picked;
};

// ─── Integrations ────────────────────────────────────────────────────────
// The CrmStack above says *which* products a contact is linked to. This says
// *what is in them* — the question the sheet could not answer before, which
// left an agent opening Salesforce in another tab to read a renewal date.
//
// One card per product, expanding on its own, because that is what the records
// are: four separate systems that happen to be about the same person, not four
// sections of one document. That shape is also why these are not the sheet's
// Accordion — it draws a grey band with a chevron and a title, and drops the
// icon it is handed, and the product's mark is most of what identifies a card
// here.

// Everything below is authored and seeded off the thread, so a contact's
// Pipedrive deal is the same deal on every render and two products never
// disagree about the company they are both looking at. Nothing is fetched.
const crmSeed = (thread) => [...String((thread && thread.id) || "x")]
  .reduce((a, c) => a + c.charCodeAt(0), 0);
const crmPick = (arr, n) => arr[Math.abs(n) % arr.length];

// Which products this contact is in. Seeded off the thread rather than off its
// rank in the list the way crmsForThread is — that gate exists to keep the
// header widget off every row, and a section that was empty for most contacts
// would say nothing about what the section is for.
const crmsLinkedTo = (thread) => {
  if (!thread || !thread.id) return [];
  const s = crmSeed(thread);
  const count = 2 + s % 3; // 2–4 of the four
  const out = [];
  for (let i = 0; out.length < count && i < CRM_APPS.length * 2; i++) {
    const app = CRM_APPS[(s + i * 3) % CRM_APPS.length];
    if (!out.find((a) => a.id === app.id)) out.push(app);
  }
  return out;
};

const CRM_RECORDS = {
  // Sales engagement: where the contact is in a sequence.
  salesloft: (t, s) => {
    const cadence = crmPick(["Renewal outreach", "Q3 re-engagement", "Mentor onboarding", "Winback — lapsed"], s);
    const step = 1 + s % 6;
    return {
      summary: `${cadence} · step ${step} of 8`,
      fields: [
        { label: "Cadence", value: cadence },
        { label: "Step", value: `${step} of 8` },
        { label: "Owner", value: crmPick(["Priya Shah", "Jordan Reyes", "Ayush Mehta"], s) },
        { label: "Last touch", value: crmPick(["6 hours ago", "yesterday", "2 days ago", "4 days ago"], s + 1) },
        { label: "Replies", value: String(s % 4) }
      ]
    };
  },
  // Support: what they have raised.
  freshdesk: (t, s) => {
    const open = s % 3;
    const priority = crmPick(["Low", "Medium", "High", "Urgent"], s + 2);
    return {
      summary: open ? `${open} open ticket${open > 1 ? "s" : ""} · ${priority}` : "No open tickets",
      fields: [
        { label: "Open tickets", value: String(open) },
        { label: "Last ticket", value: `#${4200 + s % 700} · ${crmPick(["Booking not showing", "Refund request", "Login loop", "Duplicate charge"], s)}` },
        { label: "Priority", value: priority, color: priority === "Urgent" || priority === "High" ? "#B42318" : null },
        { label: "Requester since", value: crmPick(["Mar 2025", "Aug 2025", "Jan 2026", "Nov 2024"], s + 1) },
        { label: "Satisfaction", value: `${88 + s % 11}% positive` }
      ]
    };
  },
  // CRM: the commercial picture.
  hubspot: (t, s) => {
    const stage = crmPick(["Customer", "Opportunity", "Qualified lead", "Evangelist"], s);
    const amount = 12 + s % 84;
    return {
      summary: `Lifecycle · ${stage}`,
      fields: [
        { label: "Lifecycle stage", value: stage },
        { label: "Company", value: t.company || "Acme Corp" },
        { label: "Open deal", value: `${t.company || "Acme"} — ${crmPick(["Renewal FY27", "Seat expansion", "Pilot to paid", "Multi-year"], s)}` },
        { label: "Amount", value: `$${amount},000` },
        { label: "Close date", value: crmPick(["12 Oct 2026", "3 Nov 2026", "28 Sep 2026", "15 Dec 2026"], s + 1) },
        { label: "Owner", value: crmPick(["Jordan Reyes", "Priya Shah", "Sam Okonkwo"], s + 2) }
      ]
    };
  },
  // Deal pipeline.
  pipedrive: (t, s) => {
    const stage = crmPick(["Negotiation", "Proposal sent", "Demo booked", "Contact made"], s);
    const value = 8 + s % 46;
    return {
      summary: `${stage} · $${value},000`,
      fields: [
        { label: "Deal", value: `${t.name.split(" ")[0]}'s ${crmPick(["renewal", "expansion", "pilot", "upgrade"], s)}` },
        { label: "Stage", value: stage },
        { label: "Value", value: `$${value},000` },
        { label: "Probability", value: `${35 + s % 60}%` },
        { label: "Expected close", value: crmPick(["end of Q3", "end of Q4", "next month", "in 3 weeks"], s + 1) },
        { label: "Activities", value: `${1 + s % 9} logged` }
      ]
    };
  }
};

const crmRecordFor = (app, thread) => {
  const build = CRM_RECORDS[app.id];
  const s = crmSeed(thread);
  // A product with no authored record still gets a card, so adding one to
  // CRM_APPS cannot make the section throw — it just has nothing to expand to.
  return build ? build(thread, s) : { summary: "Connected", fields: [] };
};

const IntegrationCard = ({ app, record, open, onToggle, onOpenIn }) =>
<div style={{
  border: "1px solid #E4E7EC", borderRadius: 10, background: "#FFFFFF",
  overflow: "hidden"
}}>
    <button
    type="button"
    onClick={onToggle}
    aria-expanded={open}
    style={{
      display: "flex", alignItems: "center", gap: 10, width: "100%",
      padding: "10px 12px", border: 0, background: "transparent",
      cursor: "pointer", fontFamily: "inherit", textAlign: "left"
    }}
    onMouseEnter={(e) => e.currentTarget.style.background = "#F9FAFB"}
    onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
      {/* The product's own mark, at the tile size the header stack uses, so a
          card and a stack tile read as the same object. */}
      <span style={{
      width: 28, height: 28, borderRadius: 500, background: "#FFFFFF",
      border: "1px solid #E4E7EC", display: "inline-grid", placeItems: "center",
      flexShrink: 0, boxSizing: "border-box"
    }}>
        <app.Mark size={17} />
      </span>
      <span style={{ flex: 1, minWidth: 0 }}>
        <span style={{
        display: "block", fontSize: 13, fontWeight: 600, color: "#101828",
        lineHeight: 1.35
      }}>{app.name}</span>
        {/* The one line worth reading without opening the card. */}
        <span style={{
        display: "block", fontSize: 12, color: "#667085", lineHeight: 1.35,
        whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"
      }}>{record.summary}</span>
      </span>
      <span style={{
      display: "inline-flex", alignItems: "center", justifyContent: "center",
      width: 16, height: 16, flexShrink: 0, transition: "transform .15s",
      transform: open ? "rotate(180deg)" : "rotate(0deg)"
    }}>
        <I.chevDown size={13} stroke="#98A2B3" />
      </span>
    </button>

    {open &&
  <div style={{ padding: "0 12px 10px" }}>
      {/* The fields this product holds, on the same two-column rhythm as
          Contact Fields above — the values came from somewhere else, but they
          are the same kind of thing and shouldn't be read differently. */}
      <div style={{
      display: "grid", gridTemplateColumns: "minmax(96px, 40%) 1fr",
      columnGap: 12, borderTop: "1px solid #F2F4F7", paddingTop: 8
    }}>
        {record.fields.map((f) =>
      <React.Fragment key={f.label}>
            <span style={{
          display: "flex", alignItems: "center", minHeight: 30,
          fontSize: 12, color: "#667085"
        }}>{f.label}</span>
            <span style={{
          display: "flex", alignItems: "center", minHeight: 30,
          fontSize: 12, fontWeight: 500, color: f.color || "#101828"
        }}>{f.value}</span>
          </React.Fragment>
      )}
      </div>
      <div style={{
      display: "flex", alignItems: "center", gap: 8, paddingTop: 8,
      fontSize: 11, color: "#98A2B3"
    }}>
        <span style={{ flex: 1 }}>Synced from {app.name}</span>
        <button
        type="button"
        onClick={onOpenIn}
        style={{
          border: 0, background: "transparent", padding: 0, cursor: "pointer",
          fontFamily: "inherit", fontSize: 11, fontWeight: 600, color: "#004CE6"
        }}>Open in {app.name}</button>
      </div>
    </div>
  }
  </div>;


const CRM_TILE = 32;
const CRM_ICON = 20;

// Tooltip goes through the shared data-jc-tip system so it matches every other
// tooltip in the product rather than falling back to the browser's.
const CrmTile = ({ app, offset }) =>
<span className="crm-tile" data-jc-tip={`View in ${app.name}`} style={{
  width: CRM_TILE, height: CRM_TILE, borderRadius: 500, background: "#FFFFFF",
  border: "1px solid #E4E7EC", display: "inline-grid", placeItems: "center",
  marginLeft: offset ? -8 : 0, flexShrink: 0, boxSizing: "border-box",
  cursor: "pointer",
  transition: "background-color .12s ease, border-color .12s ease, transform .12s ease"
}}>
    <app.Mark size={CRM_ICON} />
  </span>;


// Overlapping tiles. Past three CRMs it shows two and a +N tile that reveals
// the rest on hover, so the row keeps its width no matter how many are linked.
const CrmStack = ({ crms }) => {
  if (!crms || !crms.length) return null;
  const overflow = crms.length > 3;
  const shown = overflow ? crms.slice(0, 2) : crms;
  const rest = overflow ? crms.slice(2) : [];
  return (
    <div style={{ display: "flex", alignItems: "center", flexShrink: 0, position: "relative" }}>
      {shown.map((app, i) => <CrmTile key={app.id} app={app} offset={i > 0} />)}
      {overflow &&
      <span className="crm-more" style={{
        position: "relative", marginLeft: -8, width: CRM_TILE, height: CRM_TILE, borderRadius: 500,
        background: "#FFFFFF", border: "1px solid #E4E7EC", display: "inline-grid",
        placeItems: "center", fontSize: 12, fontWeight: 600, color: "#667085",
        cursor: "default", boxSizing: "border-box"
      }}>
          +{rest.length}
          <span className="crm-more-menu" style={{
            position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 40,
            minWidth: 168, padding: 4, background: "#FFFFFF",
            border: "1px solid #E4E7EC", borderRadius: 8,
            boxShadow: "0 12px 28px rgba(16,24,40,.12)"
          }}>
            {rest.map((app) =>
            <span key={app.id} style={{
              display: "flex", alignItems: "center", gap: 8, padding: "7px 8px",
              borderRadius: 6, fontSize: 13, fontWeight: 500, color: "#344054", whiteSpace: "nowrap"
            }}>
                <app.Mark size={18} />
                {app.name}
              </span>
            )}
          </span>
        </span>
      }
    </div>);

};

// ─── Contact highlights ──────────────────────────────────────────────────
// Who has touched this conversation. The assignee is always on it; every other
// conversation also carries one or two teammates who handled it earlier, so
// the highlights read like the shared inbox this actually is. Seeded off the
// thread id rather than Math.random so a contact's history doesn't reshuffle
// on every render.
const conversationParticipants = (thread) => {
  const all = window.THREADS || [];
  const roster = (window.AGENTS || []).map((a) => a.name);
  const current = thread.assignee ? thread.assignee.name : null;
  // Alternate by position in the list so it really is every other conversation
  // — hashing the id skews, since they all share a "t_" prefix.
  const idx = all.findIndex((t) => t.id === thread.id);
  const seed = [...(thread.id || "")].reduce((a, c) => a + c.charCodeAt(0), 0);
  const earlier = [];
  if (idx >= 0 && idx % 2 === 0 && roster.length) {
    const pool = roster.filter((n) => n !== current);
    const count = 1 + (Math.floor(seed / 8) % 2);
    for (let i = 0; i < count && i < pool.length; i++) {
      const pick = pool[(seed + i * 7) % pool.length];
      if (!earlier.includes(pick)) earlier.push(pick);
    }
  }
  return { current, earlier };
};

const firstSentence = (text) => {
  if (!text) return "";
  const m = String(text).match(/[^.!?]+[.!?]+/);
  return (m ? m[0] : String(text)).trim();
};

// Three lines: who has been involved, what was discussed, and where it stands.
// All three are derived from the same AI summary the Insights tab renders.
const buildHighlights = (thread, ins) => {
  const { current, earlier } = conversationParticipants(thread);
  const priorList = earlier.join(" and ");

  const who = current ?
  earlier.length ?
  `${current} is handling this now, after ${priorList} worked it earlier.` :
  `${current} has handled this conversation end to end.` :
  earlier.length ?
  `Unassigned — ${priorList} worked it earlier, nobody owns it now.` :
  `Unassigned so far — no rep has picked this conversation up.`;

  const what = ins.empty ?
  `No substantive exchange yet — nothing discussed worth summarising.` :
  firstSentence(ins.summary) || ins.headline || "";

  const where = ins.empty ?
  `Nothing outstanding on this thread.` :
  ins.stage && ins.nextStep ? `${ins.stage} — ${firstSentence(ins.nextStep)}` :
  ins.nextStep || ins.objective || ins.stage || "";

  return [
  { text: who, dot: "#004CE6" },
  { text: what, dot: "#7A5AF8" },
  { text: where, dot: "#12B76A" }].
  filter((l) => l.text);
};

// ─── Summary evidence ────────────────────────────────────────────────────
// Each bit of the conversation summary cites the thread events it was drawn
// from. A citation is stored as a *description* of the event, not as an index
// into the message list: the automated workflow/campaign pair is spliced into
// every thread after the arrays are written, and the filler conversations
// clone a thread wholesale, so a literal index drifts and lands on the wrong
// bubble. Resolution happens against window.THREAD_MESSAGES at build time.

// Everything a citation might match on — a bubble's body, a call's recap, a
// voicemail transcript.
const evHaystack = (m) =>
[m.text, m.summary, m.voicemail].filter(Boolean).join(" ").toLowerCase();

const evMatches = (m, spec) => {
  if (spec.kind && m.kind !== spec.kind) return false;
  if (spec.dir && m.dir !== spec.dir) return false;
  if (spec.status && m.status !== spec.status) return false;
  if (spec.event && m.event !== spec.event) return false;
  if (spec.source && !(m.source && m.source.type === spec.source)) return false;
  // `automated: false` excludes the workflow/campaign sends, so "the last
  // thing a human sent" doesn't resolve onto a machine's message.
  if (spec.automated === false && m.source) return false;
  if (spec.contains && !evHaystack(m).includes(spec.contains.toLowerCase())) return false;
  return true;
};

// What the citation card calls the event it points at, mirroring how the
// thread itself labels that row.
const evLabel = (m) => {
  if (m.kind === "sms") {
    const chan = m.channel === "whatsapp" ? "WhatsApp" : "SMS";
    if (m.source) return `${chan} · ${m.source.type === "campaign" ? "Campaign" : "Workflow"}`;
    return chan;
  }
  if (m.kind === "call") {
    if (m.status === "missed") return m.voicemail ? "Voicemail" : "Missed call";
    if (m.status === "transferred") return "Transferred call";
    return m.dir === "out" ? "Outbound call" : "Inbound call";
  }
  if (m.kind === "voice") return "Voice note";
  if (m.kind === "note") return "Internal note";
  if (m.kind === "event") {
    if (m.event === "campaign") return "Campaign";
    if (m.event === "assigned") return "Assigned";
    if (m.event === "unassigned") return "Unassigned";
    if (m.event === "closed") return "Thread closed";
    if (m.event === "reopened") return "Thread reopened";
    return "Event";
  }
  return "Message";
};

// Events carry a full stamp ("Apr 23, 2026 12:33 AM") where a message carries
// a bare time. The year is the one part a citation never needs.
const evWhen = (m) => m.time || String(m.at || "").replace(/,? \d{4}/, "");

// Calls and events pick their own glyph below; these are the kinds that don't
// vary.
const EV_ICON = { sms: "chat", voice: "mic", note: "note" };

const evIcon = (m) => {
  if (m.kind === "call") {
    if (m.status === "missed") return m.voicemail ? "voicemail" : "phoneMissed";
    return m.dir === "out" ? "phoneOut" : "phoneIn";
  }
  if (m.kind === "event") return m.event === "campaign" ? "megaphone" : "info";
  return EV_ICON[m.kind] || "chat";
};

// The line of the event the card previews. Calls lead with their recap, a
// missed call with the voicemail transcript — the same thing the card in the
// thread shows.
const evPreview = (m) => {
  if (m.kind === "event") {
    // An event has no body of its own — the card previews the line the thread
    // shows on its marker, so the label above it isn't the only thing there.
    if (m.event === "campaign") return `Contact added to campaign ${m.campaign || ""}`.trim();
    if (m.event === "assigned") return `Conversation assigned to ${m.actor || "someone"}`;
    if (m.event === "unassigned") return "Conversation unassigned";
    if (m.event === "closed") return `Conversation closed by ${m.actor || "someone"}`;
    if (m.event === "reopened") return `Conversation reopened by ${m.actor || "someone"}`;
    return "";
  }
  const raw = m.kind === "call" ? m.voicemail || m.summary || "" : m.text || m.summary || "";
  return String(raw).replace(/\s+/g, " ").trim();
};

// Resolve one citation spec against a thread. `{ at: n }` is already an index
// — the fallback builder works that way, since it picks its anchors by
// position rather than by phrase.
const resolveCitation = (threadId, spec) => {
  const msgs = (window.THREAD_MESSAGES || {})[threadId] || [];
  let idx = -1;
  if (spec.at != null) {
    idx = spec.at >= 0 && spec.at < msgs.length && msgs[spec.at].kind !== "date" ? spec.at : -1;
  } else {
    const hits = [];
    msgs.forEach((m, i) => {if (m.kind !== "date" && evMatches(m, spec)) hits.push(i);});
    if (hits.length) {
      const n = spec.nth == null ? 1 : spec.nth;
      const pick = n < 0 ? hits[hits.length + n] : hits[n - 1];
      if (pick != null) idx = pick;
    }
  }
  if (idx < 0) return null;
  const m = msgs[idx];
  return {
    idx,
    label: evLabel(m),
    icon: evIcon(m),
    when: evWhen(m),
    author: m.author || "",
    preview: evPreview(m)
  };
};

// Turn authored bits into rendered ones. A bit whose citations all fail to
// resolve is dropped rather than shown bare — the whole point of a bit is
// that you can go and check it.
const resolveBits = (threadId, bits) =>
(bits || []).map((b) => ({
  text: b.text,
  cites: (b.src || []).map((s) => resolveCitation(threadId, s)).filter(Boolean)
})).
filter((b) => b.text && b.cites.length);

// Bits for the conversations without an authored pack — the five hand-written
// threads that fall through to the tag-driven summary, and the fifteen filler
// clones. They cite by position in the thread rather than by phrase, so they
// resolve against whatever the conversation actually contains.
const buildFallbackBits = (thread, name, flags) => {
  const msgs = (window.THREAD_MESSAGES || {})[thread.id] || [];
  const lastOf = (pred) => {for (let i = msgs.length - 1; i >= 0; i--) if (pred(msgs[i])) return i;return -1;};
  const inIdx = lastOf((m) => m.kind === "sms" && m.dir === "in");
  const outIdx = lastOf((m) => m.kind === "sms" && m.dir === "out" && !m.source);
  const callIdx = lastOf((m) => m.kind === "call");
  const noteIdx = lastOf((m) => m.kind === "note");
  const autoIdx = lastOf((m) => !!m.source);
  const lastIdx = lastOf((m) => m.kind !== "date");
  const bits = [];

  // What the thread is about, pinned to the most recent thing sent on it.
  const opening =
  flags.isOnboarding ? `${name} finished onboarding with availability confirmed — no mentee matched yet.` :
  flags.isReschedule ? `${name}'s intro call has moved to a new slot and the confirmation has already gone out.` :
  flags.isRecurring ? `${name} has a completed session behind them and is open to a recurring cadence.` :
  flags.isPaused ? `The thread is closed with nothing outstanding, and ${name} left it on warm terms.` :
  flags.isMissed ? `${name} reached out by phone and the thread is still sitting on that call.` :
  `${name} is mid-flow on this match, with the next concrete step still unset.`;
  const openingAt = flags.isMissed && callIdx >= 0 ? callIdx : outIdx >= 0 ? outIdx : lastIdx;
  if (openingAt >= 0) bits.push({ text: opening, src: [{ at: openingAt }] });

  // Who spoke last decides whether the ball is on our side or theirs — the
  // single most useful thing to know before opening a thread. On a closed one
  // it decides instead whether anything was left hanging when it shut.
  if (inIdx >= 0 && inIdx === lastIdx) {
    bits.push({
      text: flags.isPaused ?
      `${name} spoke last before it closed — read that message before you reopen it.` :
      `${name} spoke last and nothing has gone back since — the reply is owed on this side.`,
      src: [{ at: inIdx }] });
  } else if (inIdx >= 0 && outIdx > inIdx) {
    if (!flags.isPaused) bits.push({ text: `Their last message has been answered, so the thread is waiting on them.`, src: [{ at: inIdx }, { at: outIdx }] });
  } else if (outIdx >= 0) {
    bits.push({ text: `${name} hasn't replied on this thread yet — everything on it has gone one way.`, src: [{ at: outIdx }] });
  }

  // A call carries detail the messages only summarise, and an internal note
  // carries something the contact never saw. Naming who left each one keeps
  // these from reading as boilerplate on a thread that has both.
  if (callIdx >= 0 && callIdx !== openingAt) {
    const who = msgs[callIdx].author;
    bits.push({
      text: who ?
      `${who} took part of this by phone — the call recap holds detail the messages don't.` :
      `Part of this happened by phone — the call recap holds detail the messages don't.`,
      src: [{ at: callIdx }] });
  }
  if (noteIdx >= 0) {
    const who = msgs[noteIdx].author;
    bits.push({
      text: who ?
      `An internal note from ${who} flags context ${name} never saw.` :
      `An internal note on the thread flags context ${name} never saw.`,
      src: [{ at: noteIdx }] });
  }
  if (bits.length < 4 && autoIdx >= 0) {
    bits.push({ text: `Some of this went out by automation rather than by a person — check before adding to it.`, src: [{ at: autoIdx }] });
  }
  return bits.slice(0, 4);
};

const buildInsights = (thread) => {
  const name = thread.name?.split(" ")[0] || "Contact";
  const company = thread.company || "";
  const tags = thread.tags || [];

  // Threads without enough signal — unknown callers, internal-only, brand new.
  const isEmpty = !thread.isContact || thread.channel === "note" || thread.id === "t_unknown";
  if (isEmpty) {
    return { empty: true };
  }

  // Per-thread insight pack. Each entry models a real moment in the dataset.
  const PACKS = {
    // CONV-1010 — Mia just had a great intro call with Zara, wants to keep going.
    t_mia: {
      sentiment: "Positive · highly engaged",
      stage: "Post-intro · ready to convert",
      objective: `Convert Mia into a recurring booking with Zara this week.`,
      headline: `Mia rated her intro call with Zara 5★ and wants to continue — ready to book recurring.`,
      summary: `Mia (Track & Field mentee) just submitted a 5★ feedback form after her intro call with Zara — the system flagged a strong match. She specifically asked to "keep working with her." Send the recurring booking link today and confirm cadence; she's warm and the post-call window is the conversion peak.`,
      // The summary, broken into claims that can each be checked against the
      // thread. `src` describes the event to cite, not its index — see
      // resolveCitation above.
      summaryBits: [
      { text: `Mia's intro call with Zara ran 28 minutes — both engaged, logged as a strong match.`,
        src: [{ kind: "call", contains: "Intro call between" }] },
      { text: `She submitted 5★ feedback within the hour and asked to keep working with Zara.`,
        src: [{ kind: "sms", dir: "in", contains: "Just submitted" }, { kind: "note", contains: "high-rebook-likelihood" }] },
      { text: `Cadence is effectively agreed — she'd take weekly while school is in session.`,
        src: [{ kind: "sms", dir: "in", contains: "down for weekly" }] },
      { text: `Wednesdays 5pm ET from 13 May is locked with Zara's parent; the thread's now on WhatsApp.`,
        src: [{ kind: "sms", dir: "out", contains: "Locked in with Zara's parent" }, { kind: "call", contains: "prefers WhatsApp" }] }],

      commitments: [
      { id: "c1", text: `Send recurring booking link with Zara`, done: false },
      { id: "c2", text: `Confirm preferred cadence (weekly vs biweekly)`, done: false },
      { id: "c3", text: `Logged 5★ intro feedback`, done: true }],

      // What the AI would have you do next, in one line each. Terser than the
      // Suggested actions cards below, which carry drafts and CTAs.
      summaryActions: [
      `Send the recurring booking link today — the post-call window is the conversion peak.`,
      `Confirm whether she wants weekly or biweekly sessions.`,
      `Tell Zara she was rated 5★ so she's ready for the request.`],
      nextStep: `Send the recurring booking link with Zara and confirm cadence.`,
      nextStepsBuild: () => [
      { title: "Send booking link", event: "Recurring request", severity: "urgent", subtitle: "Reply on this thread · Today",
        description: `Mia asked to keep working with Zara. Drop the recurring link while the post-call energy is still high.`,
        channel: "sms",
        draft: `Amazing to hear, Mia! Zara had great things to say too. Here's the recurring booking link to lock in your cadence with her: https://athletetoathlete.com/book/recurring?mentorId=zara&menteeId=mia` },
      { title: "Share onboarding guide", event: "Mentee onboarding", severity: "info", subtitle: "Reply on this thread · Attachment",
        description: `Send Mia the mentee onboarding guide so she knows what to expect before her first recurring session.`,
        channel: "sms",
        attachment: { name: "Athlete-to-Athlete-Mentee-Onboarding-Guide.pdf", size: "1.4 MB" },
        draft: `Attaching our mentee onboarding guide, Mia — it covers how sessions run, how to prep, and what Zara will expect each week. Have a quick read before your first recurring session and ping me with any questions!` },
      { title: "Notify Zara of strong match", event: "5★ intro feedback", severity: "info", subtitle: "Internal note",
        description: `Loop Zara in that Mia rated 5★ and is converting to recurring.`,
        channel: "note",
        draft: `@Zara — Mia rated your intro 5★ and asked to keep going. Sending her the recurring link now; you'll likely see a Tues/Thurs evening request shortly.` },
      { title: "Tag for High-rebook playbook", event: "High-rebook signal", severity: "info", subtitle: "Internal · CRM",
        description: `Confirm the High-rebook tag is applied so this lead routes through the conversion playbook.`,
        channel: "note",
        draft: `Tagging Mia as High-rebook + Zara_match_5star. Adding to the post-intro conversion sequence.` }],

      risks: [`Conversion window is hours, not days — recurring asks cool quickly after intro calls.`],
      timeline: [
      { when: "12 min ago", what: `Mia submitted 5★ feedback and asked to continue with Zara.` },
      { when: "Today, 5:00 pm", what: `Intro call with Zara — 28 min, both engaged, parent observed.` },
      { when: "Apr 30", what: `Match confirmed: Mia ↔ Zara (Track & Field).` }]

    },

    // CONV-1007 — Marcus Webb (parent): voicemail, broken meet link mid-call.
    t_marcus: {
      sentiment: "Frustrated · recovered",
      stage: "Recovery · trust at stake",
      objective: `Recover trust after the broken-link disruption to Hannah's intro call.`,
      headline: `Marcus left an angry voicemail after a broken meet link delayed Hannah's intro call.`,
      summary: `Marcus (parent of Hannah, soccer mentee) left an inbound voicemail at 7:34 pm — frustrated that the Google Meet link sent earlier didn't work, with the call already 4 minutes late. Priya called back inside 2 minutes and resolved it: he had been clicking an outdated link from a prior reschedule. Call is back in progress, but follow up tomorrow with a written apology + reassurance that the new link flow is fixed.`,
      summaryBits: [
      { text: `Marcus called 30 minutes before Hannah's intro call — the Meet link wasn't working.`,
        src: [{ kind: "call", status: "missed" }] },
      { text: `Priya called back inside two minutes — he'd been clicking an old link from a reschedule email.`,
        src: [{ kind: "call", status: "completed" }, { kind: "note", contains: "URGENT" }] },
      { text: `The current link went out by SMS and he confirmed he was in.`,
        src: [{ kind: "sms", dir: "out", contains: "correct meet link" }, { kind: "sms", dir: "in", contains: "In! Thank you" }] },
      { text: `The cause is already with engineering as ENG-2241 — reschedule emails don't retire the old links.`,
        src: [{ kind: "note", contains: "ENG-2241" }] }],

      commitments: [
      { id: "c1", text: `Returned call within 2 minutes of voicemail`, done: true },
      { id: "c2", text: `Sent corrected meet link via SMS`, done: true },
      { id: "c3", text: `Send written apology + post-call check-in tomorrow`, done: false }],

      summaryActions: [
      `Follow up in writing tomorrow with an apology and the fixed link flow.`,
      `Check Hannah's rescheduled intro call actually went ahead.`,
      `Retire the outdated Meet link so it can't be clicked again.`],
      nextStep: `Send a written apology + check on how Hannah's call went.`,
      nextStepsBuild: () => [
      { title: "Send post-call check-in", event: "Service disruption", severity: "urgent", subtitle: "Reply on this thread · Tomorrow AM",
        description: `Apologise in writing for the link confusion and ask how Hannah felt the call went.`,
        channel: "sms",
        draft: `Hi Marcus — Priya here again. Wanted to apologise once more for the link mix-up yesterday and check how Hannah felt about the call. If anything came up, please let me know directly and I'll handle it.` },
      { title: "Flag link-flow bug", event: "Broken meet link", severity: "watch", subtitle: "Internal · Engineering",
        description: `Old reschedule emails are surfacing stale meet links. File this so it doesn't repeat.`,
        channel: "note",
        draft: `@Eng — old reschedule emails contain stale meet links. Marcus Webb hit this last night; root cause is the email template not being invalidated when a reschedule happens. Repro details in thread.` },
      { title: "Comp gesture", event: "Escalated complaint", severity: "watch", subtitle: "Internal · CX",
        description: `Approve a small credit or free session to acknowledge the disruption.`,
        channel: "note",
        draft: `Approving a $25 credit on Marcus Webb's account as a goodwill gesture for the broken-link disruption. Will mention in the follow-up SMS.` },
      { title: "Confirm call completed", event: "Interrupted call", severity: "info", subtitle: "Internal · QA",
        description: `Verify Hannah's intro call actually finished and gather mentor feedback.`,
        channel: "note",
        draft: `Pulling the call recording from 7:36pm yesterday to confirm Hannah's intro completed cleanly + grab mentor notes for follow-up.` }],

      risks: [
      `Repeat link issues will erode parent trust immediately.`,
      `Hannah is 11 — parent satisfaction drives every future booking decision.`],

      timeline: [
      { when: "32 min ago", what: `Marcus left an inbound voicemail (00:38) about the broken meet link.` },
      { when: "32 min ago", what: `Priya returned the call inside 2 min — root cause was a stale link.` },
      { when: "Yesterday", what: `Intro call scheduled for Hannah ↔ Cole Banner at 7:30 pm ET.` }]

    },

    // CONV-1003 — Landon Pierce: re-engaged mentor, mentee booked.
    t_landon: {
      sentiment: "Positive · re-engaged",
      stage: "Active · mentee assigned",
      objective: `Lock the booking for Landon's first session with his new mentee.`,
      headline: `Landon re-engaged after a quiet stretch and accepted his new mentee — ready to book.`,
      summary: `Landon (Football QB mentor) had gone quiet for ~3 weeks. The re-engagement SMS landed and he replied "Yes! Please book it." within 12 minutes. Push the booking through today while he's hot — re-engaged mentors lapse fast. Confirm availability and send the calendar invite for the intro call.`,
      summaryBits: [
      { text: `Landon went quiet in April with no Football match available; the thread was closed.`,
        src: [{ kind: "note", contains: "Football priority queue" }, { kind: "event", event: "closed" }] },
      { text: `A winback campaign and an availability workflow reopened the thread.`,
        src: [{ kind: "event", event: "campaign" }, { kind: "sms", source: "workflow" }] },
      { text: `He replied "Yes! Please book it." within 15 minutes of the Monday 5/4 slot going out.`,
        src: [{ kind: "sms", dir: "in", contains: "Please book it" }, { kind: "sms", dir: "out", contains: "would love to meet with you on Monday" }] },
      { text: `Nothing has gone back to him since — no confirmation and no invite.`,
        src: [{ kind: "sms", dir: "in", nth: -1 }] }],

      commitments: [
      { id: "c1", text: `Send booking confirmation + calendar invite`, done: false },
      { id: "c2", text: `Refresh availability link for the next 2 weeks`, done: false },
      { id: "c3", text: `Re-engagement campaign converted`, done: true }],

      summaryActions: [
      `Send the calendar invite today — re-engaged mentors lapse fast.`,
      `Refresh his availability for the next two weeks.`,
      `Mark the re-engagement campaign as converted.`],
      nextStep: `Send the booking confirmation while engagement is hot.`,
      nextStepsBuild: () => [
      { title: "Confirm booking", event: "Re-engagement reply", severity: "urgent", subtitle: "Reply on this thread · Today",
        description: `Send the booking confirmation + intro-call calendar invite.`,
        channel: "sms",
        draft: `Awesome Landon — booking now! You'll get a calendar invite in the next 5 minutes with the meet link and a quick prep guide. Excited to get you back in the rotation.` },
      { title: "Refresh availability", event: "Stale availability", severity: "watch", subtitle: "Reply on this thread",
        description: `Ask Landon to refresh his next-2-week availability so we can keep momentum.`,
        channel: "sms",
        draft: `While we're at it — could you refresh your availability for the next 2 weeks? https://athletetoathlete.com/mentor/availability?athleteId=landon` },
      { title: "Notify mentee + parent", event: "Mentor matched", severity: "info", subtitle: "Internal · Outbound",
        description: `Confirm the parent + mentee that their requested mentor is locked in.`,
        channel: "note",
        draft: `Sending the parent of [mentee] confirmation that Landon is matched + intro call is being booked. Will share the meet link once on the calendar.` },
      { title: "Tag re-engagement win", event: "Campaign conversion", severity: "info", subtitle: "Internal · CRM",
        description: `Mark this re-engagement as converted so the campaign reports correctly.`,
        channel: "note",
        draft: `Marking Landon Pierce as Re-engaged → Converted. Campaign: "30-day mentor reactivation". Time-to-respond: 12 min.` }],

      risks: [`Re-engaged mentors who don't book within 24h relapse ~60% of the time.`],
      timeline: [
      { when: "1h ago", what: `Landon replied "Yes! Please book it."` },
      { when: "1h 12m ago", what: `Re-engagement SMS sent.` },
      { when: "3 weeks ago", what: `Last activity — went quiet after a session cancellation.` }]

    },

    // CONV-1005 — Jenna Whitfield (parent): missing mentor recovery, resolved warmly.
    t_jenna: {
      sentiment: "Positive · trust restored",
      stage: "Recovery complete · advocate-in-the-making",
      objective: `Reinforce the recovery and convert Jenna into a referral-ready advocate.`,
      headline: `Jenna's mentor-assignment was missed for 5 days — Priya's personal call recovered the trust.`,
      summary: `Jenna (parent of Eli, baseball mentee) flagged that no mentor had been assigned 5 days post-signup. Priya owned the recovery: personal callback, apology, locked in Cole Banner (NCAA D1 catcher) on the same call. Jenna's last reply was warm — "really appreciate the personal call." Worth a check-in after Eli's intro to cement the relationship and surface a referral ask.`,
      summaryBits: [
      { text: `Jenna chased five days after Eli's signup — payment cleared, no mentor assigned.`,
        src: [{ kind: "sms", dir: "in", contains: "Just checking on the status" }, { kind: "note", contains: "no mentor assignment triggered" }] },
      { text: `Priya called the same afternoon, apologised, and locked Cole Banner in on the call.`,
        src: [{ kind: "call", status: "transferred" }, { kind: "sms", dir: "out", contains: "paired with Cole Banner" }] },
      { text: `Her reply was warm — "really appreciate the personal call".`,
        src: [{ kind: "sms", dir: "in", contains: "appreciate the personal call" }] },
      { text: `Sunday's 4pm ET intro call is confirmed, with the grandparents watching off-camera.`,
        src: [{ kind: "call", contains: "family observing" }, { kind: "sms", dir: "in", contains: "On the calendar" }] }],

      commitments: [
      { id: "c1", text: `Personal apology call completed`, done: true },
      { id: "c2", text: `Mentor assigned + intro call booked`, done: true },
      { id: "c3", text: `Post-intro check-in with Jenna`, done: false }],

      summaryActions: [
      `Check in with Jenna once Eli's intro call with Cole has run.`,
      `Ask for a referral while the recovery is still fresh.`,
      `Flag the 5-day assignment delay so it doesn't repeat.`],
      nextStep: `Schedule a post-intro check-in once Eli's first call lands.`,
      nextStepsBuild: () => [
      { title: "Post-intro check-in", event: "Recovered escalation", severity: "watch", subtitle: "Reply on this thread · After 5/3 call",
        description: `Once Eli's intro with Cole happens, follow up with Jenna to make sure the experience landed.`,
        channel: "sms",
        draft: `Hi Jenna — checking in after Eli's intro call with Cole. How did it feel? Anything we can fine-tune for the next session?` },
      { title: "Add to advocate list", event: "High-effort save", severity: "info", subtitle: "Internal · CRM",
        description: `Jenna's recovery was a high-effort save. Add her to the advocate / referral list once Eli's first session completes well.`,
        channel: "note",
        draft: `Adding Jenna Whitfield to the advocate list pending a positive post-intro check-in. Strong recovery story — good fit for a referral ask in 2-3 weeks.` },
      { title: "Notify Cole pre-call", event: "Upcoming intro call", severity: "watch", subtitle: "Internal · Mentor brief",
        description: `Brief Cole that this match came out of a recovery — extra effort on the intro call matters.`,
        channel: "note",
        draft: `@Cole — heads-up, this match came out of an assignment recovery. Parent (Jenna) was patient through it; an extra-thoughtful intro call here would mean a lot.` },
      { title: "Document recovery playbook", event: "Assignment delay", severity: "info", subtitle: "Internal · Ops",
        description: `Capture what worked here for the recovery playbook.`,
        channel: "note",
        draft: `Documenting this recovery: 5-day delay → personal callback within 1h of escalation → mentor locked on same call. Adding to recovery playbook as Pattern A.` }],

      risks: [`If Eli's intro call goes poorly, the recovery unwinds fast.`],
      timeline: [
      { when: "3 days ago", what: `Jenna thanked Priya for the personal call.` },
      { when: "3 days ago", what: `Priya called Jenna, apologised, locked Cole Banner on the call.` },
      { when: "5 days before that", what: `Original mentor assignment was missed.` }]

    }
  };

  // Channel + tag-driven fallback for every other thread.
  const isMissed = thread.type === "missed";
  const isOnboarding = tags.includes("Onboarding");
  const isReschedule = tags.includes("Reschedule");
  const isPaused = thread.status === "closed" || tags.includes("Paused");
  const isRecurring = tags.includes("Recurring");
  const role = (company || "").toLowerCase().includes("parent of") ? "parent" :
  (company || "").toLowerCase().includes("mentor") ? "mentor" :
  "contact";

  const fallbackPack = {
    sentiment: isMissed ? "Needs attention" : isPaused ? "Resolved · paused" : "Positive",
    stage: isOnboarding ? "Onboarding" :
    isReschedule ? "Reschedule in motion" :
    isRecurring ? "Active · recurring" :
    isPaused ? "Closed · paused" :
    "Active",
    objective: isOnboarding ? `Get ${name} ready for their first mentee match.` :
    isReschedule ? `Confirm the new intro-call slot and notify both sides.` :
    isRecurring ? `Convert the most recent session into a recurring booking.` :
    isPaused ? `Maintain the relationship for re-activation later.` :
    `Keep this match moving toward a confirmed session.`,
    headline: isOnboarding ? `${name} just finished onboarding — ready to take their first mentees.` :
    isReschedule ? `${name}'s intro call has been rescheduled — both sides need confirmation.` :
    isRecurring ? `${name} completed a session and is open to recurring bookings.` :
    isPaused ? `${name} paused mentoring for the season — re-engage later.` :
    `${name} is mid-flow on this match — keep momentum.`,
    summary: isOnboarding ?
    `${name} (${company || "mentor"}) finished the onboarding walkthrough and confirmed availability. The next step is a real mentee match — line up an age- and sport-appropriate intro call within 7 days while their attention is fresh.` :
    isReschedule ?
    `${name}'s intro call was rescheduled to a new slot. Confirmation has gone out by SMS; double-check both the mentee/parent and the mentor have the new link, and update the scheduler so the old slot doesn't auto-remind.` :
    isRecurring ?
    `${name} just wrapped a session and signaled openness to a recurring cadence. Send the recurring booking link today — completed-session momentum is the strongest predictor of conversion in this dataset.` :
    isPaused ?
    `${name} stepped back from active mentoring (typically a college season conflict). Conversation is closed but the relationship is warm — schedule a re-engagement touch when the season ends.` :
    `${name} is mid-flow on this match. Review the last 2-3 messages, confirm the next concrete commitment (call, link, scheduling), and reply within 24h to keep the momentum.`,
    commitments: isOnboarding ? [
    { id: "c1", text: `Find an age-appropriate first mentee`, done: false },
    { id: "c2", text: `Send first match within 7 days`, done: false },
    { id: "c3", text: `Onboarding call completed`, done: true }] :
    isReschedule ? [
    { id: "c1", text: `Confirm new slot with mentor`, done: false },
    { id: "c2", text: `Update scheduler / cancel old reminders`, done: false },
    { id: "c3", text: `New meet link sent`, done: true }] :
    isRecurring ? [
    { id: "c1", text: `Send recurring booking link`, done: false },
    { id: "c2", text: `Confirm cadence + payout expectations`, done: false },
    { id: "c3", text: `First session completed`, done: true }] :
    [
    { id: "c1", text: `Reply within 24h`, done: false },
    { id: "c2", text: `Confirm next concrete step`, done: false }],

    summaryActions: isOnboarding ?
    [`Line up an age- and sport-appropriate mentee within 7 days.`,
     `Confirm their availability window before matching.`,
     `Send the onboarding guide if they haven't opened it.`] :
    isReschedule ?
    [`Confirm the new slot with both sides in writing.`,
     `Send a fresh calendar invite and retire the old link.`,
     `Watch for a reply before the call date.`] :
    isRecurring ?
    [`Send the recurring booking link while the last session is fresh.`,
     `Confirm the cadence that suits them.`,
     `Loop the mentor in on the request.`] :
    isPaused ?
    [`Set a reminder to re-engage once their season ends.`,
     `Keep them out of active campaign sends until then.`] :
    [`Reply on this thread to keep the match moving.`,
     `Confirm the next session is on the calendar.`,
     `Note anything that would block the booking.`],
    nextStep: isOnboarding ? `Match ${name} with a first mentee within 7 days.` :
    isReschedule ? `Confirm the new slot with the other side and update the scheduler.` :
    isRecurring ? `Send the recurring booking link today.` :
    isPaused ? `Schedule a re-engagement touch for after the season.` :
    `Reply within 24h with the next concrete step.`,
    nextStepsBuild: () => isOnboarding ? [
    { title: "Find a first match", event: "Onboarding complete", severity: "watch", subtitle: "Internal · Matching",
      description: `Pull 2-3 mentee candidates that fit ${name}'s sport, age range and availability.`,
      channel: "note",
      draft: `Pulling candidates for ${name}'s first match: same sport, ages 13-16, evening availability ET. Will share top 2 in the matching channel.` },
    { title: "Send first match SMS", event: "Match pending", severity: "watch", subtitle: "Reply on this thread · This week",
      description: `Once a candidate is selected, send ${name} the match-confirmation SMS with intro-call slot.`,
      channel: "sms",
      draft: `Hey ${name}, great news — we've found a strong first match for you. Booking the intro call now, you'll get a calendar invite shortly.` },
    { title: "Mentor handbook", event: "New mentor", severity: "info", subtitle: "Reply on this thread",
      description: `Share the mentor handbook + first-call prep guide.`,
      channel: "sms",
      draft: `One more thing — here's the mentor handbook + prep guide for your first call: https://athletetoathlete.com/mentors/handbook` }] :
    isReschedule ? [
    { title: "Confirm new slot with mentor", event: "Reschedule requested", severity: "urgent", subtitle: "Internal · Mentor",
      description: `Make sure the mentor has the updated time + new meet link.`,
      channel: "note",
      draft: `Confirming with the mentor that the new slot is on their calendar and the new meet link is correct.` },
    { title: "Update scheduler", event: "Stale reminders", severity: "watch", subtitle: "Internal · Ops",
      description: `Cancel the old reminder cascade so it doesn't fire on the original time.`,
      channel: "note",
      draft: `Cancelling the original reminder cascade so we don't double-message ${name} on the old slot.` },
    { title: "24h reminder", event: "Upcoming call", severity: "info", subtitle: "Reply on this thread · Day before",
      description: `Send the standard 24h reminder against the new slot.`,
      channel: "sms",
      draft: `Quick reminder ${name} — your call is tomorrow at the new time. Meet link is in the previous message.` }] :
    isRecurring ? [
    { title: "Send recurring booking link", event: "Session completed", severity: "urgent", subtitle: "Reply on this thread · Today",
      description: `Catch the post-session momentum — send the recurring link now.`,
      channel: "sms",
      draft: `Great session ${name}! Here's the recurring booking link so we can lock in your cadence: https://athletetoathlete.com/book/recurring` },
    { title: "Confirm cadence", event: "Cadence undecided", severity: "watch", subtitle: "Reply on this thread",
      description: `Ask whether weekly or biweekly works best.`,
      channel: "sms",
      draft: `Quick one — would weekly or every-other-week work better on your end? We can hold a recurring slot either way.` },
    { title: "Update payout expectations", event: "Recurring setup", severity: "info", subtitle: "Internal · Ops",
      description: `Confirm payout cadence so there are no surprises after session 2.`,
      channel: "note",
      draft: `Confirming payout schedule with ${name} for recurring sessions — every other Friday into the linked account.` }] :
    isPaused ? [
    { title: "Schedule re-engagement", event: "Account paused", severity: "info", subtitle: "Internal · Calendar",
      description: `Set a reminder to check in with ${name} after their season ends.`,
      channel: "note",
      draft: `Setting a re-engagement reminder for ${name} 6 weeks from now — after their season window closes.` },
    { title: "Send well-wishes", event: "Season conflict", severity: "info", subtitle: "Reply on this thread",
      description: `Close warmly so the relationship stays alive for re-activation.`,
      channel: "sms",
      draft: `Totally understood ${name} — good luck with your season! We'll be here whenever you're ready to come back.` }] :
    [
    { title: "Reply within 24h", event: "Awaiting reply", severity: "urgent", subtitle: "Reply on this thread · Today",
      description: `Acknowledge ${name}'s last message and confirm the next concrete step.`,
      channel: "sms",
      draft: `Hey ${name}, thanks for the update — confirming on my end and getting back to you with details shortly.` },
    { title: "Confirm next step", event: "No next step set", severity: "watch", subtitle: "Reply on this thread",
      description: `Lock the next call, link or scheduling moment so the thread keeps momentum.`,
      channel: "sms",
      draft: `Quick one — what's the best next step on your end? Happy to pick a time or send the link, whichever helps.` },
    { title: "Internal note", event: "Context gap", severity: "info", subtitle: "Internal · CRM",
      description: `Capture any signal worth surfacing for the team.`,
      channel: "note",
      draft: `Logging context on ${name}'s thread so the next coordinator picking this up has the full picture.` }],

    risks: isMissed ?
    [`Voicemail-stage threads cool fast — recover within the hour.`] :
    isOnboarding ?
    [`Onboarded mentors who don't get matched in 7 days drop off ~40%.`] :
    isReschedule ?
    [`Old meet links are the #1 source of mid-call disruption.`] :
    isRecurring ?
    [`Conversion window after a completed session is ~24 hours.`] :
    isPaused ?
    [`Risk of forgetting — set a re-engagement touch.`] :
    [`Silence past 24h is the strongest predictor of churn in this dataset.`],
    timeline: [
    { when: thread.time || "Recently", what: `${name}: ${thread.preview || "latest activity in this thread."}` }]

  };

  const pack = PACKS[thread.id] || fallbackPack;
  const nextSteps = (pack.nextStepsBuild || (() => []))();
  // The summary is rendered as evidence-backed bits. Threads with an authored
  // pack cite specific events; everything else derives its bits from the
  // thread's own shape. Resolution can come back empty — a conversation
  // created in this session has no entry in THREAD_MESSAGES yet — and the
  // panel falls back to the paragraph when it does.
  const summaryBits = resolveBits(
    thread.id,
    pack.summaryBits || buildFallbackBits(thread, name, {
      isMissed, isOnboarding, isReschedule, isPaused, isRecurring
    })
  );
  return {
    headline: pack.headline,
    summary: pack.summary,
    summaryBits,
    objective: pack.objective,
    commitments: pack.commitments,
    sentiment: pack.sentiment,
    stage: pack.stage,
    bullets: [],
    summaryActions: pack.summaryActions || [],
    nextStep: pack.nextStep,
    nextSteps,
    risks: pack.risks,
    timeline: pack.timeline
  };
};

const AIInsightsCard = ({ thread, onOpenAgent }) => {
  const [open, setOpen] = useState(false);
  const ins = useMemo(() => buildInsights(thread), [thread.id]);
  // Only render this card while the floating widget is dismissed/hidden — the
  // two surfaces show the same summary, so we never want both at once.
  const [widgetVisible, setWidgetVisible] = useState(
    typeof window !== "undefined" && !!window.__aiSummaryWidgetVisible
  );
  useEffect(() => {
    const onState = (e) => setWidgetVisible(!!(e.detail && e.detail.visible));
    window.addEventListener("aiSummaryWidget:state", onState);
    setWidgetVisible(!!window.__aiSummaryWidgetVisible);
    return () => window.removeEventListener("aiSummaryWidget:state", onState);
  }, [thread.id]);

  const openBrief = () => {
    if (onOpenAgent) onOpenAgent("brief");else
    setOpen(true);
  };

  if (widgetVisible) return null;

  return (
    <>
      <div style={{ padding: "14px 20px", borderBottom: "1px solid #E4E7EC" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
          <I.sparkle size={12} stroke="#7F56D9" />
          <div className="t-label-xs" style={{ color: "#7F56D9" }}>AI insights</div>
        </div>
        {ins.empty ?
        <div style={{
          background: "linear-gradient(135deg, #FAFAFB 0%, #F5F4F7 100%)",
          border: "1px dashed #E4E7EC", borderRadius: 8,
          padding: "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={{ fontSize: 12, color: "#667085", lineHeight: 1.4 }}>
              Not enough conversation yet to summarize. Insights will appear as the thread develops.
            </div>
          </div> :

        <button
          onClick={openBrief}
          style={{
            width: "100%", textAlign: "left", cursor: "pointer", fontFamily: "inherit",
            background: "linear-gradient(135deg, #FAF5FF 0%, #F4EBFF 100%)",
            border: "1px solid #E9D7FE", borderRadius: 8,
            padding: "10px 12px", display: "flex", flexDirection: "column", gap: 6
          }}
          onMouseEnter={(e) => e.currentTarget.style.borderColor = "#B692F6"}
          onMouseLeave={(e) => e.currentTarget.style.borderColor = "#E9D7FE"}>
          <div style={{
            fontSize: 12, color: "#42307D", lineHeight: 1.45, fontWeight: 500,
            display: "-webkit-box", WebkitLineClamp: 3, 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: "2px 7px", borderRadius: 500
            }}>{ins.sentiment}</span>
            <span style={{
              fontSize: 10, fontWeight: 500, color: "#5925DC",
              background: "#FFFFFF", border: "1px solid #E9D7FE",
              padding: "2px 7px", borderRadius: 500
            }}>{ins.stage}</span>
            <span style={{
              marginLeft: "auto",
              fontSize: 11, color: "#7F56D9", fontWeight: 600,
              display: "inline-flex", alignItems: "center", gap: 2
            }}>
              View all insights <I.chevRight size={12} stroke="#7F56D9" />
            </span>
          </div>
        </button>
        }
      </div>
      {open && <AIInsightsModal thread={thread} ins={ins} onClose={() => setOpen(false)} />}
    </>);

};

const AIInsightsModal = ({ thread, ins, onClose }) => {
  useEffect(() => {
    const onKey = (e) => {if (e.key === "Escape") onClose();};
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);
  return (
    <div
      onClick={onClose}
      style={{
        position: "fixed", inset: 0, zIndex: 100, background: "rgba(16,24,40,0.45)",
        display: "flex", alignItems: "center", justifyContent: "center", padding: 24
      }}>
      <div
        onClick={(e) => e.stopPropagation()}
        style={{
          width: "min(620px, 100%)", maxHeight: "86vh",
          background: "#FFFFFF", borderRadius: 12, overflow: "hidden",
          boxShadow: "0 24px 48px rgba(16,24,40,.18), 0 4px 12px rgba(16,24,40,.08)",
          display: "flex", flexDirection: "column"
        }}>
        {/* Header — back arrow + heading + last updated */}
        <div style={{
          padding: "14px 20px",
          borderBottom: "1px solid #E4E7EC",
          display: "flex", alignItems: "center", gap: 10
        }}>
          <button onClick={onClose} title="Back"
          style={{
            width: 28, height: 28, borderRadius: 6, border: "1px solid transparent",
            background: "transparent", cursor: "pointer", padding: 0,
            display: "inline-flex", alignItems: "center", justifyContent: "center"
          }}
          onMouseEnter={(e) => e.currentTarget.style.background = "#F2F4F7"}
          onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
            <I.chevLeft size={16} stroke="#475467" />
          </button>
          <div style={{ fontSize: 15, fontWeight: 600, color: "#101828" }}>AI Insights</div>
          <div style={{ flex: 1 }} />
          <div style={{ fontSize: 12, color: "#667085" }}>Last updated just now</div>
        </div>

        {/* Body */}
        <AIInsightsBrief ins={ins} />

        {/* Footer */}
        <div style={{
          padding: "12px 20px", borderTop: "1px solid #E4E7EC", background: "#FAFBFC",
          display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8
        }}>
          <button style={{
            display: "inline-flex", alignItems: "center", gap: 5,
            padding: "7px 10px", borderRadius: 4, border: "1px solid #E4E7EC",
            background: "#FFFFFF", color: "#344054", fontSize: 12, fontWeight: 500,
            cursor: "pointer", fontFamily: "inherit"
          }}>
            <I.refresh size={12} stroke="#667085" /> Regenerate
          </button>
          <button onClick={onClose} style={{
            padding: "7px 14px", borderRadius: 4, border: "none",
            background: "#7F56D9", color: "#FFFFFF", fontSize: 12, fontWeight: 600,
            cursor: "pointer", fontFamily: "inherit"
          }}>
            Got it
          </button>
        </div>
      </div>
    </div>);

};

const Pill = ({ label, value }) =>
<div style={{ border: "1px solid #E4E7EC", borderRadius: 8, padding: "8px 12px", background: "#FFFFFF" }}>
    <div style={{ fontSize: 10, color: "#98A2B3", fontWeight: 600, textTransform: "uppercase", letterSpacing: ".04em", marginBottom: 3 }}>{label}</div>
    <div style={{ fontSize: 13, fontWeight: 500, color: "#101828" }}>{value}</div>
  </div>;


const Section = ({ title, children }) =>
<div>
    <div className="t-label-xs" style={{ color: "#98A2B3", marginBottom: 8 }}>{title}</div>
    {children}
  </div>;


// AI insights body for the contact pane's "AI insights" tab.
// Layout:
//  - Conversation summary heading + summary text + sentiment pill
//  - Collapsible "Auto-tags" section (2 suggestions)
//  - Collapsible "Suggested replies" section (2 options; click pre-fills the input)
// Conversation health tab — admin-only. Surfaces conversation-level signals:
// overall health score, sentiment trajectory, response cadence, friction
// signals, and momentum — NOT agent performance.
const CoachingTab = ({ thread, bare = false, extraActions = [], hideSignals = false }) => {
  const isChurn = (thread.tags || []).includes("Churn risk");
  const isVIP = (thread.tags || []).includes("VIP");
  // Signals refresh seed — bumped when admin clicks "Re-scan", so the
  // randomized signal mix updates without a full reload.
  const [signalSeed, setSignalSeed] = useState(() => Date.now() % 100000);
  // Hovered signal row — drives the swap between anchor pill and arrow icon.
  const [hoverIdx, setHoverIdx] = useState(-1);

  // Conversation health score (out of 10) — synthesized from sentiment,
  // cadence and momentum. Stays distinct from per-agent scoring.
  const callScore = isChurn ? 5.4 : isVIP ? 8.7 : 7.2;
  const scoreColor = callScore >= 8 ? "#067647" : callScore >= 6.5 ? "#B54708" : "#B42318";
  const scoreBg = callScore >= 8 ? "#ECFDF3" : callScore >= 6.5 ? "#FFFAEB" : "#FEF3F2";
  const scoreBorder = callScore >= 8 ? "#ABEFC6" : callScore >= 6.5 ? "#FEDF89" : "#FECDCA";
  const scoreLabel = callScore >= 8 ? "Healthy" : callScore >= 6.5 ? "Watch closely" : "At risk";

  // Sub-factors that drive conversation health — all conversation-scoped,
  // not tied to any single agent's performance.
  const healthFactors = isChurn ?
  [
  { label: "Sentiment trajectory", v: 4.6 },
  { label: "Response cadence", v: 5.2 },
  { label: "Resolution clarity", v: 5.8 },
  { label: "Engagement", v: 6.0 }] :
  isVIP ?
  [
  { label: "Sentiment trajectory", v: 9.0 },
  { label: "Response cadence", v: 8.4 },
  { label: "Resolution clarity", v: 8.8 },
  { label: "Engagement", v: 9.2 }] :
  [
  { label: "Sentiment trajectory", v: 7.4 },
  { label: "Response cadence", v: 7.0 },
  { label: "Resolution clarity", v: 7.2 },
  { label: "Engagement", v: 7.5 }];


  // Conversation-level summary metrics — tracked across the whole thread,
  // not for any individual agent. `agentAvg` and `teamAvg` are 30-day
  // benchmarks shown beneath each metric so the admin can read the value
  // in context (am I above/below my own pace? above/below the team?).
  // Deterministic per-thread jitter so each conversation reads differently
  // while staying inside the band its tags imply.
  const mSeed = String(thread.id).split("").reduce((a, c) => a + c.charCodeAt(0), 0);
  const band = isChurn ? "risk" : isVIP ? "strong" : "steady";
  const pick = (map, k) => {const arr = map[band];return arr[(mSeed + k) % arr.length];};
  const M = {
    sentiment: { risk: ["−0.51", "−0.42", "−0.34", "−0.27"], steady: ["+0.11", "+0.18", "+0.24", "+0.31"], strong: ["+0.58", "+0.64", "+0.71", "+0.77"] },
    sentAgent: { risk: ["+0.02", "+0.05", "+0.09"], steady: ["+0.19", "+0.22", "+0.27"], strong: ["+0.38", "+0.42", "+0.47"] },
    sentTeam: { risk: ["−0.04", "−0.02", "+0.01"], steady: ["+0.12", "+0.14", "+0.17"], strong: ["+0.24", "+0.28", "+0.31"] },
    resp: { risk: ["1h 52m", "2h 14m", "2h 46m", "3h 08m"], steady: ["26m", "33m", "38m", "47m"], strong: ["7m", "12m", "16m", "21m"] },
    respDelta: { risk: ["▲ +32m vs SLA", "▲ +48m vs SLA", "▲ +1h 06m vs SLA"], steady: ["▼ Within SLA", "▼ Within SLA", "▼ 12m under SLA"], strong: ["▼ Well within SLA", "▼ Within SLA", "▼ 20m under SLA"] },
    respAgent: { risk: ["28m ↓", "32m ↓", "39m ↓"], steady: ["19m ↓", "22m ↓", "27m ↓"], strong: ["14m ↓", "18m ↓", "23m ↓"] },
    respTeam: { risk: ["38m ↓", "41m ↓", "46m ↓"], steady: ["23m ↓", "25m ↓", "29m ↓"], strong: ["21m ↓", "24m ↓", "28m ↓"] },
    openDays: { risk: [8, 11, 14, 17], steady: [2, 4, 5, 7], strong: [1, 2, 3, 4] }
  };
  const riskValue = isChurn ? "High" : isVIP ? "Low" : "Medium";
  const openDays = pick(M.openDays, 3);
  const convoMetrics = [
  { label: "Customer sentiment", value: pick(M.sentiment, 0), delta: isChurn ? "▼ Cooling" : isVIP ? "▲ Warming" : "▲ Stable", down: isChurn,
    agentAvg: `${pick(M.sentAgent, 1)} ▲`, teamAvg: `${pick(M.sentTeam, 2)} ${band === "risk" ? "▼" : "▲"}` },
  { label: "Avg response time", value: pick(M.resp, 1), delta: pick(M.respDelta, 2), down: isChurn,
    agentAvg: pick(M.respAgent, 3), teamAvg: pick(M.respTeam, 0) },
  { label: "Resolution risk", value: riskValue, delta: isVIP ? "On track" : `Open ${openDays} day${openDays === 1 ? "" : "s"}`, down: isChurn,
    agentAvg: isChurn ? "Low" : "Low", teamAvg: "Medium" }];

  // Friction / momentum signals — dynamically synthesized from the actual
  // thread messages. We scan THREAD_MESSAGES[thread.id] for content cues
  // (questions, gratitude, hesitation, channel switches, gaps, decisions)
  // and pick a randomized subset so the panel feels live across refreshes.
  // severity: "info" (FYI) | "watch" (keep an eye on) | "urgent" (act now)
  const firstName = (thread.name || "Contact").split(" ")[0];
  // Some threads store `assignee` as an object ({name, avatar, …}) rather than
  // a plain string — coerce to a name string before splitting to avoid a crash.
  const assigneeName = typeof thread.assignee === "string" ? thread.assignee : thread.assignee && (thread.assignee.name || thread.assignee.fullName) || "";
  const agentFirst = assigneeName ? assigneeName.split(" ")[0] : null;
  const agentLabel = agentFirst || "the agent";
  const signals = useMemo(() => {
    const msgs = window.THREAD_MESSAGES && window.THREAD_MESSAGES[thread.id] || [];
    const indexed = msgs.map((m, idx) => ({ m, idx })).filter(({ m }) => m.kind !== "date");
    if (!indexed.length) return [];

    // Tiny helper: shorten a message text into a quoted snippet for the detail line.
    const snip = (s, max = 56) => {
      if (!s) return "";
      const t = String(s).replace(/\s+/g, " ").trim();
      return t.length <= max ? t : t.slice(0, max - 1).replace(/[\s,.!?;:]+\S*$/, "") + "…";
    };
    const anchorOf = (idx) => {
      const m = msgs[idx];
      if (!m) return "";
      // Walk backward to the most recent date label for a nicer anchor.
      let date = "";
      for (let i = idx - 1; i >= 0; i--) {
        if (msgs[i].kind === "date") {date = msgs[i].label;break;}
      }
      return [date, m.time].filter(Boolean).join(" · ");
    };

    // Detect content-driven cues across the thread.
    const candidates = [];
    indexed.forEach(({ m, idx }) => {
      const text = String(m.text || m.summary || "").toLowerCase();
      const dir = m.dir;
      const isCustomer = dir === "in";
      const isAgent = dir === "out";

      // Open question from customer — needs a reply.
      if (isCustomer && /\?/.test(text)) {
        candidates.push({
          severity: "watch", label: "Open question", weight: 3,
          detail: `${firstName} asked: "${snip(m.text)}"`,
          action: `Suggest ${agentLabel} reply today or reassign.`,
          msgIdx: idx, anchor: anchorOf(idx)
        });
      }
      // Buying / decision-stage language.
      if (isCustomer && /(decide|decision|sign|onboard|next step|when can we|ready to|let's go|loop in|ceo|cto|founder|exec|approve)/.test(text)) {
        candidates.push({
          severity: "info", label: "Buying signal", weight: 2,
          detail: `Decision-stage language — "${snip(m.text)}"`,
          action: `Reply with concrete next steps within 2 hours.`,
          msgIdx: idx, anchor: anchorOf(idx)
        });
      }
      // Positive sentiment / acknowledgement.
      if (isCustomer && /(great|awesome|love|thanks|thank you|appreciate|perfect|amazing|excited)/.test(text)) {
        candidates.push({
          severity: "info", label: "Positive sentiment", weight: 1,
          detail: `${firstName} responded warmly — "${snip(m.text)}"`,
          action: `Capitalize: ask for a referral or expansion.`,
          msgIdx: idx, anchor: anchorOf(idx)
        });
      }
      // Hesitation / objection cues.
      if (isCustomer && /(but|however|expensive|too much|not sure|concern|worried|hesitant|delay|push back)/.test(text)) {
        candidates.push({
          severity: "urgent", label: "Hesitation detected", weight: 4,
          detail: `Objection cue — "${snip(m.text)}"`,
          action: `Address head-on with proof or pricing options.`,
          msgIdx: idx, anchor: anchorOf(idx)
        });
      }
      // Pricing / scope topic raised.
      if (/(pricing|price|cost|quote|breakdown|invoice|discount)/.test(text)) {
        candidates.push({
          severity: "watch", label: "Pricing on-thread", weight: 2,
          detail: `Pricing referenced — "${snip(m.text)}"`,
          action: `Confirm pricing in writing before scope drifts.`,
          msgIdx: idx, anchor: anchorOf(idx)
        });
      }
      // Commitment from agent — track delivery.
      if (isAgent && /(i'?ll|we'?ll|will send|will share|will set up|loop|follow up|circle back|booked|confirmed)/.test(text)) {
        candidates.push({
          severity: "watch", label: "Commitment made", weight: 2,
          detail: `Agent committed — "${snip(m.text)}"`,
          action: `Verify follow-through landed on time.`,
          msgIdx: idx, anchor: anchorOf(idx)
        });
      }
      // Channel switch — call inside a chat thread.
      if (m.kind === "call") {
        candidates.push({
          severity: "watch", label: "Channel switch", weight: 1,
          detail: `Conversation moved to a ${m.duration ? "voice call" : "call"} — context split across surfaces.`,
          action: `Summarize call outcome inline so context stays unified.`,
          msgIdx: idx, anchor: anchorOf(idx)
        });
      }
    });

    // Quiet period — gap between the last customer reply and now.
    const lastIn = [...indexed].reverse().find(({ m }) => m.dir === "in");
    const lastOut = [...indexed].reverse().find(({ m }) => m.dir === "out");
    if (lastOut && (!lastIn || lastIn.idx < lastOut.idx)) {
      candidates.push({
        severity: "watch", label: "Quiet period", weight: 2,
        detail: `${firstName} hasn't replied since the last outbound.`,
        action: `Send a low-pressure nudge referencing the open ask.`,
        msgIdx: lastOut.idx, anchor: anchorOf(lastOut.idx)
      });
    }
    // Reply velocity up — customer replied quickly after agent.
    if (lastIn && lastOut && lastIn.idx > lastOut.idx) {
      candidates.push({
        severity: "info", label: "Reply velocity up", weight: 1,
        detail: `${firstName} replied promptly — momentum is on.`,
        action: `Strike while hot — propose the next milestone now.`,
        msgIdx: lastIn.idx, anchor: anchorOf(lastIn.idx)
      });
    }

    // Persona overrides keep the demo feeling distinct for tagged threads.
    if (isChurn) {
      candidates.push({
        severity: "urgent", label: "Sentiment drop", weight: 5,
        detail: `${firstName}'s tone cooled sharply across recent messages.`,
        action: `Schedule a recovery call before EOD; flag to manager.`,
        msgIdx: indexed[Math.min(indexed.length - 1, 4)].idx,
        anchor: anchorOf(indexed[Math.min(indexed.length - 1, 4)].idx)
      });
    }
    if (isVIP) {
      candidates.push({
        severity: "info", label: "VIP momentum", weight: 3,
        detail: `Tagged VIP — engagement is trending up this week.`,
        action: `Offer a white-glove sync; pre-empt expansion asks.`,
        msgIdx: indexed[indexed.length - 1].idx,
        anchor: anchorOf(indexed[indexed.length - 1].idx)
      });
    }

    if (!candidates.length) return [];

    // Seeded pseudo-random pick — stable per (thread, sessionSeed) so
    // refreshing the page surfaces a different mix, but switching tabs
    // doesn't reshuffle. Bias toward higher weight (more meaningful cues).
    const seedKey = `${thread.id}::${signalSeed}`;
    let s = 0;
    for (let i = 0; i < seedKey.length; i++) s = s * 31 + seedKey.charCodeAt(i) >>> 0;
    const rand = () => {s = s * 1664525 + 1013904223 >>> 0;return s / 0x100000000;};

    // Deduplicate by msgIdx + label so we don't double up on the same moment.
    const dedup = [];
    const seen = new Set();
    candidates.forEach((c) => {
      const k = `${c.msgIdx}::${c.label}`;
      if (seen.has(k)) return;
      seen.add(k);
      dedup.push(c);
    });

    // Weighted shuffle: jitter each weight, then sort.
    const shuffled = dedup.
    map((c) => ({ c, score: c.weight + rand() * 1.5 })).
    sort((a, b) => b.score - a.score).
    map(({ c }) => c);

    // Show 2–4 signals — count is seeded so it stays stable per-thread until
    // the admin clicks Re-scan (which bumps signalSeed).
    const count = Math.min(shuffled.length, 2 + s % 3);
    return shuffled.slice(0, count);
  }, [thread.id, signalSeed, isChurn, isVIP]);

  // Severity styling — info (blue, FYI), watch (amber, keep an eye on), urgent (red, act now)
  const severityStyle = (s) =>
  s === "urgent" ? { fg: "#B42318", bg: "#FEF3F2", bd: "#FECDCA", dot: "#D92D20", label: "Urgent" } :
  s === "watch" ? { fg: "#B54708", bg: "#FFFAEB", bd: "#FEDF89", dot: "#F5C518", label: "Watch" } :
  { fg: "#6941C6", bg: "#F9F5FF", bd: "#E9D7FE", dot: "#7F56D9", label: "Info" };

  const onSignalClick = (sig) => {
    if (sig.msgIdx == null) return;
    window.dispatchEvent(new CustomEvent("convo:scrollToMsg", { detail: { msgIdx: sig.msgIdx } }));
  };

  // Admin sends the suggestion as an internal note tagged to the agent.
  // The note bubble shows up in-thread, mentioning @<agentFirst>, so the
  // agent sees the coaching prompt in context.
  const sendSuggestionAsNote = (sig) => {
    const adminName = window.CURRENT_USERS && window.CURRENT_USERS.admin && window.CURRENT_USERS.admin.name || "Admin";
    const fmtTime = () => {
      const d = new Date();
      let h = d.getHours();const m = String(d.getMinutes()).padStart(2, "0");
      const ampm = h >= 12 ? "pm" : "am";h = h % 12 || 12;
      return `${h}:${m} ${ampm}`;
    };
    window.dispatchEvent(new CustomEvent("convo:appendMessage", {
      detail: {
        threadId: thread.id,
        message: {
          kind: "note", dir: "out", time: fmtTime(), author: adminName,
          mention: agentLabel,
          text: `@${agentLabel} ${sig.action} (Coaching nudge — ${sig.label})`
        }
      }
    }));
    if (window.showAppToast) window.showAppToast(`Sent to ${agentLabel} as a thread note`);
  };

  const SectionHeader = ({ title, sub }) =>
  <div style={{ marginBottom: 10 }}>
      <div style={{
      fontSize: 11, fontWeight: 600, color: "#475467",
      textTransform: "uppercase", letterSpacing: ".06em"
    }} data-comment-anchor="20bb00a375-div-1722-7">{title}</div>
      {sub && <div style={{ fontSize: 12, color: "#98A2B3", marginTop: 2 }}>{sub}</div>}
    </div>;


  return (
    <div style={{ padding: bare ? 0 : "16px 16px 24px", display: "flex", flexDirection: "column", gap: 22 }} data-comment-anchor={bare ? undefined : "725eac58f9-div-1731-5"}>
      {/* Friction / momentum signals — conversation-scoped, dynamically synthesized */}
      <div>
        <div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", marginBottom: 10 }}>
          <div style={SHEET_HEADING}>Suggested Actions</div>
          {!hideSignals &&
          <button
            onClick={() => setSignalSeed((Date.now() + Math.floor(Math.random() * 1000)) % 100000)}
            title="Re-scan thread"
            style={{
              fontFamily: "inherit", fontSize: 11, fontWeight: 600, color: "#5925DC",
              background: "#FFFFFF", border: "1px solid #E9D7FE", borderRadius: 4,
              padding: "3px 8px", cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 4
            }}
            onMouseEnter={(e) => e.currentTarget.style.background = "#F4EBFF"}
            onMouseLeave={(e) => e.currentTarget.style.background = "#FFFFFF"}>
            <I.sparkle size={11} stroke="#5925DC" /> Re-scan
          </button>
          }
        </div>
        <div style={{
          border: "1px solid #EAECF0", borderRadius: 10, background: "#FFFFFF",
          maxHeight: 360, overflowY: "auto"
        }} data-comment-anchor="6bfeba83f4-div-1785-9">
          {signals.length === 0 && !extraActions.length &&
          <div style={{ padding: "14px", fontSize: 12, color: "#98A2B3", fontStyle: "italic" }}>
            No notable signals in this thread yet.
          </div>
          }
          {(hideSignals ? [] : signals).map((s, i) => {
            const sv = severityStyle(s.severity);
            const clickable = s.msgIdx != null;
            const isHover = hoverIdx === i;
            const isSuggestionHover = hoverIdx === `sug-${i}`;
            // Each row: header (dot + label + severity + detail) is a jump-to-msg
            // target. Below it, an admin-suggestion strip wraps full-width and
            // sends a coaching note when clicked.
            return (
              <div
                key={`${s.label}-${s.msgIdx}-${i}`}
                style={{
                  borderTop: i === 0 ? "none" : "1px solid #F2F4F7",
                  background: isHover ? "#F9FAFB" : "transparent",
                  transition: "background .15s",
                  padding: "12px 14px",
                  display: "flex", flexDirection: "column", gap: 8
                }}
                onMouseEnter={() => setHoverIdx(i)}
                onMouseLeave={() => setHoverIdx((h) => h === i ? -1 : h)}>
                <div
                  role={clickable ? "button" : undefined}
                  tabIndex={clickable ? 0 : undefined}
                  onClick={() => onSignalClick(s)}
                  onKeyDown={(e) => {if (clickable && (e.key === "Enter" || e.key === " ")) {e.preventDefault();onSignalClick(s);}}}
                  title={clickable ? `Jump to ${s.anchor || "thread moment"}` : undefined}
                  style={{
                    display: "flex", alignItems: "flex-start", gap: 10,
                    cursor: clickable ? "pointer" : "default",
                    fontFamily: "inherit"
                  }}>
                  <span style={{
                    width: 8, height: 8, borderRadius: 500, background: sv.dot,
                    marginTop: 6, flexShrink: 0
                  }} />
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
                      <span style={{ fontSize: 13, fontWeight: 600, color: "#101828" }}>{s.label}</span>
                      <span style={{
                        fontSize: 10, fontWeight: 600, color: sv.fg,
                        background: sv.bg, border: `1px solid ${sv.bd}`,
                        padding: "1px 6px", borderRadius: 500,
                        textTransform: "uppercase", letterSpacing: ".04em"
                      }}>{sv.label}</span>
                    </div>
                    <div style={{
                      fontSize: 12, color: "#475467", marginTop: 2, lineHeight: 1.45,
                      display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical",
                      overflow: "hidden", textOverflow: "ellipsis"
                    }} title={s.detail}>
                      {s.detail}
                    </div>
                  </div>
                </div>
                {/* Coaching suggestion strip — admin → agent. Wraps full-width
                                               within the row. Click sends a coaching note tagged to the agent. */}
                {s.action &&
                <button
                  onClick={(e) => {e.stopPropagation();sendSuggestionAsNote(s);}}
                  onMouseEnter={() => setHoverIdx(`sug-${i}`)}
                  onMouseLeave={() => setHoverIdx((h) => h === `sug-${i}` ? -1 : h)}
                  style={{
                    width: "calc(100% - 18px)", marginLeft: 18,
                    display: "flex", alignItems: "center", gap: 8,
                    padding: "8px 10px", borderRadius: 8,
                    border: "none",
                    color: "#101828", fontFamily: "inherit", fontSize: 12,
                    lineHeight: 1.45, textAlign: "left", cursor: "pointer",
                    transition: "background .15s ease, transform .15s ease", whiteSpace: "normal", wordBreak: "break-word",
                    transform: isSuggestionHover ? "scale(1.015)" : "scale(1)",
                    transformOrigin: "left center",
                    background: isSuggestionHover ? "rgba(16, 24, 40, 0.07)" : "rgba(16, 24, 40, 0.035)"
                  }}
                  title={`Send to ${agentLabel} as an internal thread note`}>
                  <span style={{ flex: 1, minWidth: 0 }}>{s.action}</span>
                  <I.note
                    size={14}
                    stroke="#475467"
                    style={{
                      flexShrink: 0, opacity: isSuggestionHover ? 1 : 0,
                      transition: "opacity .15s"
                    }} />
                </button>
                }
              </div>);
          })}
          {/* Merged — AI next actions live in the same container as the signal-
                                  derived suggestions. Heading row = the action, context row = the part of
                                  the conversation that prompted it. Click drafts the reply. */}
          {extraActions.map((step, i) => {
            const sev = severityStyle(step.severity || "info");
            const hov = hoverIdx === `na-${i}`;
            return (
              <button
                key={`na-${i}`}
                onClick={() => window.dispatchEvent(new CustomEvent("convo:draft", { detail: { text: step.draft || "", channel: step.channel || "sms", title: step.title, attachment: step.attachment || null } }))}
                onMouseEnter={() => setHoverIdx(`na-${i}`)}
                onMouseLeave={() => setHoverIdx((h) => h === `na-${i}` ? -1 : h)}
                style={{
                  width: "100%", textAlign: "left", fontFamily: "inherit", cursor: "pointer",
                  border: "none",
                  borderTop: (hideSignals || signals.length === 0) && i === 0 ? "none" : "1px solid #F2F4F7",
                  background: hov ? "#FCFCFD" : "transparent",
                  transition: "background .15s", padding: "12px 14px",
                  display: "block"
                }}>
                {/* Heading — the event that warrants the action + urgency dot */}
                <span style={{ display: "flex", alignItems: "center", gap: 8 }}>
                  <span style={{
                    width: 8, height: 8, borderRadius: 500, background: sev.dot, flexShrink: 0
                  }} />
                  <span style={{
                    flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600, color: "#101828",
                    overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"
                  }}>{step.event || step.title}</span>
                  <I.chevRight size={12} stroke="#98A2B3" style={{ flexShrink: 0 }} />
                </span>
                {/* Reference from the thread — clamped to 2 lines */}
                <span style={{
                  display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical",
                  overflow: "hidden", fontSize: 12, color: "#667085", lineHeight: 1.45,
                  margin: "4px 0 0 16px"
                }} title={step.description || step.subtitle}>
                  {step.description || step.subtitle}
                </span>
                {/* Suggested action to take — grey strip, grows + darkens on hover */}
                <span style={{
                  display: "block", margin: "8px 0 0 16px", padding: "7px 10px", borderRadius: 6,
                  fontSize: 12, fontWeight: 600, color: hov ? "#101828" : "#344054",
                  background: hov ? "rgba(16, 24, 40, 0.07)" : "rgba(16, 24, 40, 0.035)",
                  transform: hov ? "scale(1.015)" : "scale(1)", transformOrigin: "left center",
                  transition: "background .15s ease, transform .15s ease, color .15s ease"
                }}>{step.title}</span>
              </button>);
          })}
        </div>
      </div>


      {/* Conversation health score — primary value as a pill in the heading row;
                                     4 sub-factors arranged as a 2x2 grid in the container below.
                                     Hidden by request — flip SHOW_QUALITY_SECTION back to true to restore. */}
      {false &&
      <div data-comment-anchor="9934109dbc-div-1882-7">
        <div style={{
          marginBottom: 10, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10
        }}>
          <div style={{
            fontSize: 11, fontWeight: 600, color: "#475467",
            textTransform: "uppercase", letterSpacing: ".06em"
          }}>Quality</div>
          <div style={{
            display: "inline-flex", alignItems: "center", gap: 6,
            background: scoreBg, border: `1px solid ${scoreBorder}`,
            padding: "2px 10px", borderRadius: 500
          }}
          title={`${callScore.toFixed(1)} / 10 · ${scoreLabel}`}>
            <span style={{ fontSize: 13, fontWeight: 700, color: scoreColor, lineHeight: 1.2, letterSpacing: "-0.01em" }}>
              {callScore.toFixed(1)}
            </span>
            <span style={{ fontSize: 10, fontWeight: 600, color: scoreColor, opacity: .75 }}></span>
            <span style={{ width: 1, height: 10, background: scoreBorder, margin: "0 2px" }} />
            <span style={{ fontSize: 10, fontWeight: 600, color: scoreColor, textTransform: "uppercase", letterSpacing: ".04em" }}>
              {scoreLabel}
            </span>
          </div>
        </div>
        <div style={{
          border: "1px solid #EAECF0", borderRadius: 10, background: "#FFFFFF",

          display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", columnGap: 16, rowGap: 12, padding: "14px 14px 18px"
        }}>
          {healthFactors.map((f, i) => {
            const pct = Math.round(f.v * 10);
            const col = f.v >= 8 ? "#12B76A" : f.v >= 6.5 ? "#F79009" : "#D92D20";
            return (
              <div key={i} style={{ minWidth: 0, display: "flex", flexDirection: "column", gap: 4 }}>
                <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 6 }}>
                  <span style={{
                    fontSize: 12, color: "#475467", fontWeight: 500,
                    whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", minWidth: 0
                  }}
                  title={f.label}>{f.label}</span>
                  <span style={{ fontSize: 12, fontWeight: 600, color: "#101828", flexShrink: 0 }}>
                    {f.v.toFixed(1)}
                  </span>
                </div>
                <div style={{ height: 4, background: "#F2F4F7", borderRadius: 4, overflow: "hidden" }}>
                  <div style={{ width: `${pct}%`, height: "100%", background: col, borderRadius: 4 }} />
                </div>
              </div>);
          })}
        </div>
      </div>
      }

    </div>);

};

// Open commitments — small checklist shown in the AI insights tab.
// AI seeds the initial list (from `ins.commitments`); user can toggle items
// done/undone, add new ones, delete, and ask AI to refresh suggestions.
// State is keyed per-thread so it persists when switching threads/tabs.
const OpenCommitments = ({ threadId, initial }) => {
  // Each thread gets its own commitments list — store per id
  const [byThread, setByThread] = useState({});
  const items = byThread[threadId] || initial;
  // Seed once per thread
  useEffect(() => {
    setByThread((prev) => prev[threadId] ? prev : { ...prev, [threadId]: initial });
  }, [threadId]);

  const updateItems = (next) =>
  setByThread((prev) => ({ ...prev, [threadId]: typeof next === "function" ? next(prev[threadId] || initial) : next }));

  const toggle = (id) => updateItems((prev) => prev.map((c) => c.id === id ? { ...c, done: !c.done } : c));
  const remove = (id) => updateItems((prev) => prev.filter((c) => c.id !== id));
  const [adding, setAdding] = useState(false);
  const [draft, setDraft] = useState("");
  const inputRef = useRef(null);
  useEffect(() => {if (adding && inputRef.current) inputRef.current.focus();}, [adding]);

  const commit = () => {
    const text = draft.trim();
    if (!text) {setAdding(false);setDraft("");return;}
    updateItems((prev) => [...prev, { id: `m_${Date.now()}`, text, done: false }]);
    setDraft("");setAdding(false);
  };

  const refreshAI = () => {
    if (window.showAppToast) window.showAppToast("AI refreshing commitments…");
    // Simulated refresh — re-seed from initial list
    updateItems(initial.map((c, i) => ({ ...c, id: `ai_${Date.now()}_${i}` })));
  };

  const openCount = items.filter((c) => !c.done).length;

  return (
    <div>
      <div style={{
        display: "flex", alignItems: "center", gap: 6, marginBottom: 8
      }}>
        <span style={{
          fontSize: 10, fontWeight: 700, color: "#98A2B3",
          textTransform: "uppercase", letterSpacing: ".06em"
        }}>Open commitments</span>
        <span style={{
          fontSize: 10, fontWeight: 600, color: "#475467",
          background: "#F2F4F7", border: "1px solid #E4E7EC",
          padding: "1px 7px", borderRadius: 500, minWidth: 18, textAlign: "center"
        }}>{openCount}</span>
        <span style={{ flex: 1 }} />
        <button
          onClick={refreshAI}
          title="Ask AI to refresh"
          style={{
            display: "inline-flex", alignItems: "center", gap: 4,
            padding: "3px 8px", borderRadius: 4, border: "1px solid #E9D7FE",
            background: "#FFFFFF", color: "#5925DC",
            fontSize: 11, fontWeight: 600, cursor: "pointer", fontFamily: "inherit"
          }}
          onMouseEnter={(e) => e.currentTarget.style.background = "#F4EBFF"}
          onMouseLeave={(e) => e.currentTarget.style.background = "#FFFFFF"}>
          <I.sparkle size={11} stroke="#5925DC" /> Refresh
        </button>
      </div>

      <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
        {items.length === 0 && !adding &&
        <div style={{ fontSize: 12, color: "#98A2B3", fontStyle: "italic", padding: "4px 0" }}>
            No open commitments yet
          </div>
        }
        {items.map((c) =>
        <div key={c.id}
        className="commitment-row"
        style={{
          display: "flex", alignItems: "center", gap: 10,
          padding: "6px 8px", borderRadius: 6,
          background: "transparent",
          transition: "background .15s"
        }}
        onMouseEnter={(e) => {
          e.currentTarget.style.background = "#F9FAFB";
          const del = e.currentTarget.querySelector("[data-del]");
          if (del) del.style.opacity = 1;
        }}
        onMouseLeave={(e) => {
          e.currentTarget.style.background = "transparent";
          const del = e.currentTarget.querySelector("[data-del]");
          if (del) del.style.opacity = 0;
        }}>
            <button
            onClick={() => toggle(c.id)}
            title={c.done ? "Mark as open" : "Mark as done"}
            style={{
              width: 16, height: 16, borderRadius: 4, flexShrink: 0,
              border: c.done ? "1px solid #12B76A" : "1.5px solid #D0D5DD",
              background: c.done ? "#12B76A" : "#FFFFFF",
              cursor: "pointer", padding: 0,
              display: "inline-flex", alignItems: "center", justifyContent: "center",
              transition: "background .15s, border-color .15s"
            }}>
              {c.done && <I.check size={10} stroke="#FFFFFF" />}
            </button>
            <span style={{
            flex: 1, fontSize: 13, lineHeight: 1.45,
            color: c.done ? "#98A2B3" : "#344054",
            textDecoration: c.done ? "line-through" : "none"
          }}>{c.text}</span>
            <button
            data-del
            onClick={() => remove(c.id)}
            title="Remove"
            style={{
              width: 20, height: 20, borderRadius: 4, border: "none",
              background: "transparent", cursor: "pointer", padding: 0,
              display: "inline-flex", alignItems: "center", justifyContent: "center",
              opacity: 0, transition: "opacity .15s"
            }}>
              <I.x size={11} stroke="#98A2B3" />
            </button>
          </div>
        )}

        {adding ?
        <div style={{
          display: "flex", alignItems: "center", gap: 10,
          padding: "6px 8px", borderRadius: 6,
          background: "#F9FAFB", border: "1px dashed #D0D5DD"
        }}>
            <span style={{
            width: 16, height: 16, borderRadius: 4, flexShrink: 0,
            border: "1.5px solid #D0D5DD", background: "#FFFFFF"
          }} />
            <input
            ref={inputRef}
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") commit();else
              if (e.key === "Escape") {setAdding(false);setDraft("");}
            }}
            onBlur={commit}
            placeholder="Add a commitment…"
            style={{
              flex: 1, border: "none", outline: "none",
              background: "transparent", fontSize: 13, color: "#101828",
              fontFamily: "inherit"
            }} />
          </div> :

        <button
          onClick={() => setAdding(true)}
          style={{
            display: "inline-flex", alignItems: "center", gap: 6,
            padding: "6px 8px", borderRadius: 6, border: "none",
            background: "transparent", color: "#475467",
            fontSize: 12, fontWeight: 500, cursor: "pointer",
            fontFamily: "inherit", alignSelf: "flex-start", marginTop: 2
          }}
          onMouseEnter={(e) => e.currentTarget.style.background = "#F9FAFB"}
          onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
            <I.plus size={12} stroke="#475467" /> Add commitment
          </button>
        }
      </div>
    </div>);

};

// Summary text clamped to 3 lines with a read more / read less toggle.
// The toggle only appears when the text actually overflows.
const ClampedSummary = ({ text, style }) => {
  const [expanded, setExpanded] = useState(false);
  const [overflows, setOverflows] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    setExpanded(false);
    const el = ref.current;
    if (!el) return;
    const check = () => setOverflows(el.scrollHeight - el.clientHeight > 1);
    check();
    const t = setTimeout(check, 60);
    return () => clearTimeout(t);
  }, [text]);
  return (
    <div>
      <div
        ref={ref}
        style={{
          ...style,
          ...(expanded ? {} : {
            display: "-webkit-box", WebkitLineClamp: 3,
            WebkitBoxOrient: "vertical", overflow: "hidden"
          })
        }}>{text}</div>
      {(overflows || expanded) &&
      <button
        type="button"
        onClick={() => setExpanded((v) => !v)}
        style={{
          marginTop: 4, padding: 0, border: 0, background: "transparent",
          fontFamily: "inherit", fontSize: 12, fontWeight: 600,
          color: "#004CE6", cursor: "pointer"
        }}>{expanded ? "Read less" : "Read more"}</button>
      }
    </div>);
};

// ─── Evidence-backed summary ─────────────────────────────────────────────
// One numbered citation after a bit of the summary. Hovering previews the
// event it points at; clicking scrolls the thread to that event and flashes
// it. The card is position:fixed rather than absolute — the sheet clips its
// own overflow, and a card inside the scroller would be cut off.
const SummaryCite = ({ n, cite }) => {
  const [pos, setPos] = useState(null);
  const ref = useRef(null);
  const Ic = I[cite.icon] || I.chat;

  const place = () => {
    const el = ref.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const W = 272;
    const half = W / 2;
    const left = Math.min(Math.max(r.left + r.width / 2, half + 8), window.innerWidth - half - 8);
    // Above by default; below when the chip sits too close to the top.
    const above = r.top > 200;
    setPos({ left, above, y: above ? r.top - 8 : r.bottom + 8, w: W });
  };

  // A fixed card would drift away from its chip once the panel scrolls, so
  // follow the chip instead of dismissing — tabbing to a chip scrolls it into
  // view, and dismissing on that scroll meant a keyboard user never saw the
  // card at all. Out of the viewport entirely, it goes.
  useEffect(() => {
    if (!pos) return undefined;
    const track = () => {
      const el = ref.current;
      if (!el) return;
      const r = el.getBoundingClientRect();
      if (r.bottom < 0 || r.top > window.innerHeight) {setPos(null);return;}
      place();
    };
    document.addEventListener("scroll", track, true);
    window.addEventListener("resize", track);
    return () => {
      document.removeEventListener("scroll", track, true);
      window.removeEventListener("resize", track);
    };
  }, [!!pos]);

  const jump = () => {
    setPos(null);
    window.dispatchEvent(new CustomEvent("convo:scrollToMsg", { detail: { msgIdx: cite.idx } }));
  };

  return (
    <>
      <button
        ref={ref}
        type="button"
        onClick={jump}
        onMouseEnter={place}
        onMouseLeave={() => setPos(null)}
        onFocus={place}
        onBlur={() => setPos(null)}
        aria-label={`Source ${n} — ${cite.label}${cite.when ? `, ${cite.when}` : ""}. Open in the thread.`}
        style={{
          position: "relative",
          display: "inline-flex", alignItems: "center", justifyContent: "center",
          minWidth: 16, height: 16, padding: "0 3px", marginLeft: 5,
          verticalAlign: "-1px",
          border: `1px solid ${pos ? "#B692F6" : "#E4DFFE"}`,
          background: pos ? "#EBE9FE" : "#F4F3FF",
          // 5, not 4: the embed's harmonisation rule rewrites any inline
          // border-radius: 4px on a button to 6px, which is too round on a
          // chip this size.
          borderRadius: 5, cursor: "pointer", fontFamily: "inherit",
          fontSize: 9.5, fontWeight: 700, lineHeight: 1, color: "#5925DC",
          transition: "background .12s, border-color .12s"
        }}>
        {n}
        {/* A 16px chip is a 16px target. This pushes the hit area out to 24px
            square without growing the chip or the line box it sits in — the
            number wants to stay small, the thing you have to hit does not. */}
        <span aria-hidden="true" style={{
          // -5, not -4: inset works off the padding box, so the chip's 1px
          // border costs 2px of the square before this starts counting.
          position: "absolute", inset: -5, background: "transparent"
        }} />
      </button>
      {pos &&
      <div
        role="tooltip"
        style={{
          position: "fixed", zIndex: 60, left: pos.left, top: pos.y,
          width: pos.w, transform: `translate(-50%, ${pos.above ? "-100%" : "0"})`,
          background: "#FFFFFF", border: "1px solid #E9D7FE", borderRadius: 8,
          boxShadow: "0 12px 28px rgba(16,24,40,.12), 0 2px 6px rgba(16,24,40,.05)",
          padding: "9px 11px 8px", pointerEvents: "none",
          // The chip sits in a nowrap span so its citation stays glued to the
          // claim; the card must opt back out or its preview never wraps.
          whiteSpace: "normal", textAlign: "left",
          display: "flex", flexDirection: "column", gap: 5
        }}>
          <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
            <span style={{
            width: 18, height: 18, borderRadius: 5, background: "#F4F3FF",
            display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0
          }}>
              <Ic size={11} stroke="#5925DC" />
            </span>
            <span style={{ fontSize: 11.5, fontWeight: 600, color: "#42307D", flex: 1, minWidth: 0 }}>{cite.label}</span>
            {cite.when &&
          <span style={{ fontSize: 11, color: "#98A2B3", flexShrink: 0, whiteSpace: "nowrap" }}>{cite.when}</span>
          }
          </div>
          {cite.preview &&
        <div style={{
          fontSize: 12, color: "#475467", lineHeight: 1.45,
          display: "-webkit-box", WebkitLineClamp: 4, WebkitBoxOrient: "vertical", overflow: "hidden"
        }}>{cite.preview}</div>
        }
          <div style={{
          display: "flex", alignItems: "center", gap: 4, paddingTop: 5,
          borderTop: "1px solid #F4F3FF", fontSize: 10.5, fontWeight: 600, color: "#7F56D9"
        }}>
            {cite.author ? `${cite.author} · ` : ""}Click to open in the thread
          </div>
        </div>
      }
    </>);

};

// The summary as a stack of claims rather than a paragraph, each one carrying
// the events it came from. Citations are numbered continuously down the stack
// so a bit can cite two events without the numbers repeating.
const SummaryBits = ({ bits }) => {
  let n = 0;
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
      {bits.map((b, i) => {
        // The citations ride with the bit's last word. Left to wrap on their
        // own they end up orphaned on a line below the claim, reading like a
        // stray control rather than a footnote.
        const cut = b.text.lastIndexOf(" ");
        const head = cut > 0 ? b.text.slice(0, cut + 1) : "";
        const tail = cut > 0 ? b.text.slice(cut + 1) : b.text;
        return (
          <div key={i} style={{ fontSize: 12.5, color: "#344054", lineHeight: 1.45, fontWeight: 400 }}>
            {head}
            <span style={{ whiteSpace: "nowrap" }}>
              {tail}
              {b.cites.map((c, j) => <SummaryCite key={j} n={++n} cite={c} />)}
            </span>
          </div>);

      })}
    </div>);

};

const ContactAIInsightsBrief = ({
  thread, ins, onOpenAgent, focus = null,
  acceptedTags = [], dismissedTags = [],
  onAcceptTag, onDismissTag
}) => {
  const [openTags, setOpenTags] = useState(true);
  const [openReplies, setOpenReplies] = useState(true);
  const [openNext, setOpenNext] = useState(true);
  const [pulse, setPulse] = useState(false);
  const nextRef = useRef(null);

  // When the floating widget signals "open AI insights and pulse Next actions",
  // run a short pulse animation on the section and scroll it into view.
  useEffect(() => {
    if (focus && focus.key === "nextActions" && focus.ts) {
      setOpenNext(true);
      setPulse(true);
      const t1 = setTimeout(() => {
        if (nextRef.current && nextRef.current.scrollIntoView) {
          nextRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" });
        }
      }, 60);
      const t2 = setTimeout(() => setPulse(false), 900);
      return () => {clearTimeout(t1);clearTimeout(t2);};
    }
  }, [focus && focus.ts]);

  if (ins.empty) {
    return (
      <div style={{ padding: 20, display: "flex", alignItems: "center", justifyContent: "center" }}>
        <div style={{
          maxWidth: 360, textAlign: "center",
          display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
          padding: "32px 20px"
        }}>
          <div style={{
            width: 48, height: 48, borderRadius: 12, background: "#F9FAFB",
            border: "1px solid #EAECF0", display: "inline-flex",
            alignItems: "center", justifyContent: "center"
          }}>
            <I.sparkle size={22} stroke="#98A2B3" />
          </div>
          <div style={{ fontSize: 14, fontWeight: 600, color: "#344054" }}>No insights yet</div>
          <div style={{ fontSize: 12, color: "#667085", lineHeight: 1.5 }}>
            Not enough conversation to summarize. Insights appear automatically as the thread develops.
          </div>
        </div>
      </div>);
  }

  const a = window.buildAgentActivity ? window.buildAgentActivity(thread) : null;
  const tagsAll = a && a.tags || [];
  const repliesAll = a && a.replies || [];
  const tagSuggestions = tagsAll.slice(0, 2);
  const replySuggestions = repliesAll.slice(0, 2);

  const acceptTag = (label) => onAcceptTag && onAcceptTag(label);
  const dismissTag = (label) => onDismissTag && onDismissTag(label);
  const useReply = (text) => {
    window.dispatchEvent(new CustomEvent("convo:draft", { detail: { text, channel: "sms" } }));
  };

  const toneStyle = (tone) => {
    if (tone === "warn") return { bg: "#FEF3F2", color: "#B42318", border: "#FECDCA" };
    if (tone === "good") return { bg: "#ECFDF3", color: "#067647", border: "#ABEFC6" };
    return { bg: "#EEF4FF", color: "#3538CD", border: "#C7D7FE" };
  };

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "24px", padding: "16px 20px 20px" }}>
      {/* Conversation summary */}
      <div>
        <div style={{ ...SHEET_HEADING, marginBottom: 8 }}>
          Conversation Summary
        </div>
        {/* Bits with citations where the thread supports them; the plain
            paragraph when nothing resolves (a conversation started in this
            session has no message history to cite yet). */}
        {(ins.summaryBits || []).length > 0 ?
        <SummaryBits bits={ins.summaryBits} /> :
        <ClampedSummary
          text={ins.summary}
          style={{ fontSize: 12.5, color: "#344054", lineHeight: 1.45, fontWeight: 400 }} />
        }
        {/* `ins.summaryActions` used to render here as a bullet list under the
            summary. It cost ~125px of a ~390px section to restate what the
            Suggested Actions cards immediately below already say — and say
            better, with a draft and a CTA attached. Eleven of the twelve
            authored lines paired one-to-one with a card. The copy is still on
            `ins.summaryActions` if it's wanted back somewhere it isn't
            competing with the cards. */}
      </div>

      {/* Suggested actions — AI next actions merged into the coaching-signal
                                                                     section below; the ref keeps the pulse highlight from the floating widget. */}
      <div ref={nextRef} className={pulse ? "convoNextPulse" : ""} style={{ borderRadius: 8 }}>
        <CoachingTab thread={thread} bare hideSignals extraActions={(ins.nextSteps || []).slice(0, 4)} />
      </div>
    </div>);

};

// Collapsible section used inside the AI insights tab — header with chevron,
// icon, title, count badge; body sits in a bordered card below when open.
const CollapsibleAISection = ({ icon: IconC, title, count, open, onToggle, children }) =>
<div style={{ display: "flex", flexDirection: "column" }}>
    <div
    onClick={onToggle}
    style={{ ...{
        display: "flex", alignItems: "center", gap: 8,
        padding: "8px 12px",
        background: "#F9FAFB",
        border: "1px solid #E4E7EC",
        borderRadius: open ? "6px 6px 0 0" : 6,
        cursor: "pointer", userSelect: "none"
      }, borderRadius: "8px 8px 0px 0px" }}>
      <span style={{
      display: "inline-flex", alignItems: "center", justifyContent: "center",
      width: 14, height: 14, transition: "transform .15s",
      transform: open ? "rotate(90deg)" : "rotate(0deg)"
    }}>
        <I.chevRight size={11} stroke="#667085" />
      </span>
      <IconC size={13} stroke="#7F56D9" />
      <span style={{
      fontSize: 11, fontWeight: 600, color: "#344054",
      textTransform: "uppercase", letterSpacing: ".06em", flex: 1
    }}>{title}</span>
      {typeof count === "number" &&
    <span style={{
      fontSize: 10, fontWeight: 600, color: "#475467",
      background: "#FFFFFF", border: "1px solid #E4E7EC",
      padding: "1px 7px", borderRadius: 500, minWidth: 18, textAlign: "center"
    }}>{count}</span>
    }
    </div>
    {open &&
  <div style={{
    padding: 12, background: "#FFFFFF",
    border: "1px solid #E4E7EC", borderTop: "none", borderRadius: "0px 0px 8px 8px"

  }}>
        {children}
      </div>
  }
  </div>;


// Reusable brief body — used by the modal AND the "AI insights" tab in Bold mode
const AIInsightsBrief = ({ ins }) => {
  if (ins.empty) {
    return (
      <div style={{ padding: 20, overflowY: "auto", display: "flex", alignItems: "center", justifyContent: "center", flex: 1 }}>
        <div style={{
          maxWidth: 360, textAlign: "center",
          display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
          padding: "32px 20px"
        }}>
          <div style={{
            width: 48, height: 48, borderRadius: 12, background: "#F9FAFB",
            border: "1px solid #EAECF0", display: "inline-flex",
            alignItems: "center", justifyContent: "center"
          }}>
            <I.sparkle size={22} stroke="#98A2B3" />
          </div>
          <div style={{ fontSize: 14, fontWeight: 600, color: "#344054" }}>No insights yet</div>
          <div style={{ fontSize: 12, color: "#667085", lineHeight: 1.5 }}>
            Not enough conversation to summarize. Insights appear automatically as the thread develops.
          </div>
        </div>
      </div>);

  }
  return (
    <div style={{ padding: 20, overflowY: "auto", display: "flex", flexDirection: "column", gap: 18 }}>
      <Section title="Conversation summary">
        <div style={{
          fontSize: 13, lineHeight: 1.6, color: "#101828"
        }}>{ins.summary}</div>
      </Section>

      <Section title="Sentiment check">
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
          <Pill label="Sentiment" value={ins.sentiment} />
          <Pill label="Stage" value={ins.stage} />
        </div>
      </Section>

      <Section title="Next actions">
        <div style={{
          display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 10
        }}>
          {(ins.nextSteps || []).map((s, i) =>
          <button
            key={i}
            onClick={() => {
              window.dispatchEvent(new CustomEvent("convo:toast", { detail: { msg: `${s.title} · queued` } }));
            }}
            style={{
              textAlign: "left", cursor: "pointer", fontFamily: "inherit",
              background: "#FFFFFF", border: "1px solid #E4E7EC", borderRadius: 8,
              padding: "12px 14px", display: "flex", flexDirection: "column", gap: 4,
              transition: "border-color .15s, box-shadow .15s, transform .15s"
            }}
            onMouseEnter={(e) => {
              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 = "#E4E7EC";
              e.currentTarget.style.boxShadow = "none";
            }}>
              <div style={{ fontSize: 13, fontWeight: 600, color: "#101828", lineHeight: 1.35 }}>{s.title}</div>
              <div style={{ fontSize: 12, color: "#667085" }}>{s.subtitle}</div>
            </button>
          )}
        </div>
      </Section>
    </div>);

};

window.ContactPane = ContactPane;
window.AIInsightsCard = AIInsightsCard;
window.AIInsightsModal = AIInsightsModal;
window.AIInsightsBrief = AIInsightsBrief;
window.buildInsights = buildInsights;