UNPKG

relay-companion

Version:

Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.

1,780 lines • 79.5 kB
// The preview window wears the same sheet as the pill: light unless the pill's
// moon button ever chose dark (shared file-origin localStorage; never the OS).
try { if (localStorage.getItem("relayTheme") === "dark") document.documentElement.dataset.theme = "dark"; } catch {}
(() => {
  "use strict";

  const bridge = window.relayPreview;
  for (const eventName of ["pointerdown", "keydown", "wheel"]) {
    window.addEventListener(eventName, () => bridge.chatReadActivity?.(), { capture:true, passive:true });
  }
  const titleEl = document.getElementById("messageTitle");
  const metaEl = document.getElementById("messageMeta");
  const senderEl = document.getElementById("messageSender");
  const metaDotEl = document.getElementById("messageMetaDot");
  const timeEl = document.getElementById("messageTime");
  const bodyEl = document.getElementById("messageBody");
  const emptyEl = document.getElementById("emptyDetail");
  const errorEl = document.getElementById("renderError");
  const detailScrollEl = document.getElementById("detailScroll");
  const replyInputEl = document.getElementById("replyInput");
  const replyButtonEl = document.getElementById("replyButton");
  const safetyButtonEl = document.getElementById("safetyButton");
  const safetyPanelEl = document.getElementById("safetyPanel");
  const replyAttachButtonEl = document.getElementById("replyAttachButton");
  const replyFilePickerEl = document.getElementById("replyFilePicker");
  const replyFilesEl = document.getElementById("replyFiles");
  const replyNoteEl = document.getElementById("replyNote");
  const minimizeButtonEl = document.getElementById("minimizeButton");
  const closeButtonEl = document.getElementById("closeButton");
  const facesEl = document.getElementById("faces");
  const faceMessageEl = document.getElementById("faceMessage");
  const faceChatEl = document.getElementById("faceChat");
  const headMessageEl = document.getElementById("headMessage");
  const headChatEl = document.getElementById("headChat");
  const chatChipEl = document.getElementById("chatChip");
  const chatChipNameEl = document.getElementById("chatChipName");
  const chatChipCountEl = document.getElementById("chatChipCount");
  const chatBackEl = document.getElementById("chatBack");
  const chatNameEl = document.getElementById("chatName");
  const chatPeopleEl = document.getElementById("chatPeople");
  const chatScrollEl = document.getElementById("chatScroll");
  const chatListEl = document.getElementById("chatList");
  const chatStateEl = document.getElementById("chatState");
  const taskChipEl = document.getElementById("taskChip");
  const taskStateEl = document.getElementById("taskState");
  const taskRuntimeNoteEl = document.getElementById("taskRuntimeNote");
  const runtimeBtnEl = document.getElementById("runtimeBtn");
  const runtimeWordsEl = document.getElementById("runtimeWords");
  const runtimePopEl = document.getElementById("runtimePop");
  const rtCodexItemEl = document.getElementById("rtCodexItem");
  const sessionScrollEl = document.getElementById("sessionScroll");
  const sessionListEl = document.getElementById("sessionList");
  const sessionStateEl = document.getElementById("sessionState");
  const sessionFooterEl = document.querySelector(".reply-composer");

  const COMPOSER_MIN_HEIGHT = 42;
  const COMPOSER_MAX_HEIGHT = 124;
  const RUN_GAP_MS = 5 * 60 * 1000;
  const CLAMP_HEIGHT = 190;
  const DEFAULT_NOTE = "Press Enter to send, Shift + Enter for a new line.";
  const MAX_REPLY_FILES = 20;

  let renderRevision = 0;
  let chatRevision = 0;
  let content = {};
  /** The loaded conversation this message belongs to, or null. */
  let chat = null;
  /** Has the reader already been placed in this conversation once? */
  let landed = false;
  /**
   * This window IS a conversation, opened from a contact card rather than from
   * a message. There is no message behind it to go back to, so the reading face
   * never comes up and the composer writes to the room even when the room is
   * still empty.
   */
  let chatOnly = false;
  let face = "message";
  let sending = false;
  /** Replies shown before the server has confirmed them. */
  let outgoing = [];
  let stagedReplyFiles = [];
  /** Relay ids whose bubble the reader has opened in place. */
  const expanded = new Set();
  /** Relay ids whose per-person group receipt roster is visible. */
  const expandedReceipts = new Set();
  /** The task face's ignition state: composer starts the task, not a reply. */
  let taskMode = false;
  let starting = false;
  let reviewingSafety = false;
  /** A started task shows its live session in the message-face slot. */
  let sessionMode = false;
  let sessionWatching = false;
  let sessionWatchedRelayId = "";
  let sessionWatchEpoch = 0;
  let sessionRevision = 0;
  let sessionCurrentSessionId = "";
  let sessionFingerprint = "";
  let sessionLive = { provider: "", liveState: "", error: "" };
  /** Disclosure rows the reader has opened; keyed by a stable per-record key. */
  const actOpen = new Set();
  const sessionSummaryStabilizer = RelayWorkUI.createSummaryStabilizer({ delayMs:1000 });
  let sessionScrollController = null;
  /** The runtime the Start button will use. Claude Code · Opus 5 · high. */
  const runtime = { host: "claude", model: "claude-opus-5", effort: "high" };
  const MODEL_LABELS = { "claude-opus-5": "Opus 5", "claude-sonnet-5": "Sonnet 5" };
  const EFFORT_LABELS = { high: "High thinking", medium: "Standard thinking" };

  function text(value) {
    return String(value == null ? "" : value).trim();
  }

  function escapeHtml(value) {
    return String(value == null ? "" : value).replace(/[&<>"']/g, (character) => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" })[character]);
  }

  function firstMeaningfulLine(markdown) {
    for (const raw of String(markdown || "").replace(/\r/g, "").split("\n")) {
      const line = raw
        .replace(/^\s{0,3}#{1,6}\s+/, "")
        .replace(/^\s{0,3}>\s?/, "")
        .replace(/^\s{0,3}(?:[-*+] |\d+\. )/, "")
        .replace(/[*_`]+/g, "")
        .trim();
      if (line) return line.length > 140 ? `${line.slice(0, 139).trimEnd()}…` : line;
    }
    return "";
  }

  function titleFor(row, markdown) {
    return text(row.title) || firstMeaningfulLine(markdown) || "Relay preview";
  }

  function senderFor(row) {
    const sender = text(row.senderName);
    if (sender) return `From ${sender}${row.e2ee ? " · End-to-end encrypted" : ""}`;
    return "Relay";
  }

  function parseDate(value) {
    const raw = text(value);
    if (!raw) return null;
    const date = new Date(raw);
    return Number.isFinite(date.getTime()) ? date : null;
  }

  function dateFor(row) {
    const raw = text(row.createdAt);
    if (!raw) return { label: "", iso: "" };
    const date = parseDate(raw);
    if (!date) return { label: raw, iso: "" };
    return {
      label: new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date),
      iso: date.toISOString(),
    };
  }

  function clockLabel(value) {
    const date = parseDate(value);
    return date ? new Intl.DateTimeFormat(undefined, { timeStyle: "short" }).format(date) : "";
  }

  function receiptTimestamp(value) {
    const date = parseDate(value);
    return date ? new Intl.DateTimeFormat(undefined, { dateStyle:"medium", timeStyle:"short" }).format(date) : "Seen";
  }

  /** "Today" / "Yesterday" / a weekday inside the last week / a full date. */
  function dayLabel(value) {
    const date = parseDate(value);
    if (!date) return "";
    const startOf = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
    const days = Math.round((startOf(new Date()) - startOf(date)) / 86400000);
    if (days === 0) return "Today";
    if (days === 1) return "Yesterday";
    if (days > 1 && days < 7) return new Intl.DateTimeFormat(undefined, { weekday: "long" }).format(date);
    return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date);
  }

  function dayKey(value) {
    const date = parseDate(value);
    return date ? `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` : "";
  }

  function markdownFor(row) {
    return String(row.forHuman || "");
  }

  function setError(message) {
    bodyEl.replaceChildren();
    emptyEl.classList.add("gone");
    errorEl.textContent = text(message) || "This relay could not be rendered.";
    errorEl.classList.remove("gone");
  }

  function autosizeComposer() {
    replyInputEl.style.height = `${COMPOSER_MIN_HEIGHT}px`;
    replyInputEl.style.height = `${Math.min(
      COMPOSER_MAX_HEIGHT,
      Math.max(COMPOSER_MIN_HEIGHT, replyInputEl.scrollHeight),
    )}px`;
  }

  function clipboardFiles(event) {
    const clipboard = event && event.clipboardData;
    const files = [...(clipboard?.files || [])].filter(Boolean);
    if (files.length) return files;
    return [...(clipboard?.items || [])]
      .filter((item) => item && item.kind === "file" && typeof item.getAsFile === "function")
      .map((item) => item.getAsFile())
      .filter(Boolean);
  }

  function paintReplyFiles() {
    replyFilesEl.replaceChildren();
    replyFilesEl.classList.toggle("gone", !stagedReplyFiles.length);
    stagedReplyFiles.forEach((file, index) => {
      const chip = document.createElement("span");
      chip.className = "reply-file";
      const name = document.createElement("span");
      name.className = "reply-file-name";
      name.textContent = file.name || `pasted-${index + 1}`;
      const remove = document.createElement("button");
      remove.type = "button";
      remove.className = "reply-file-remove";
      remove.setAttribute("aria-label", `Remove ${name.textContent}`);
      remove.textContent = "×";
      remove.addEventListener("click", () => {
        stagedReplyFiles.splice(index, 1);
        paintReplyFiles();
        refreshComposer();
      });
      chip.append(name, remove);
      replyFilesEl.appendChild(chip);
    });
  }

  function stageReplyFiles(list) {
    for (const file of [...(list || [])].filter(Boolean)) {
      if (stagedReplyFiles.length >= MAX_REPLY_FILES) break;
      stagedReplyFiles.push(file);
    }
    paintReplyFiles();
    refreshComposer();
  }

  async function replyFilePayloads(staged) {
    const files = [];
    const attachments = [];
    for (let index = 0; index < staged.length; index += 1) {
      const file = staged[index];
      const name = String(file?.name || `pasted-${index + 1}`);
      const size = Number(file?.size || 0);
      const contentType = String(file?.type || "application/octet-stream");
      const osPath = bridge.pathForFile ? bridge.pathForFile(file) : "";
      if (osPath) {
        files.push({ name, size, contentType, path: osPath });
      } else if (file?.arrayBuffer) {
        const bytes = new Uint8Array(await file.arrayBuffer());
        let binary = "";
        for (let offset = 0; offset < bytes.length; offset += 0x8000) {
          binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
        }
        files.push({ name, size, contentType, contentBase64: btoa(binary) });
      } else {
        throw new Error(`${name} could not be read`);
      }
      attachments.push({ name, contentType, bytes: size });
    }
    return { files, attachments };
  }

  // --- The conversation affordance -------------------------------------------

  /**
   * Who a reply typed right now is answering. On the conversation face that is
   * the newest message in the room (the server decides, so a chat that merges
   * several threads stays one conversation); on the message face it is the
   * message being read.
   */
  function replyTargetId() {
    if (face === "chat" && chat && text(chat.replyToRelayId)) return text(chat.replyToRelayId);
    if (face === "chat" && chat && chat.items && chat.items.length) {
      return text(chat.items[chat.items.length - 1].relayId);
    }
    return text(content.relayId);
  }

  function composerPlaceholder() {
    if (face === "chat") {
      // The name the reader knows this person by comes with the window; the
      // room's own title is the fallback.
      const who = chatOnly
        ? text(content.chatTitle) || text(chat && chat.title)
        : text(chat && chat.title) || chatTitleHint(content);
      return `Message ${who || "this conversation"}…`;
    }
    const who = text(content.senderName).replace(/^You → /, "");
    return who && !content.outbound ? `Reply to ${who.split(" ")[0]}…` : "Write a reply…";
  }

  function sessionSettled() {
    if (Boolean(text(content.taskCompletedAt))) return true;
    return ["review_ready", "blocked", "completed", "failed", "idle", "archived", "stopped", "offline"]
      .includes(text(sessionLive.liveState).toLowerCase());
  }

  function refreshComposer() {
    const humanComposer = !taskMode && !(sessionMode && face !== "chat");
    replyAttachButtonEl.classList.toggle("gone", !humanComposer);
    replyAttachButtonEl.disabled = sending || !humanComposer;
    if (taskMode) {
      // The ignition: the note is optional, so Start is ready whenever a start
      // is not already in flight.
      replyInputEl.placeholder = "Add a note for your agent — sent to the sender when you start…";
      replyInputEl.disabled = starting;
      replyButtonEl.disabled = starting;
      replyButtonEl.setAttribute("aria-disabled", starting ? "true" : "false");
      replyButtonEl.textContent = starting ? "Starting…" : "Start task";
      safetyButtonEl.classList.remove("gone");
      safetyButtonEl.disabled = starting || reviewingSafety;
      safetyButtonEl.textContent = reviewingSafety ? "Reviewing…" : "Review Safety";
      taskRuntimeNoteEl.classList.remove("gone");
      replyNoteEl.classList.add("gone");
      return;
    }
    safetyButtonEl.classList.add("gone");
    safetyPanelEl.classList.add("gone");
    taskRuntimeNoteEl.classList.add("gone");
    replyNoteEl.classList.remove("gone");
    if (sessionMode && face !== "chat") {
      // On the session face the composer talks to the AGENT: words land in the
      // live turn without interrupting it. Replies to the sender live one
      // slide away, on the conversation face.
      const done = sessionSettled();
      replyInputEl.placeholder = done ? "Ask the agent for a follow-up…" : "Steer the current turn…";
      replyInputEl.disabled = sending;
      const ready = !sending && Boolean(replyInputEl.value.trim());
      replyButtonEl.disabled = !ready;
      replyButtonEl.setAttribute("aria-disabled", ready ? "false" : "true");
      replyButtonEl.textContent = sending ? "Sending…" : done ? "Send" : "Steer";
      return;
    }
    // A room opened from a contact card can always be written to: with nothing
    // to answer, the message is addressed to the person the window is for.
    const hasTarget = Boolean(replyTargetId()) || chatOnly;
    replyInputEl.placeholder = composerPlaceholder();
    replyInputEl.disabled = sending || !hasTarget;
    const ready = hasTarget && !sending && Boolean(replyInputEl.value.trim() || stagedReplyFiles.length);
    replyButtonEl.disabled = !ready;
    replyButtonEl.setAttribute("aria-disabled", ready ? "false" : "true");
    replyButtonEl.textContent = sending ? "Sending…" : "Send";
  }

  // --- The task face's ignition ----------------------------------------------

  function renderRuntimeWords() {
    runtimeWordsEl.textContent =
      runtime.host === "codex"
        ? "Codex"
        : `Claude Code · ${MODEL_LABELS[runtime.model] || runtime.model} · ${EFFORT_LABELS[runtime.effort] || runtime.effort}`;
    // Model and thinking are Claude choices; Codex keeps its own defaults.
    for (const el of runtimePopEl.querySelectorAll("[data-rt-claude]")) {
      el.classList.toggle("gone", runtime.host === "codex");
    }
  }

  function renderTaskMeta() {
    const isTaskFace = text(content.openFace) === "task";
    taskChipEl.classList.toggle("gone", !isTaskFace);
    if (!isTaskFace) {
      taskStateEl.classList.add("gone");
      return;
    }
    if (text(content.taskCompletedAt)) {
      taskStateEl.textContent = "Done";
      taskStateEl.classList.add("done");
      taskStateEl.classList.remove("gone");
    } else if (text(content.taskStartedAt)) {
      const clock = clockLabel(content.taskStartedAt);
      taskStateEl.textContent = clock ? `Started ${clock}` : "Started";
      taskStateEl.classList.remove("done");
      taskStateEl.classList.remove("gone");
    } else {
      taskStateEl.classList.add("gone");
    }
  }

  function renderSafetyReview(review) {
    safetyPanelEl.replaceChildren();
    const heading = document.createElement("strong");
    heading.textContent = review.concern === "high" ? "Look closely before accepting" : "Safety review";
    const summary = document.createElement("div");
    summary.textContent = text(review.summary);
    safetyPanelEl.append(heading, summary);
    const permissions = Array.isArray(review.permissions) ? review.permissions : [];
    if (permissions.length) {
      const chips = document.createElement("div");
      chips.className = "safety-permissions";
      for (const permission of permissions) {
        const chip = document.createElement("span");
        chip.className = "safety-permission";
        chip.textContent = text(permission.label);
        chip.title = text(permission.effect);
        chips.appendChild(chip);
      }
      safetyPanelEl.appendChild(chips);
      const effects = document.createElement("div");
      for (const permission of permissions) {
        const effect = document.createElement("div");
        effect.textContent = `${text(permission.label)}: ${text(permission.effect)}`;
        effects.appendChild(effect);
      }
      safetyPanelEl.appendChild(effects);
    }
    const plain = document.createElement("div");
    plain.textContent = text(review.plainLanguage);
    safetyPanelEl.appendChild(plain);
    if (text(review.trustContext)) {
      const trust = document.createElement("div");
      trust.textContent = text(review.trustContext);
      safetyPanelEl.appendChild(trust);
    }
    safetyPanelEl.classList.remove("gone");
  }

  async function reviewSafety() {
    if (!taskMode || reviewingSafety) return;
    const id = text(content.relayId);
    if (!id) return;
    reviewingSafety = true;
    refreshComposer();
    let result;
    try {
      result = await bridge.reviewSafety(id);
    } catch (error) {
      result = { ok: false, error: (error && error.message) || String(error) };
    }
    reviewingSafety = false;
    if (result?.ok && result.review) renderSafetyReview(result.review);
    else renderSafetyReview({
      concern: "review",
      summary: text(result?.error) || "The safety review could not be prepared.",
      permissions: [],
      plainLanguage: "Nothing was accepted or started. You can try the review again.",
    });
    refreshComposer();
  }

  async function submitStart() {
    if (starting) return;
    const note = replyInputEl.value.trim();
    const id = text(content.relayId);
    if (!id) return;
    starting = true;
    refreshComposer();
    let result;
    try {
      result = await bridge.startTask(id, note, runtime.host, runtime.model, runtime.effort);
    } catch (error) {
      result = { ok: false, error: (error && error.message) || String(error) };
    }
    starting = false;
    if (!result || !result.ok) {
      refreshComposer();
      taskRuntimeNoteEl.classList.add("gone");
      replyNoteEl.classList.remove("gone");
      setNote(text(result && result.error) || "The task could not be started.", true);
      return;
    }
    // Started: the composer settles into an ordinary reply box, the receipt
    // word appears, and the conversation face shows the "Started" reply the
    // sender just received.
    content.taskStartedAt = text(result.taskStartedAt) || new Date().toISOString();
    taskMode = false;
    replyInputEl.value = "";
    autosizeComposer();
    renderTaskMeta();
    // The window turns into the live session: the note appears as the one
    // bubble, the agent's work streams in beneath it. The conversation (with
    // the sender's "Started" copy) stays one slide away on the chip.
    setSessionMode(true);
    refreshComposer();
    if (result.running === false) {
      setNote("Started, but the run could not be launched automatically — open it in Claude to work on it.", true);
    } else {
      setNote("Started — your agent has it from here.");
    }
    loadChat({ keepScroll: false }).catch(() => {});
  }

  function setNote(message, isError) {
    replyNoteEl.textContent = text(message) || DEFAULT_NOTE;
    replyNoteEl.classList.toggle("error", Boolean(isError));
  }

  function updateChip() {
    if (!chat) {
      chatChipEl.classList.add("gone");
      return;
    }
    const count = (chat.messageCount || (chat.items || []).length || 0) + outgoing.length;
    chatChipNameEl.textContent = text(chat.title) || "Conversation";
    chatChipCountEl.textContent = count > 1 ? String(count) : "";
    chatChipEl.setAttribute(
      "aria-label",
      `Open the conversation with ${text(chat.title) || "these people"}`,
    );
    chatChipEl.classList.remove("gone");
  }

  function showFace(next, { force = false } = {}) {
    // There is no message behind a contact's chat, so there is no reading face
    // to fall back to — every path that would leave the conversation stays.
    if (chatOnly && next !== "chat") return;
    if (face === next && !force) return;
    face = next;
    const onChat = face === "chat";
    facesEl.classList.toggle("at-chat", onChat);
    headMessageEl.classList.toggle("gone", onChat);
    headChatEl.classList.toggle("gone", !onChat);
    faceMessageEl.setAttribute("aria-hidden", onChat ? "true" : "false");
    faceChatEl.setAttribute("aria-hidden", onChat ? "false" : "true");
    if (onChat) paintChat();
    refreshComposer();
  }

  /**
   * Show the conversation, and put the reader where they belong in it: on the
   * message they came in on the first time, at the live end after that.
   */
  function paintChat({ stick = true } = {}) {
    renderChat();
    if (!chat) return; // still loading — the load will place them
    if (landed) {
      if (stick) scrollChatToLatest();
      return;
    }
    landed = true;
    scrollChatToOpened();
  }

  function scrollChatToLatest() {
    requestAnimationFrame(() => {
      chatScrollEl.scrollTop = chatScrollEl.scrollHeight;
    });
  }

  const cssEscape = (value) =>
    typeof CSS !== "undefined" && CSS.escape ? CSS.escape(value) : String(value).replace(/["\\]/g, "\\$&");

  /**
   * Land on the message the window was opened from. Opening a text message
   * three days old should not drop the reader at the bottom of a conversation
   * that has moved on since — but the newest message belongs where a
   * conversation always sits, at the end.
   *
   * Centred by moving THIS scroller and nothing else. scrollIntoView is wrong
   * here: it scrolls every scrollable ancestor, and the faces track is one of
   * them (overflow:hidden still scrolls programmatically), so it slid the whole
   * conversation sideways out of the window while every DOM check still passed.
   */
  function scrollChatToOpened() {
    const id = text(content.relayId);
    const target = id ? chatListEl.querySelector(`.bubble-row[data-relay-id="${cssEscape(id)}"]`) : null;
    if (!target || !target.nextElementSibling) {
      scrollChatToLatest();
      return;
    }
    requestAnimationFrame(() => {
      if (!target.isConnected) return;
      const view = chatScrollEl.getBoundingClientRect();
      const row = target.getBoundingClientRect();
      chatScrollEl.scrollTop += row.top + row.height / 2 - (view.top + view.height / 2);
    });
  }

  function nearChatBottom() {
    return chatScrollEl.scrollHeight - chatScrollEl.scrollTop - chatScrollEl.clientHeight < 90;
  }

  /**
   * Load the conversation around the open message. Guarded by a revision so a
   * slow load for a message the reader has already navigated away from is
   * dropped rather than painted over the new one.
   */
  async function loadChat({ keepScroll = false } = {}) {
    if (!bridge.loadChat) return;
    const threadId = text(content.threadId);
    const revision = ++chatRevision;
    // A contact's window knows its room by id, which main holds; it has no
    // thread of its own to ask about, and asking is not how it resolves.
    if (!threadId && !chatOnly) {
      chat = null;
      updateChip();
      // Nothing to open into: never strand the reader on an empty conversation.
      if (face === "chat") showFace("message");
      return;
    }
    let result;
    try {
      result = await bridge.loadChat(threadId);
    } catch (error) {
      result = { ok: false, error: (error && error.message) || String(error) };
    }
    if (revision !== chatRevision) return;
    if (!result || !result.ok) {
      // A refresh that fails must not erase a conversation already on screen —
      // that is what a reload after a SUCCESSFUL send does, and blanking the
      // room the reply just went to reads as though the reply destroyed it.
      if (chat) {
        setNote(text(result && result.error) || "Could not refresh this conversation.", true);
        return;
      }
      chat = null;
      updateChip();
      const failure = text(result && result.error) || "Could not open this conversation.";
      // A contact's window has no message to fall back to, so the reason goes
      // where the conversation would have been. The composer stays live: the
      // history did not load, but this person can still be written to.
      if (chatOnly) {
        // Unless they have already written in it — then their own messages
        // stay on screen and the note carries the reason instead.
        if (outgoing.length) renderChat();
        else renderChatState(failure, true);
        setNote(failure, true);
        refreshComposer();
        return;
      }
      // A message that opened straight into its conversation has nowhere to sit
      // if that conversation will not load. Give the reader back the message
      // they clicked, with the reason on the composer's note line.
      if (face === "chat") {
        showFace("message");
        setNote(failure, true);
      }
      return;
    }
    chat = result.chat || null;
    // Only replies that never landed are still ours to show. Matching on the
    // returned relayId is not enough: a GROUP send answers with one sibling's
    // id, while the transcript collapses the fan-out to a different sibling, so
    // an id match never happened and the bubble showed twice forever. A reply
    // the server accepted is retired here by its own local id instead.
    outgoing = outgoing.filter((o) => o.state !== "sent");
    updateChip();
    if (face === "chat") paintChat({ stick: keepScroll ? nearChatBottom() : true });
    refreshComposer();
  }

  // --- The conversation face --------------------------------------------------

  /** A stable colour per person, hashed from their name so it never shuffles. */
  function authorColorClass(name) {
    const key = text(name).toLowerCase();
    let hash = 0;
    for (let i = 0; i < key.length; i += 1) hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
    return `p${hash % 8}`;
  }

  /**
   * Who the conversation is with, read off the message alone. Shown while the
   * chat loads so a window that opens straight into a conversation names it
   * from the first frame instead of saying "Conversation" and then changing.
   */
  function chatTitleHint(row) {
    return text(row && row.senderName)
      .replace(/^From\s+/i, "")
      .replace(/^You\s*→\s*/, "");
  }

  function renderChatState(message, isError) {
    chatListEl.replaceChildren();
    chatStateEl.textContent = text(message) || "No messages here yet.";
    chatStateEl.classList.toggle("error", Boolean(isError));
    chatStateEl.classList.remove("gone");
  }

  /**
   * Who is in the room. Only worth saying in a group: in a one-to-one the
   * heading already names the one other person, and repeating it under itself
   * says nothing.
   */
  function peopleLine() {
    if (!chat || chat.kind !== "group") return "";
    const others = ((chat && chat.participants) || [])
      .filter((p) => !p.self)
      .map((p) => text(p.name))
      .filter(Boolean);
    return others.length ? `You, ${others.join(", ")}` : "";
  }

  /**
   * One bubble. The message body is already in hand, so opening a long one
   * costs nothing — no round trip, no second window.
   */
  function buildBubble(entry, { showAuthor, lastOfRun, firstOfRun, receipt }) {
    const row = document.createElement("div");
    row.className = `bubble-row ${entry.mine ? "mine" : "theirs"}`;
    // Named so the conversation can be scrolled to one particular message.
    if (entry.relayId) row.dataset.relayId = entry.relayId;
    if (firstOfRun) row.classList.add("first-of-run");
    if (lastOfRun) row.classList.add("last-of-run");
    if (entry.unread) row.classList.add("unread");
    if (entry.state === "pending") row.classList.add("pending");
    if (entry.state === "failed") row.classList.add("failed");
    if (entry.inReplyToRelayId) row.classList.add("thread-reply");

    if (showAuthor) {
      const author = document.createElement("p");
      author.className = `bubble-author ${authorColorClass(entry.author)}`;
      author.textContent = entry.author;
      row.appendChild(author);
    }

    const bubble = document.createElement("button");
    bubble.className = "bubble";
    bubble.type = "button";

    // Only a two-document Relay carries a visible subject. Human-only rows are
    // text messages; their wire title exists for list/email compatibility and
    // must not repeat or truncate the message inside its bubble.
    const body = String(entry.body || "");
    const subject = text(entry.subject);
    if (subject && entry.hasAgentDocument) {
      const subjectEl = document.createElement("strong");
      subjectEl.className = "bubble-subject";
      subjectEl.style.display = "block";
      subjectEl.style.marginBottom = "5px";
      subjectEl.textContent = subject;
      bubble.appendChild(subjectEl);
    }

    const bodyWrap = document.createElement("div");
    bodyWrap.className = "bubble-body";
    const files = entry.attachments || [];
    try {
      const html = body ? bridge.renderMarkdown(body) : "";
      if (html) {
        // renderMarkdown runs in the isolated preload and returns allowlisted,
        // DOMPurify-sanitized HTML. No other value is assigned through innerHTML.
        const article = document.createElement("div");
        article.className = "markdown";
        article.innerHTML = html;
        bodyWrap.appendChild(article);
      } else {
        bodyWrap.textContent = text(entry.preview) || (files.length ? "" : "(no message body)");
      }
    } catch {
      bodyWrap.textContent = text(entry.preview) || "(this message could not be rendered)";
    }
    if (body || bodyWrap.textContent || !files.length) bubble.appendChild(bodyWrap);

    if (files.length) {
      const list = document.createElement("div");
      list.className = "bubble-files";
      for (const file of files) {
        const chip = document.createElement("button");
        chip.className = "bubble-file";
        chip.type = "button";
        const label = document.createElement("span");
        label.textContent = text(file.name) || "Attachment";
        chip.appendChild(label);
        chip.addEventListener("click", (event) => {
          event.stopPropagation();
          if (text(file.openUrl)) bridge.openExternal(file.openUrl);
        });
        list.appendChild(chip);
      }
      bubble.appendChild(list);
    }

    row.appendChild(bubble);

    if (entry.origin === "slack" || entry.provider?.name === "slack") {
      const provider = document.createElement("div");
      provider.className = "bubble-provider";
      const mark = document.createElement("img");
      mark.src = "slackMark.png";
      mark.alt = "";
      const label = document.createElement("span");
      label.textContent = "From Slack";
      provider.append(mark, label);
      row.appendChild(provider);
    }

    const stamp = document.createElement("div");
    stamp.className = "bubble-time";
    if (entry.state === "pending") stamp.textContent = "Sending…";
    else if (entry.state === "failed") stamp.textContent = `Not sent — ${entry.error || "try again"}`;
    else stamp.textContent = clockLabel(entry.at);
    row.appendChild(stamp);

    if (receipt) {
      const receiptLabel = document.createElement(receipt.expandable ? "button" : "div");
      receiptLabel.className = "bubble-receipt";
      receiptLabel.textContent = receipt.label;
      if (receipt.expandable) {
        receiptLabel.type = "button";
        receiptLabel.setAttribute("aria-expanded", expandedReceipts.has(entry.relayId) ? "true" : "false");
        receiptLabel.addEventListener("click", (event) => {
          event.stopPropagation();
          if (expandedReceipts.has(entry.relayId)) expandedReceipts.delete(entry.relayId);
          else expandedReceipts.add(entry.relayId);
          renderChat();
        });
      }
      row.appendChild(receiptLabel);
      if (receipt.expandable && expandedReceipts.has(entry.relayId)) {
        const panel = document.createElement("div");
        panel.className = "receipt-panel";
        for (const member of [...receipt.readers, ...receipt.unread]) {
          const detail = document.createElement("div");
          detail.className = "receipt-row";
          const name = document.createElement("span");
          name.textContent = member.name;
          const when = document.createElement("span");
          when.className = "receipt-when";
          when.textContent = member.seen ? receiptTimestamp(member.readAt) : "Not seen";
          detail.append(name, when);
          panel.appendChild(detail);
        }
        row.appendChild(panel);
      }
    }

    bubble.addEventListener("click", () => {
      if (entry.state === "failed") {
        retryOutgoing(entry.localId);
        return;
      }
      if (!entry.relayId) return;
      if (expanded.has(entry.relayId)) expanded.delete(entry.relayId);
      else expanded.add(entry.relayId);
      renderChat();
    });

    // Clamp only what actually overflows, and only when it is not already open.
    if (!entry.relayId || !expanded.has(entry.relayId)) {
      requestAnimationFrame(() => {
        if (!bodyWrap.isConnected) return;
        if (bodyWrap.scrollHeight <= CLAMP_HEIGHT) return;
        bubble.classList.add("clamped");
        if (bubble.querySelector(".bubble-more")) return;
        const more = document.createElement("span");
        more.className = "bubble-more";
        more.textContent = "Read the whole message";
        bubble.appendChild(more);
      });
    } else {
      const less = document.createElement("span");
      less.className = "bubble-more";
      less.textContent = "Show less";
      bubble.appendChild(less);
    }

    return row;
  }

  /** The chat's messages plus anything still on its way, oldest first. */
  function chatEntries() {
    const items = (chat && chat.items) || [];
    const isGroup = Boolean(chat && chat.kind === "group");
    const entries = items.map((item) => ({
      relayId: text(item.relayId),
      localId: "",
      mine: item.direction === "outbound",
      author: item.direction === "outbound" ? "You" : text(item.sender && item.sender.name) || "Relay",
      subject: text(item.title),
      body: String(item.forHuman || ""),
      hasAgentDocument: Boolean(text(item.forAgent)),
      preview: text(item.preview),
      at: text(item.createdAt),
      unread: item.direction === "inbound" && item.state === "delivered",
      attachments: item.attachments || [],
      state: "sent",
      isGroup,
      readReceipts: Array.isArray(item.readReceipts) ? item.readReceipts : [],
      origin: text(item.origin),
      provider: item.provider || null,
      inReplyToRelayId: text(item.inReplyToRelayId),
      threadId: text(item.threadId),
      editedAt: text(item.editedAt),
      deletedAt: text(item.deletedAt),
    }));
    for (const out of outgoing) {
      entries.push({
        relayId: "",
        localId: out.localId,
        mine: true,
        author: "You",
        subject: "",
        body: out.body,
        preview: out.body,
        at: out.at,
        unread: false,
        attachments: out.attachments || [],
        state: out.state,
        error: out.error,
        isGroup,
        readReceipts: [],
      });
    }
    return entries;
  }

  function renderChat() {
    // A room whose history has not arrived can still hold what the person has
    // just written into it. Blanking their own message to say "loading" reads
    // as though sending it lost it.
    if (!chat && !(chatOnly && outgoing.length)) {
      renderChatState("Loading the conversation…", false);
      return;
    }
    // A window opened from a contact card is headed by the name that card
    // carries — what THIS person calls them — over the room's own title.
    chatNameEl.textContent =
      (chatOnly && text(content.chatTitle)) || text(chat && chat.title) || "Conversation";
    chatPeopleEl.textContent = peopleLine();

    const entries = chatEntries();
    if (!entries.length) {
      // Empty because nobody has said anything yet, rather than empty because
      // something failed: the composer below is the answer, so point at it.
      renderChatState(
        chatOnly ? "No messages yet. Write the first one below." : "No messages in this conversation yet.",
        false,
      );
      return;
    }
    chatStateEl.classList.add("gone");

    const fragment = document.createDocumentFragment();
    const visibleIds = new Set(entries.map((entry) => entry.relayId).filter(Boolean));
    const shownMissingRoots = new Set();
    let previousDay = "";
    for (let i = 0; i < entries.length; i += 1) {
      const entry = entries[i];
      const previous = entries[i - 1];
      const next = entries[i + 1];

      const day = dayKey(entry.at);
      if (day && day !== previousDay) {
        const divider = document.createElement("div");
        divider.className = "day-divider";
        divider.textContent = dayLabel(entry.at);
        fragment.appendChild(divider);
        previousDay = day;
      }

      if (
        entry.provider?.name === "slack"
        && entry.inReplyToRelayId
        && !visibleIds.has(entry.inReplyToRelayId)
        && !shownMissingRoots.has(entry.threadId || entry.inReplyToRelayId)
      ) {
        shownMissingRoots.add(entry.threadId || entry.inReplyToRelayId);
        const stub = document.createElement("div");
        stub.className = "thread-stub";
        const mark = document.createElement("img");
        mark.src = "slackMark.png";
        mark.alt = "";
        const label = document.createElement("span");
        label.textContent = "Earlier Slack thread";
        stub.append(mark, label);
        fragment.appendChild(stub);
      }

      // A run is the same person speaking without a long pause: it gets one
      // name, one timestamp, and one tail.
      const sameRun = (a, b) => {
        if (!a || !b) return false;
        if (a.mine !== b.mine || a.author !== b.author) return false;
        const gap = Math.abs((parseDate(b.at) || 0) - (parseDate(a.at) || 0));
        return Number.isFinite(gap) && gap < RUN_GAP_MS;
      };
      const firstOfRun = !sameRun(previous, entry) || dayKey(previous && previous.at) !== day;
      const lastOfRun = !sameRun(entry, next) || dayKey(next && next.at) !== day;

      fragment.appendChild(
        buildBubble(entry, {
          // Names only in a room with more than two people: labelling every
          // line of a one-to-one is noise.
          showAuthor: entry.isGroup && !entry.mine && firstOfRun,
          firstOfRun,
          lastOfRun,
          receipt: RelayReadReceipts.forLatest(entry, entries, clockLabel),
        }),
      );
    }
    chatListEl.replaceChildren(fragment);
    const renderedUnreadIds = entries
      .filter((entry) => entry.unread && entry.relayId)
      .map((entry) => entry.relayId);
    if (renderedUnreadIds.length && bridge.renderedChat) bridge.renderedChat(renderedUnreadIds);
  }

  // --- Sending ---------------------------------------------------------------

  function retryOutgoing(localId) {
    const entry = outgoing.find((o) => o.localId === localId);
    if (!entry || sending) return;
    outgoing = outgoing.filter((o) => o.localId !== localId);
    renderChat();
    // Same key as the first attempt: if that one actually landed, the server
    // returns the original relay instead of sending a second copy.
    deliver(entry.body, entry.inReplyToRelayId, entry.idempotencyKey, entry.files, entry.attachments);
  }

  /** A key identifying this MESSAGE, stable across every attempt to send it. */
  function newIdempotencyKey() {
    const random = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Math.random()}`;
    return `preview-reply-${random}`;
  }

  async function deliver(body, targetId, idempotencyKey, files = [], attachments = []) {
    const localId = `local-${Date.now()}-${outgoing.length}`;
    const entry = {
      localId,
      relayId: "",
      body,
      at: new Date().toISOString(),
      state: "pending",
      inReplyToRelayId: targetId,
      idempotencyKey: idempotencyKey || newIdempotencyKey(),
      files,
      attachments,
      error: "",
    };
    outgoing.push(entry);
    sending = true;
    refreshComposer();
    setNote("");
    if (face === "chat") {
      renderChat();
      scrollChatToLatest();
    }

    let result;
    try {
      result = await bridge.sendReply(targetId, body, entry.idempotencyKey, entry.files);
    } catch (error) {
      result = { ok: false, error: (error && error.message) || String(error) };
    }
    sending = false;

    if (result && result.ok) {
      entry.state = "sent";
      entry.relayId = text(result.relayId);
      setNote("");
      // The bubble is already on screen and the server now has the message, so
      // settle it immediately. Re-reading the conversation is reconciliation —
      // it puts the reply in server order alongside everyone else's — and
      // awaiting it just to show what is already correct spent another round
      // trip (~1s) with the composer still saying "Sending…".
      if (face === "chat") renderChat();
      loadChat({ keepScroll: true }).catch(() => {});
    } else {
      entry.state = "failed";
      entry.error = text(result && result.error) || "the reply did not send";
      setNote(`Not sent: ${entry.error}`, true);
      if (face === "chat") {
        renderChat();
      } else {
        // On the reading face there are no bubbles, so a failed reply would
        // vanish with the text the person wrote. Give it back to them.
        outgoing = outgoing.filter((o) => o.localId !== entry.localId);
        if (!replyInputEl.value.trim()) {
          replyInputEl.value = body;
          autosizeComposer();
        }
      }
    }
    refreshComposer();
  }

  async function submitReply() {
    if (taskMode) {
      await submitStart();
      return;
    }
    // The session face always talks to the agent. Once a turn settles, Send
    // resumes that same native session; human correspondence stays on Chat.
    if (sessionMode && face !== "chat") {
      await submitSteer();
      return;
    }
    if (sending) return;
    const body = replyInputEl.value.trim();
    const targetId = replyTargetId();
    // With no target and no contact's room, there is nothing this could be
    // addressed to; in a contact's room, main addresses it to that person.
    if ((!body && !stagedReplyFiles.length) || (!targetId && !chatOnly)) return;

    let payload;
    try {
      payload = await replyFilePayloads(stagedReplyFiles);
    } catch (error) {
      setNote(`Could not attach that file: ${(error && error.message) || String(error)}`, true);
      return;
    }

    replyInputEl.value = "";
    stagedReplyFiles = [];
    paintReplyFiles();
    autosizeComposer();
    // Sending from the message face slides you into the conversation, so the
    // reply lands somewhere you can see it — and the room it went to is now
    // the thing on screen.
    if (face !== "chat" && chat) showFace("chat");
    await deliver(body, targetId, newIdempotencyKey(), payload.files, payload.attachments);
    // Sending should not cost the person their place in the composer.
    if (!replyInputEl.disabled) replyInputEl.focus();
  }

  // --- The session face -------------------------------------------------------
  // A started task's transcript, rendered as a calm document: the human's
  // words in the one bubble, the agent's prose flat, and every action folded
  // to a dimmed sentence. Evidence appears only when asked for.

  const TOOL_ICONS = {
    command:
      '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><path d="M2.5 4.5 6 8l-3.5 3.5M8.5 11.5h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
    file:
      '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><path d="M9.5 1.5h-5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V4.5l-3-3Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="M9.5 1.5v3h3" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>',
    search:
      '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.4"/><path d="m10.5 10.5 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>',
    web:
      '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3"/><path d="M2.2 8h11.6M8 2c1.8 1.7 2.6 3.7 2.6 6S9.8 12.3 8 14C6.2 12.3 5.4 10.3 5.4 8S6.2 3.7 8 2Z" stroke="currentColor" stroke-width="1.1"/></svg>',
    agent:
      '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><circle cx="4.5" cy="4" r="2" stroke="currentColor" stroke-width="1.3"/><circle cx="11.5" cy="12" r="2" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 6v2.5a2 2 0 0 0 2 2h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>',
  };
  const ACT_CHEVRON =
    '<svg class="act-chev" viewBox="0 0 10 10" fill="none" aria-hidden="true"><path d="M3 1.5 7 5 3 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';

  function sessionEntries(run) {
    // reasoning_summary/reasoningSummary and commentary remain collapsible;
    // only phase === "final_answer" arrives with placement:"final".
    return RelayWorkUI.normalizeConversationView(run?.presentation || run?.workPresentation || { turns:[] });
  }

  function reconcileSessionChildren(parent, items, update, animateFresh = false) {
    const existing = new Map([...parent.children].map((node) => [node.dataset.sessionSegment, node]));
    let cursor = parent.firstElementChild;
    for (const item of items) {
      let node = existing.get(item.key);
      const fresh = !node;
      if (!node) { node = document.createElement("div"); node.dataset.sessionSegment = item.key; }
      else existing.delete(item.key);
      update(node, item);
      if (fresh && animateFresh) node.dataset.sessionAnimate = "1";
      if (node.dataset.sessionAnimate === "1") node.classList.add("session-fade");
      if (node !== cursor) parent.insertBefore(node, cursor);
      cursor = node.nextElementSibling;
    }
    for (const node of existing.values()) {
      node._workDisclosure?.destroy?.();
      node._semanticDisclosure?.destroy?.();
      node._nativeShimmerCleanup?.();
      if (node._workSummaryKey) sessionSummaryStabilizer.clear(node._workSummaryKey);
      node.remove();
    }
  }

  function updateSessionMarkdown(node, unit, className) {
    node.className = className;
    const source = text(unit?.text);
    if (node.dataset.sessionText === source) return;
    let html = "";
    try { html = bridge.renderMarkdown(source); } catch { html = ""; }
    node.innerHTML = html ? `<div class="markdown" data-markdown-animated="true">${html}</div>` : "";
    if (!html) node.textContent = source;
    node.dataset.sessionText = source;
  }
  function safeSessionImageUrl(value) {
    const url = text(value);
    return /^(?:blob:|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url) ? url : "";
  }
  function hydrateSessionAttachment(file, attachment, context, attachmentIndex) {
    if (!bridge.runFeedAttachment || file.dataset.loading === "1") return;
    file.dataset.loading = "1";
    bridge.runFeedAttachment({ ...context, attachmentId:text(attachment?.id), attachmentIndex }).then((result) => {
      if (!file.isConnected) return;
      const mimeType = /^image\/(?:png|jpeg|gif|webp)$/i.test(text(result?.mimeType)) ? text(result.mimeType) : "";
      const encoded = mimeType && /^[A-Za-z0-9+/=]+$/.test(text(result?.dataBase64)) ? `data:${mimeType};base64,${result.dataBase64}` : "";
      const src = safeSessionImageUrl(result?.dataUrl || encoded);
      if (!result?.ok || !src) { file.dataset.loading = ""; return; }
      file.className = "session-user-image-wrap";
      const frame = document.createElement("span"); frame.className = "session-user-image-frame";
      const caption = document.createElement("span"); caption.className = "session-user-image-cap"; caption.textContent = attachment.name || "Image";
      const img = document.createElement("img"); img.className = "session-user-image"; img.alt = attachment.name || "Image";
      img.addEventListener("load", () => file.classList.add("ready"), { once:true });
      img.src = src; frame.append(img); file.replaceChildren(frame, caption); file.dataset.src = src;
    }).catch(() => { if (file.isConnected) file.dataset.loading = ""; });
  }

  function canonicalActivityNode(node, unit) {
    const activity = unit?.activity || {};
    const running = activity.status === "inProgress";
    const verb = running ? activity.activeVerb : activity.doneVerb;
    const label = `${verb || (running ? "Working" : "Worked")}${activity.object ? ` ${activity.object}` : ""}`;
    node.className = `act${running ? " live" : ""}`;
    node._sessionActivityUnit = unit;
    if (node.dataset.sessionText === label) return;
    node.innerHTML = `<button type="button" class="act-head" aria-expanded="${node._sessionDetailOpen ? "true" : "false"}"><span class="act-ic">${TOOL_ICONS[activity.kind === "edit" ? "file" : activity.kind === "web" ? "web" : activity.kind === "subagent" ? "agent" : activity.kind === "command" ? "command" : "search"] || TOOL_ICONS.command}</span><span class="act-sum">${escapeHtml(label)}</span></button>`;
    if (!node._sessionDetailBound && bridge.runFeedDetail) {
      node.addEventListener("click", async () => {
        if (node._sessionDetailOpen) {
          node._sessionDetailOpen = false; node.querySelector(".act-head")?.setAttribute("aria-expanded", "false");
          node._sessionDetailHost?.remove(); node._sessionDetailHost = null; return;
        }
        const generation = (node._sessionDetailGeneration || 0) + 1;
        node._sessionDetailGeneration = generation;
        const result = await bridge.runFeedDetail({ relayId:text(content.relayId), sessionId:sessionCurrentSessionId,
          turnId:node.closest(".session-turn")?.dataset.workAnchor || "", itemId:node._sessionActivityUnit?.id || "" }).catch(() => null);
        if (!node.isConnected || node._sessionDetailGeneration !== generation || !result?.ok) return;
        const host = document.createElement("pre"); host.className = "session-unit-detail";
        host.textContent = typeof result.detail === "string" ? result.detail : JSON.stringify(result.detail || {}, null, 2);
        node.append(host); node._sessionDetailHost = host; node._sessionDetailOpen = true;
        node.querySelector(".act-head")?.setAttribute("aria-expanded", "true");
      });
      node._sessionDetailBound = true;
    }
    node.dataset.sessionText = label;
  }

  function nativeSessionShimmerMarkup(label) {
    const value = escapeHtml(label);
    return `<span class="native-cadenced-shimmer" data-native-cadenced-shimmer>${value}<span aria-hidden="true" class="native-cadenced-shimmer-sweep"><span class="native-cadenced-shimmer-highlight">${value}</span></span></span>`;
  }

  function bindSessionNativeShimmer(node, active) {
    node._nativeShimmerCleanup?.();
    node._nativeShimmerCleanup = null;
    const shimmer = node.querySelector?.("[data-native-cadenced-shimmer]");
    if (!active || !shimmer || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const timers = new Set();
    const later = (fn, delay) => { const timer = setTimeout(() => { timers.delete(timer); fn(); }, delay); timers.add(timer); };
    const sweep = () => { shimmer.classList.remove("active"); void shimmer.offsetWidth; shimmer.classList.add("active"); later(() => shimmer.classList.remove("active"), 1000); };
    let cadenceTimer = 0;
    const cadence = () => { sweep(); cadenceTimer = setTimeout(cadence, 4000); };
    later(cadence, 600);
    node._nativeShimmerCleanup = () => { for (const timer of timers) clearTimeout(timer); clearTimeout(cadenceTimer); shimmer.classList.remove("active"); };
  }

  function updateSessionSemanticGroup(node, unit) {
    const active = unit.active === true || (unit.items || []).some((entry) => entry?.activity?.status === "inProgress");
    const label = unit.activeSummary || unit.summary || (active ? "Working" : "Worked");
    node.className = "session-activity session-semantic-group";
    if (!node._sessionInner) {
      const toggle = document.createElement("button");
      toggle.type = "button"; toggle.className = "session-activity-toggle";
      toggle.setAttribute("data-work-disclosure-toggle", "");
      const body = document.createElement("div"); body.className = "session-activity-body";
      body.setAttribute("data-work-disclosure-body", "");
      const inner = document.createElement("div"); body.append(inner);
      node.append(toggle, body); node._sessionToggle = toggle; node._sessionInner = inner;
      node._semanticDisclosure = RelayWorkUI.createDisclosureController(node, { initiallyExpanded:false });
    }
    if (node.dataset.semanticLabel !== `${active}:${label}`) {
      node._nativeShimmerCleanup?.();
      node._sessionToggle.innerHTML = `<span role="status" aria-live="polite">${active ? nativeSessionShimmerMarkup(label) : escapeHtml(label)}</span>${ACT_CHEVRON}`;
      node.dataset.semanticLabel = `${active}:${label}`;
      bindSessionNativeShimmer(node, active);
    }
    node._sessionToggle.setAttribute("aria-label", `${label}. ${(unit.items || []).length} activities.`);
    reconcileSessionChildren(node._sessionInner, (unit.items || []).map((entry, index) => ({ ...entry, key:String(entry.id || index) })), updateSessionUnit, true);
  }

  function updateSessionUnit(node, unit) {
    if (unit.type === "activity") return canonicalActivityNode(node, unit);
    if (unit.type === "exploration" || unit.type === "activityGroup") {
      return updateSessionSemanticGroup(node, unit);
    }
    if (unit.type === "request") {
      node.className = "session-request"; node.setAttribute("role", "status"); node.setAttribute("aria-live", "polite");
      node.textContent = unit.text || "Input required"; return;
    }
    if (unit.type === "error") {
      node.className = "session-error"; node.setAttribute("role", "alert");
      node.textContent = unit.text || "The run hit an error"; return;
    }
    if (unit.type === "retry") {
      node.className = "turn-marker"; node.setAttribute("role", "status"); node.setAttribute("aria-live", "polite");
      node.textContent = unit.text || "Reconnecting"; return;
    }
    updateSessionMarkdown(node, unit, "agent-prose");
  }

  function updateSessionDisclosure(node, item) {
    node.className = "session-activity";
    const sessionActivityOpenForTurn = `${item.turnId}:${item.segment}`;
    node._workSummaryKey = sessionActivityOpenForTurn;
    if (!node._sessionInner) {
      const toggle = document.createElement("button");
      toggle.type = "button"; toggle.className = "session-activity-toggle";
      toggle.setAttribute("data-work-disclosure-toggle", "");
      toggle.innerHTML = `<span data-work-summary></span>${ACT_CHEVRON}`;
      const body = document.createElement("div"); body.className = "session-activity-body";
      body.setAttribute("data-work-disclosure-body", "");
      const inner = document.createElement("div"); body.append(inner);
      node.append(toggle, body); node._sessionInner = inner;
      node.dataset.workDisclosure = sessionActivityOpenForTurn;
      node._workDisclosure = RelayWorkUI.createDisclosureController(node, {
        initiallyExpanded:actOpen.has(sessionActivityOpenForTurn),
        onStateChange:(state) => {
          if (state === "opening" || state === "expanded") actOpen.add(sessionActivityOpenForTurn);
          else actOpen.delete(sessionActivityOpenForTurn);
        },
      });
    }
    const summary = node.querySelector("[data-work-summary]");
    sessionSummaryStabilizer.offer(sessionActivityOpenForTurn, item.label, (value) => { summary.textContent = value; });
    node.querySelector("[data-work-disclosure-toggle]").setAttribute("aria-label", `${item.label}. ${item.units.length} previous messages.`);
    reconcileSessionChildren(node._sessionInner, item.units.map((unit) => ({ ...unit, key:String(unit.id) })), updateSessionUnit, true);
  }

  function renderSession(records, run = {}) {
    const presentation = sessionEntries(run);
    const snapshot = sessionScrollController?.capture();
    const turnUnits = presentation.turns.map((turn) => {
      return { key:turn.turnId, turnId:turn.turnId, status:turn.status, blocks:RelayWorkUI.partitionTurn(turn) };
    });
    reconcileSessionChildren(sessionListEl, turnUnits, (turnNode, turn) => {
      turnNode.className = "session-turn"; turnNode.dataset.workAnchor = turn.turnId;
      reconcileSessionChildren(turnNode, turn.blocks, (node, block) => {
        if (block.type === "activity") return updateSessionDisclosure(node, block);
        if (block.type === "user") {
          node.className = "session-user-wrap";
          node._sessionUserText = block.unit.text || "";
          if (!node._sessionUser) {
            const files = document.createElement("div"); files.className = "session-user-files";
            const bubble = document.createElement("div"); bubble.className = "user-msg"; bubble.tabIndex = 0;
            bubble.setAttribute("role", "group"); bubble.setAttribute("aria-label", "Your message");
            const actions = document.createElement("div"); actions.className = "session-user-actions";
            actions.innerHTML = `<button type="button" aria-label="Copy message">Copy</button><button type="button" aria-label="Edit message">Edit</button>`;
            actions.addEventListener("click", (event) => {
              const action = event.target.closest("button[aria-label]");
              if (!action) return;
              if (action.getAttribute("aria-label") === "Copy message") navigator.clipboard?.writeText(node._sessionUserText || "");
              if (action.getAttribute("aria-label") === "Edit message") {
                replyInputEl.value = node._sessionUserText || ""; autosizeComposer(); refreshComposer(); replyInputEl.focus();
              }
            });
            bubble.addEventListener("dblclick", () => actions.querySelector('[aria-label="Edit message"]')?.click());
            node.append(files, bubble, actions); node._sessionFiles = files; node._sessionUser = bubble;
          }
          reconcileSessionChildren(node._sessionFiles, (block.unit.attachments || []).map((attachment, index) => ({ ...attachment, attachmentIndex:index, key:String(attachment.id || attachment.name || index) })), (file, attachment) => {
            const image = attachment.image || attachment.kind === "image" || /^image\//i.test(text(attachment.mimeType));
            const previewUrl = safeSessionImageUrl(attachment.previewUrl || attachment.url);
            if (image && previewUrl) {
              file.className = "session-user-image-wrap ready";
              if (file.dataset.src !== previewUrl) {
                file.innerHTML = `<span class="session-user-image-frame"><img class="session-user-image" src="${escapeHtml(previewUrl)}" alt="${escapeHtml(attachment.name || "Image")}"></span><span class="session-user-image-cap">${escapeHtml(attachment.name || "Image")}</span>`;
                file.dataset.src = previewUrl;
              }
            } else if (image) {
              file.className = "session-user-image-wrap";
              if (!file.querySelector(".session-user-image-frame")) file.innerHTML = `<span class="session-user-image-frame"></span><span class="session-user-image-cap">${escapeHtml(attachment.name || "Image")}</span>`;
              hydrateSessionAttachment(file, attachment, { relayId:text(content.relayId), sessionId:sessionCurrentSessionId, turnId:turn.turnId || turn.key, itemId:block.unit.id }, attachment.attachmentIndex);
            } else {
              file.className = "session-user-file"; file.textContent = attachment.name || "Attachment";
            }
          });
          node._sessionFiles.classList.toggle("one", (block.unit.attachments || []).filter((attachment) => attachment.image || attachment.kind === "image" || /^image\//i.test(text(attachment.mimeType))).length === 1);
          updateSessionMarkdown(node._sessionUser, block.unit, "user-msg");
          node._sessionUser.hidden = !text(block.unit.text).trim();
          return;
        }
        if (block.type === "divider") { node.className = "session-divider"; node.replaceChildren(); return; }
        if (block.type === "final") { updateSessionMarkdown(node, block.unit, "session-final"); return; }
        updateSessionUnit(node, block.unit);
      }, true);
    });
    sessionStateEl.classList.toggle("gone", turnUnits.length > 0);
    if (!turnUnits.length) sessionStateEl.textContent = "No native run output was captured.";
    requestAnimationFrame(() => sessionScrollController?.contentChanged(snapshot));
  }

  function acceptSessionResult(result) {
    if (!sessionMode) return;
    if (!result || (result.ok === false && !result.presentation)) {
      sessionLive = { provider: "", liveState: "", error: text(result && result.error) };
      // A live-stream failure must remain visible even when a previously
      // hydrated transcript is still readable below it.
      sessionStateEl.textContent = sessionLive.error || "The session is not readable yet.";
      sessionStateEl.classList.remove("gone");
      return;
    }
    const revision = Number(result.revision) || 0;
    // Once a native revisioned feed owns this surface, an older compatibility
    // response must never erase it. Legacy session reads have no revision.
    if (!revision && sessionRevision > 0) return;
    if (revision && revision <= sessionRevision) return;
    if (revision) sessionRevision = revision;
    sessionCurrentSessionId = text(result.sessionId || result.presentation?.sessionId);
    const latestTurn = result.presentation?.turns?.at(-1) || null;
    const nativeState = latestTurn?.active ? "active" : latestTurn?.status || result.liveState;
    sessionLive = { provider:result.provider || result.presentation?.provider || "", liveState:text(nativeState), error:"" };
    // Receipts ride the poll — the Done flip lands here, not on a reopen.
    if (text(result.taskStartedAt)) content.taskStartedAt = text(result.taskStartedAt);
    if (text(result.taskCompletedAt) && !text(content.taskCompletedAt)) {
      content.taskCompletedAt = text(result.taskCompletedAt);
      renderTaskMeta();
    }
    const fingerprint = `${text(result.relayId)}|${text(result.sessionId)}|${Number(result.revision) || 0}`;
    if (fingerprint === sessionFingerprint && sessionListEl.childElementCount) return;
    sessionFingerprint = fingerprint;
    renderSession(result.records || [], result);
    refreshComposer();
  }

  async function pollSession() {
    if (!sessionMode) return;
    let result;
    try { result = await bridge.loadSession(text(content.relayId)); }
    catch (error) { result = { ok:false, error:(error && error.message) || String(error) }; }
    acceptSessionResult(result);
  }

  const disposeNativeRunUpdates = bridge.onRunFeedUpdate?.((envelope) => {
    const id = text(envelope?.relayId);
    if (sessionMode && id === text(content.relayId)) acceptSessionResult(envelope);
  });
  const disposeSessionUpdates = bridge.onSessionUpdate?.((id, result) => {
    if (sessionMode && id === text(content.relayId)) acceptSessionResult(result);
  });

  function setSessionMode(next) {
    const watchEpoch = ++sessionWatchEpoch;
    sessionMode = Boolean(next);
    detailScrollEl.classList.toggle("gone", sessionMode);
    sessionScrollEl.classList.toggle("gone", !sessionMode);
    if (sessionWatching && sessionWatchedRelayId) {
      if (bridge.unwatchRunFeed) bridge.unwatchRunFeed(sessionWatchedRelayId);
      else bridge.unwatchSession?.(sessionWatchedRelayId);
    }
    sessionWatching = false;
    sessionWatchedRelayId = "";
    sessionScrollController?.destroy();
    sessionScrollController = sessionMode ? RelayWorkUI.createScrollController(sessionScrollEl, sessionFooterEl, {
      bottomTolerance:24,
      preservePositionForNextLayout:true,
      onFollowChange:(following) => sessionScrollEl.toggleAttribute("data-work-detached", !following),
    }) : null;
    if (sessionMode) {
      const watchedRelayId = text(content.relayId);
      sessionFingerprint = "";
      sessionRevision = 0;
      actOpen.clear();
      sessionStateEl.textContent = "Reading the session…";
      sessionStateEl.classList.remove("gone");
      const subscribeNativeRun = bridge.watchRunFeed || bridge.watchSession;
      // A watch invocation is both hydration and subscription. Running the
      // legacy history read beside it creates two owners and permits a slow,
      // revisionless response to overwrite a newer native snapshot.
      if (!subscribeNativeRun) pollSession().catch(() => {});
      Promise.resolve(subscribeNativeRun?.(watchedRelayId)).then((result) => {
        if (watchEpoch !== sessionWatchEpoch || !sessionMode || watchedRelayId !== text(content.relayId)) {
          if (bridge.unwatchRunFeed) bridge.unwatchRunFeed(watchedRelayId); else bridge.unwatchSession?.(watchedRelayId);
          return;
        }
        if (result?.ok === false || !result?.presentation) {
          if (bridge.unwatchRunFeed) bridge.unwatchRunFeed(watchedRelayId); else bridge.unwatchSession?.(watchedRelayId);
          acceptSessionResult({ ok:false, error:result?.error || "The live event stream is unavailable." });
          return;
        }
        acceptSessionResult(result);
        sessionWatching = Boolean(subscribeNativeRun);
        sessionWatchedRelayId = sessionWatching ? watchedRelayId : "";
      }).catch((error) => {
        if (watchEpoch !== sessionWatchEpoch || !sessionMode || watchedRelayId !== text(content.relayId)) return;
        acceptSessionResult({ ok:false, error:(error && error.message) || String(error) });
      });
    }
  }

  window.addEventListener("beforeunload", () => {
    if (sessionWatching && sessionWatchedRelayId) {
      if (bridge.unwatchRunFeed) bridge.unwatchRunFeed(sessionWatchedRelayId); else bridge.unwatchSession?.(sessionWatchedRelayId);
    }
    sessionScrollController?.destroy();
    if (typeof disposeSessionUpdates === "function") disposeSessionUpdates();
    if (typeof disposeNativeRunUpdates === "function") disposeNativeRunUpdates();
  }, { once:true });

  async function submitSteer() {
    if (sending) return;
    const body = replyInputEl.value.trim();
    const id = text(content.relayId);
    if (!body || !id) return;
    const newTurn = sessionSettled();
    sending = true;
    refreshComposer();
    let result;
    try {
      result = await bridge.steer(id, body, newTurn);
    } catch (error) {
      result = { ok: false, error: (error && error.message) || String(error) };
    }
    sending = false;
    if (result && result.ok) {
      if (newTurn) {
        content.taskCompletedAt = "";
        sessionFingerprint = "";
        actOpen.clear();
      }
      replyInputEl.value = "";
      autosizeComposer();
      setNote("Steered — it lands in the current turn without interrupting it.");
      if (!sessionWatching) pollSession().catch(() => {});
    } else {
      setNote(text(result && result.error) || "The steer did not send.", true);
    }
    refreshComposer();
  }

  // --- The message face -------------------------------------------------------

  async function renderContent(next) {
    const currentRevision = ++renderRevision;
    const row = next && typeof next === "object" ? next : {};
    content = row;
    // A room with no message behind it: opened from a contact card, so it is
    // the conversation itself rather than a message that has one.
    chatOnly = Boolean(text(row.chatId)) && !text(row.relayId);
    const id = text(row.relayId);
    const markdown = markdownFor(row);
    const title = titleFor(row, markdown);
    const when = dateFor(row);

    // Paint the high-level message before doing any Markdown work so it is
    // always the first and most prominent user-facing content in the panel.
    titleEl.textContent = title;
    document.title = `${title} — Relay preview`;
    senderEl.textContent = senderFor(row);
    timeEl.textContent = when.label;
    metaDotEl.classList.toggle("gone", !when.label);
    if (when.iso) timeEl.dateTime = when.iso;
    else timeEl.removeAttribute("datetime");
    metaEl.classList.toggle("gone", !senderEl.textContent && !when.label);
    detailScrollEl.scrollTop = 0;
    bodyEl.replaceChildren();
    errorEl.classList.add("gone");
    errorEl.textContent = "";
    emptyEl.textContent = markdown ? "Rendering details…" : "No additional detail was included with this relay.";
    emptyEl.classList.remove("gone");
    replyInputEl.value = "";
    stagedReplyFiles = [];
    paintReplyFiles();
    autosizeComposer();

    // A different message means a different conversation until proven otherwise.
    chat = null;
    outgoing = [];
    expanded.clear();
    landed = false;
    chatRevision += 1;
    updateChip();

    if (chatOnly) {
      // One room and nothing else: no reading face, no task, no back button.
      // Named from the card that opened it so the window is right on its first
      // frame, then filled in from the server.
      const who = text(row.chatTitle) || "Conversation";
      document.title = `${who} — Relay`;
      chatNameEl.textContent = who;
      chatPeopleEl.textContent = "";
      chatBackEl.classList.add("gone");
      taskMode = false;
      starting = false;
      setSessionMode(false);
      renderTaskMeta();
      showFace("chat", { force: true });
      setNote("");
      refreshComposer();
      await loadChat();
      return;
    }
    chatBackEl.classList.remove("gone");
    // A new message opens on the face its own content earns, whichever face the
    // reader left the last one on: a chat message opens IN its conversation —
    // there is nothing on the reading face the conversation does not already
    // show — and everything else opens on the message itself. main decides;
    // see message-face.cjs.
    const opensInChat = text(row.openFace) === "chat" && Boolean(text(row.threadId));
    if (opensInChat) chatNameEl.textContent = chatTitleHint(row) || "Conversation";
    // The task face: the reading surface wearing the Start composer until the
    // task has been started; after that it is an ordinary conversation whose
    // meta line carries the receipt.
    starting = false;
    taskMode = text(row.openFace) === "task" && !text(row.taskStartedAt);
    if (taskMode && rtCodexItemEl) {
      const codexAvailable = Boolean(row.runtimes && row.runtimes.codex);
      rtCodexItemEl.disabled = !codexAvailable;
      if (!codexAvailable && runtime.host === "codex") runtime.host = "claude";
      renderRuntimeWords();
    }
    renderTaskMeta();
    setSessionMode(text(row.openFace) === "task" && Boolean(text(row.taskStartedAt)));
    showFace(opensInChat ? "chat" : "message", { force: true });
    setNote("");
    refreshComposer();

    if (row.error) {
      setError(row.error);
      return;
    }

    try {
      const safeHtml = markdown ? await Promise.resolve(bridge.renderMarkdown(markdown)) : "";
      if (currentRevision !== renderRevision) return;
      if (safeHtml) {
        // renderMarkdown runs in the isolated preload and returns allowlisted,
        // DOMPurify-sanitized HTML. No other value is assigned through innerHTML.
        bodyEl.innerHTML = safeHtml;
        emptyEl.classList.add("gone");
      } else {
        emptyEl.textContent = "No additional detail was included with this relay.";
        emptyEl.classList.remove("gone");
      }
      if (id) bridge.rendered(id);
    } catch (error) {
      if (currentRevision !== renderRevision) return;
      setError(error && error.message ? error.message : "This relay could not be rendered.");
    }

    // The conversation loads after the message is on screen: reading never
    // waits on the network, and the chip appears when there is one to show.
    loadChat().catch(() => {});
  }

  if (!bridge) {
    setError("The Relay preview bridge did not load.");
    minimizeButtonEl.disabled = true;
    closeButtonEl.disabled = true;
    replyInputEl.disabled = true;
    replyButtonEl.disabled = true;
    setNote("The Relay preview bridge did not load.", true);
    return;
  }

  detailScrollEl.addEventListener("click", (event) => {
    const target = event.target instanceof Element ? event.target.closest("a[href]") : null;
    if (!target || !detailScrollEl.contains(target)) return;
    event.preventDefault();
    event.stopPropagation();
    bridge.openExternal(target.getAttribute("href"));
  });

  // Links inside bubbles leave through the same audited path as the reader's.
  chatScrollEl.addEventListener("click", (event) => {
    const target = event.target instanceof Element ? event.target.closest("a[href]") : null;
    if (!target || !chatScrollEl.contains(target)) return;
    event.preventDefault();
    event.stopPropagation();
    bridge.openExternal(target.getAttribute("href"));
  });

  minimizeButtonEl.addEventListener("click", () => bridge.minimize());
  closeButtonEl.addEventListener("click", () => bridge.close());
  chatChipEl.addEventListener("click", () => showFace("chat"));
  chatBackEl.addEventListener("click", () => showFace("message"));

  // The runtime tray: quiet words that open a small menu. Selection re-renders
  // the words; nothing is applied until Start.
  if (runtimeBtnEl && runtimePopEl) {
    runtimeBtnEl.addEventListener("click", (event) => {
      event.stopPropagation();
      const open = runtimePopEl.classList.toggle("open");
      runtimeBtnEl.setAttribute("aria-expanded", open ? "true" : "false");
    });
    document.addEventListener("click", (event) => {
      if (!runtimePopEl.contains(event.target) && event.target !== runtimeBtnEl) {
        runtimePopEl.classList.remove("open");
        runtimeBtnEl.setAttribute("aria-expanded", "false");
      }
    });
    runtimePopEl.addEventListener("click", (event) => {
      const item = event.target instanceof Element ? event.target.closest(".rt-item") : null;
      if (!item || item.disabled) return;
      event.stopPropagation();
      const key = item.dataset.rtHost ? "host" : item.dataset.rtModel ? "model" : item.dataset.rtEffort ? "effort" : "";
      if (!key) return;
      const dataKey = key === "host" ? "rtHost" : key === "model" ? "rtModel" : "rtEffort";
      for (const sibling of runtimePopEl.querySelectorAll(".rt-item")) {
        if (sibling.dataset[dataKey] !== undefined) sibling.classList.toggle("on", sibling === item);
      }
      runtime[key] = item.dataset[dataKey];
      renderRuntimeWords();
    });
  }
  replyButtonEl.addEventListener("click", () => {
    submitReply().catch(() => {});
  });
  safetyButtonEl.addEventListener("click", () => {
    reviewSafety().catch(() => {});
  });

  replyAttachButtonEl.addEventListener("click", () => replyFilePickerEl.click());
  replyFilePickerEl.addEventListener("change", () => {
    stageReplyFiles(replyFilePickerEl.files);
    replyFilePickerEl.value = "";
  });

  replyInputEl.addEventListener("input", () => {
    autosizeComposer();
    refreshComposer();
  });

  replyInputEl.addEventListener("paste", (event) => {
    const files = clipboardFiles(event);
    if (!files.length) return;
    event.preventDefault();
    stageReplyFiles(files);
  });

  replyInputEl.addEventListener("dragover", (event) => {
    if (event.dataTransfer && [...(event.dataTransfer.types || [])].includes("Files")) event.preventDefault();
  });

  replyInputEl.addEventListener("drop", (event) => {
    const files = [...(event.dataTransfer?.files || [])].filter(Boolean);
    if (!files.length) return;
    event.preventDefault();
    stageReplyFiles(files);
  });

  replyInputEl.addEventListener("keydown", (event) => {
    if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
    event.preventDefault();
    submitReply().catch(() => {});
  });

  window.addEventListener("keydown", (event) => {
    const key = String(event.key || "").toLowerCase();
    if ((event.metaKey || event.ctrlKey) && key === "w") {
      event.preventDefault();
      bridge.close();
      return;
    }
    if (key === "escape") {
      event.preventDefault();
      // Escape backs out one level at a time: out of the conversation first,
      // then out of the window. A contact's room has no level under it, so
      // Escape leaves.
      if (face === "chat" && !chatOnly) showFace("message");
      else bridge.close();
      return;
    }
    if ((event.metaKey || event.ctrlKey) && key === "m") {
      event.preventDefault();
      bridge.minimize();
    }
  });

  // New mail while a conversation is open: re-read it so the message appears
  // where the person is already looking. Skipped while a send is in flight —
  // that reload is already queued and would only race it.
  if (bridge.onMail) {
    bridge.onMail(() => {
      if (sending || (!text(content.threadId) && !chatOnly)) return;
      loadChat({ keepScroll: true }).catch(() => {});
    });
  }

  refreshComposer();
  const applyTheme = (value) => {
    if (value === "dark") document.documentElement.dataset.theme = "dark";
    else delete document.documentElement.dataset.theme;
  };
  if (bridge.onTheme) bridge.onTheme(applyTheme);
  bridge.onContent((payload) => {
    if (payload && payload.uiTheme) applyTheme(payload.uiTheme);
    renderContent(payload);
  });
  // Register content and control listeners before announcing readiness so the
  // main process can never send the first payload into a listener gap.
  bridge.ready();
})();