/**
 * Widget App Persistent - テキスト+リアルタイム ハイブリッドウィジェット
 *
 * テキストモード（Chat Completions SSE）をデフォルトとし、
 * ユーザー操作でリアルタイムモード（WebRTC）に切り替え可能。
 * WordPress iframe に埋め込まれ、pjax遷移をまたいで永続する。
 */

import { TextChatController } from "../widget/text-chat-controller.js";
import {
  handlePageNavigation,
  getQuickActions,
  getPageContextMessage,
} from "../widget/navigation-context.js";
import {
  saveSession,
  restoreSession,
  clearSession,
  registerPersistenceListeners,
} from "../widget/session-persistence.js";
import { AnalyticsTracker } from "../widget/analytics-tracker.js";
import { renderMarkdownLite } from "../widget/markdown-lite.js";
import { nsFetch, t } from "../ns/ns-rest.js";

// Phase 4: 音声モード（Realtime API）統合
import {
  OpenAIRealtimeConnector,
  ephemeralTokenManager,
  startConnectionTiming,
  isIOSSafari,
  sessionLogger,
  getElapsedSinceConnectionStart,
} from "./openai-realtime-connector.js";
import { ToolHandler } from "./tool/tool-handler.js";
import {
  sendInstructionLayers as sendInstructionLayersUtil,
  loadContextLater as loadContextLaterUtil,
  prefetchEphemeralToken as prefetchEphemeralTokenUtil,
  fetchSessionConfig,
} from "./connection-utils.js";
// Issue #455: navigator.connection (Network Information API) は Safari/Firefox 未対応のため、
// localStorage の slow 履歴 fallback を含む共通 helper を経由する
import { getNetworkPreference } from "../utils/network-preference.js";
import {
  captureException,
  setupGlobalErrorHandlers,
  setUserInfo as setExceptionUserInfo,
} from "./exception-reporter.js";
import { UserIdManager } from "../core/user-id-manager.js";
import {
  RECONNECT_MAX_RETRIES,
  calculateBackoffDelayWithJitterMs,
  formatReconnectStatus,
} from "./reconnect-config.js";
import { buildGreetingTimeHintLine } from "./prompt/greeting-time-hint.js";

/**
 * §10 R2 (深い階層 .js モジュールのキャッシュ陳腐化): 相対 import には `?ver=` を付けられない
 * (INV-T3) ため、ここに焼き込んだバージョンと PHP 側が注入する
 * `window.__NS_VERSION__` (NECCOSPEAK_VERSION, includes/widget-frame.php) を
 * initPersistentWidget() 起動時に突き合わせ、不一致なら console.warn する。
 * これが版混在 (親ドキュメントだけ更新されて、CDN/キャッシュプラグインが古い
 * モジュールを配り続ける事故) を検知する唯一の手段 — リリース時は
 * neccospeak.php の NECCOSPEAK_VERSION を上げたら必ずこの定数も同じ値に
 * 更新すること (tests/test-frame-app.js が更新漏れを機械チェックする)。
 */
export const NS_FRAME_VER = "0.3.0";

/**
 * text→voice 高速切替対策 (契約 B-1): switchToTextMode() が確立済み接続を
 * suspendSession() した後、resumeSession() されないまま完全破棄
 * (disconnectRealtime()) するまでの待機時間 (ms)。この間に音声へ戻れば
 * トークン再発行なしでセッションを再利用する。
 */
const REALTIME_SUSPEND_TIMEOUT_MS = 60_000;

/**
 * dev.61: タブが非表示のまま音声セッションを維持し続ける上限 (ms)。超えたら
 * switchToTextMode() 経由で suspend へ昇格する (マイク物理停止 = 録音インジケーター
 * 消灯、さらに REALTIME_SUSPEND_TIMEOUT_MS 後に全破棄)。
 */
const BACKGROUND_VOICE_SUSPEND_MS = 20_000;

/**
 * dev.57 W2: マイクデバイス切替 (handleMicDeviceChange) が getUserMedia() の
 * 応答待ちで無期限にハングしないようにするタイムアウト (ms)。デバイスが
 * 引き抜かれた直後の再列挙などで OS 側が応答しないケースを想定 (判断事項D)。
 */
const MIC_SWITCH_TIMEOUT_MS = 20_000;

/**
 * dev.63: ユーザーが明示的に選んだマイクデバイスIDの localStorage キー。
 * 保存されている = 明示選択あり(exact取得・一覧で該当デバイスをハイライト)。
 * 未保存 = システム既定に追従(deviceId制約なしで取得・「システム既定」を
 * ハイライト)。デバイス抜去では消さない — 接続/復帰は ideal 制約なので
 * 不在時は既定へ自然にフォールバックし、デバイスが戻れば次回から再び使う。
 */
const MIC_DEVICE_STORAGE_KEY = "neccospeak-mic-device-id";

/**
 * Persistent Widget Application
 */
export class PersistentWidgetApp {
  constructor() {
    // モード管理
    this.mode = "text"; // "text" | "realtime"

    // DOM 要素キャッシュ
    this.el = {};

    // テキストチャットコントローラ
    this.textChat = null;

    // 初回来訪判定: UserIdManager.getUserId() を呼ぶ "前" にスナップショットする必要がある。
    // NS はサーバー側で発行 ID を注入しない (プライバシー審査を増やさない方針) ため、
    // UserIdManager 側の localStorage 保存キーが未使用かどうかのみで判定するクライアント
    // 完結ロジックに置換する。
    this._isFirstVisit = (() => {
      try {
        return !localStorage.getItem("conversation-user-id");
      } catch (e) {
        return false; // localStorage 不可環境では安全側に倒す
      }
    })();

    // ユーザーID
    this.userId = this.getOrCreateUserId();

    // 初期メッセージ表示済みフラグ
    this.welcomeVisible = true;

    // 現在のページコンテキスト
    this.currentPageContext = null;

    // 親ウィンドウのオリジン（postMessage のターゲット）
    this.parentOrigin = this._resolveParentOrigin();

    // セッション永続化クリーンアップ関数
    this._cleanupPersistence = null;

    // アナリティクストラッカー（実装は ns/ns-analytics.js に委譲）
    this.analytics = new AnalyticsTracker({});

    // ── Phase 4: 音声モード関連プロパティ ──

    // WebRTC コネクタ
    this.connector = new OpenAIRealtimeConnector();

    // ToolHandler（音声モード用ツール実行）
    this.toolHandler = new ToolHandler();

    // セッション管理
    this.currentSessionId = null;
    // Issue #616: startConnectionTiming の Promise を保持し、handleTranscript 等で
    // session ID 確定前に到着した voice transcript を短時間 await できるようにする。
    // null = 未開始 / Promise<string|null> = 進行中または解決済み。
    this._sessionTimingPromise = null;
    // 音声モード用 instructions（サーバーから取得した完成形）
    this.currentInstructions = "";
    // 最後に取得した sessionConfig
    this._lastSessionConfig = null;
    // 挨拶送信済みフラグ（セッション内で1回のみ）
    this._greetingSent = false;
    this._greetingTimingLogged = false;
    this._greetingAudioLogged = false;
    // Issue #558: VAD auto-response を greeting 完了後に1回だけ再有効化するフラグ
    this._autoResponseReenabled = false;
    // Issue #558 round-1 low: 旧セッションの再有効化タイマーをキャンセルするための AbortController
    this._autoResponseAbort = null;
    // text↔voice 切替で in-flight な接続 chain を bail させるためのモード epoch カウンタ。
    // switchToRealtimeMode / switchToTextMode / _handleNewConversation で必ず +1 され、
    // 接続 chain (connectToOpenAI / startWebRTCConnection / _sessionTimingPromise.then) は
    // ローカルに capture した epoch と this._connectEpoch を比較し、ズレていれば bail する。
    this._connectEpoch = 0;
    // クイックアクション→音声切替時に挨拶に含める質問テキスト
    this._pendingGreetingQuestion = null;
    // F8: 直前の挨拶がクイックアクション由来の質問に答えるものだったかを保持する。
    // 質問を伴わない「素の」挨拶のみ textChat.messages への記録を skip する対象とし、
    // 質問に実際に答えた応答（実質的な対話）は記録・保存対象に残す。
    this._greetingHadPendingQuestion = false;

    // 音声モード中の会話履歴（モード切替時の要約生成に使用）
    this._realtimeMessages = [];

    // 当ページセッションで新規に生成されたテキストモードメッセージ
    // session_end enqueue 時に送信（復元メッセージの重複送信を防止）
    this._pageSessionMessages = [];

    // メッセージ重複排除
    this.displayedMessageIds = new Set();
    this.maxDisplayedMessageIds = 100;

    // テキスト送信済みitem_id（サーバーechoによる二重表示防止）
    this._sentItemIds = new Set();

    // ストリーミング表示管理
    this.currentStreamingElement = null;
    this.currentStreamingMessageId = null;

    // 音声モード用 tool calls トラッキング
    this.currentToolCalls = [];

    // マイク・スピーカーミュート状態
    this.isMicMuted = false;
    this.isSpeakerMuted = false;
    this.isSpeechActive = false;

    // AudioContext & Waveform
    this.audioContext = null;
    this.analyser = null;
    this.micStream = null;
    this.micSourceNode = null;
    this.animationFrameId = null;

    // マイクデバイス選択
    this.currentMicDeviceId = null;
    // dev.63: 明示選択の永続値。Safari Private Mode 等 Storage 不可環境では
    // null のまま(=システム既定追従)に静かに縮退する。
    this._storedMicDeviceId = this._loadStoredMicDeviceId();
    this.isMicPopoverOpen = false;
    // dev.57 W2: handleMicDeviceChange() の再入防止フラグ。連打/切替中の
    // 別デバイス選択で gUM() が二重に走り接続がフリーズするのを防ぐ。
    this._micSwitchInProgress = false;

    // ページ離脱クリーンアップ重複防止
    this._unloadCleanupDone = false;

    // マイクウォームアップ状態（Android向け）
    this.micWarmedUp = false;

    // マルチタブ排他制御
    this._voiceSessionBlockedByOtherTab = false;
    this._tabVoiceChannel = null;

    // Realtime 自動再接続 (Issue #193 / G-013)
    this._reconnectAttempt = 0;
    this._reconnectTimeoutId = null;
    this._isAutoReconnecting = false;

    // text↔voice 高速切替対策 (契約 B-1): suspendSession() 中の完全破棄までの
    // 待機タイマー。_startSuspendTimer() / _clearSuspendTimer() のペアでのみ操作する。
    this._suspendTimeoutId = null;

    // suspend からの復帰処理 (_resumeSuspendedSession) の多重起動防止フラグ。
    // 冷間接続 (switchToRealtimeMode) は最初の await 前に同期的に this.mode を
    // "realtime" にするため連打ガードが効くが、resume 経路は await の間中
    // this.mode==="text" / connector.isSuspended()===true のままで、mode 由来の
    // ガードが機能しない。_resumeSuspendedSession() の全終了経路 (成功・epoch
    // ミスマッチ・失敗フォールバック・例外) で必ず false に戻す。
    this._resumeInProgress = false;

    // dev.61: タブ非表示時の音声ブロック。_backgroundHidden は
    // _handleVisibilityChange() だけが書き込む「現在タブが hidden か」の内部
    // フラグ(document.hidden の生値ではなく、冪等化・stale ガード用に保持する
    // スナップショット)。_backgroundSuspendTimeoutId は Stage2 昇格タイマーで、
    // _startBackgroundSuspendTimer() / _clearBackgroundSuspendTimer() のペアで
    // のみ操作する。
    this._backgroundHidden = false;
    this._backgroundSuspendTimeoutId = null;
  }

  /**
   * 初期化
   */
  init() {
    // グローバルエラーハンドラーを設定
    setupGlobalErrorHandlers();
    setExceptionUserInfo({ userId: this.userId });

    this.cacheElements();
    this._applyVoiceUiConfig();
    this.setupTextChat();
    this.setupConnectorCallbacks();
    this.setupEventListeners();
    this.setupPostMessageHandler();
    this.setupSessionPersistence();
    this.setupUnloadHandlers();
    this.setupVisibilityHandlers();
    this.setupVisualViewport();

    // iOS Safari: デフォルトでスピーカーをミュートに設定
    if (isIOSSafari()) {
      this.isSpeakerMuted = true;
      this.el.btnSpeakerMute?.classList.add("agent__mute-btn--muted");
      console.log("[PersistentWidget][iOS] speaker muted by default");
    }

    // マルチタブ排他制御の初期化
    this._initTabVoiceExclusion();

    // Issue #336: アナリティクスは "impression" のみ初期で、open session は遅延
    // (チャットを開かない閲覧者の通信負荷を削減)
    this.analytics.trackImpression?.({ userId: this.userId, initialMode: this.mode });
    this._sessionStarted = false;
    this._deferredInitDone = false;

    // __AGENT_CONFIG__ の welcome 設定を DOM に適用（SSR と同期）
    const welcomeCfg = this.cfg?.welcome || {};
    if (welcomeCfg.icon_image_url || welcomeCfg.icon) {
      const iconEl = this.el.welcomeScreen?.querySelector(".agent__welcome-icon");
      if (iconEl) {
        // icon_image_url は http/https のみ許可。javascript: / data: / file: 等は
        // escapeHtml の実体参照化で属性 breakout は防止済みだが、defense-in-depth として
        // プロトコルレベルでも拒否する（IMPLEMENTATION_GAPS.md G-005）。
        // 不正 URL の場合は icon (Bootstrap Icons 名) へフォールバック。
        if (welcomeCfg.icon_image_url && /^https?:\/\//i.test(welcomeCfg.icon_image_url)) {
          iconEl.innerHTML = `<img src="${this.escapeHtml(welcomeCfg.icon_image_url)}" alt="" class="agent__welcome-icon-img">`;
        } else if (welcomeCfg.icon) {
          iconEl.innerHTML = `<i class="bi bi-${this.escapeHtml(welcomeCfg.icon)}"></i>`;
        }
      }
    }


    // CTAパネルを常に構築（ホームビューに表示）
    this.renderWelcomeCtas();

    // セッション復元を試みる（復元されたらチャットビューに切替）
    this.currentView = "home";
    this.tryRestoreSession();

    // 親ウィンドウに準備完了を通知
    this._postToParent({ type: "widget-ready", timestamp: Date.now() });
    this._sendWidgetEvent('widget_open');

    // タブ切替イベント
    this.el.tabHome?.addEventListener("click", () => this.showHome());
    this.el.tabChat?.addEventListener("click", () => this.showChat());

    // ウィジェット展開時はホーム画面を表示するのみ
    // テキストセッション開始・挨拶送信はユーザーがチャットタブに切り替えた時に行う
    // _triggerGreeting() は外部から呼び出し可能な状態で維持
  }

  /**
   * Issue #336: 初回 user 操作後に走らせる重い初期化
   *  - sessionLogger.startSession (open session 起動)
   *  - Realtime token prefetch (idle scheduled)
   *  - (0.3.0-dev.25: Android mic warmup was removed — see the note inside)
   *
   * Save-Data / 低速回線 (effectiveType=2g/slow-2g) では prefetch / warmup を skip して通信量を節約。
   * showChat() / 音声接続開始 / openWidget 内など最初の "ユーザー意思決定" イベントから呼び出す。
   */
  _scheduleDeferredInit() {
    if (this._deferredInitDone) return;
    this._deferredInitDone = true;

    if (!this._sessionStarted) {
      this.analytics.startSession({ userId: this.userId, initialMode: this.mode });
      this._sessionStarted = true;
    }

    sessionLogger.startSession(this.userId).catch((e) => {
      console.warn("[PersistentWidget] SessionLogger start failed:", e.message);
    });

    // Issue #455: navigator.connection は Safari/Firefox 未対応のため共通 helper を利用。
    // Chromium 系は connection.saveData/effectiveType を、Safari/Firefox は localStorage の
    // slow 履歴 (7 日窓に 3 回以上 5 秒超過) を fallback として参照する。
    const networkPref = getNetworkPreference();
    if (networkPref === "save-data" || networkPref === "slow") {
      console.log(`[PersistentWidget] Network pref=${networkPref} — token prefetch / mic warmup skip`);
      return;
    }

    // 0.3.0-dev.25: pocxr 原本にあった Android のマイクウォームアップ
    // (connection-utils.js warmupMicrophone — 初回操作の idle で getUserMedia を
    // 先回り実行し音声接続を 100-200ms 短縮する) はここから削除した。テキストを
    // 送っただけのユーザーに音声意図と無関係なマイク許可プロンプトが出るうえ、
    // そこで「ブロック」されると以後の音声モードが恒久拒否で始まる — 最適化の
    // 対価として重すぎる。マイク許可は実際の音声接続時 (connector.connect())
    // にのみ要求する。
    const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 1500));
    schedule(() => {
      prefetchEphemeralTokenUtil(
        (msg) => console.log(`[PersistentWidget] ${msg}`),
        { userId: this.userId, pageContext: this.currentPageContext }
      )
        .catch((e) => console.warn("[PersistentWidget] Token prefetch failed:", e.message))
        .then(() => console.log("[PersistentWidget] async initialization complete"));
    }, { timeout: 5000 });
  }

  /**
   * AIに挨拶を促すメッセージを送信
   * ユーザーの入力としては表示せず、AIの応答のみ表示する
   * テキストモード: ephemeral sendMessage でAPI呼出し
   * リアルタイムモード: response.create で発話トリガー
   */
  /**
   * 会話が既に始まっているか（テキスト/音声いずれかのメッセージが存在するか）。
   * 挨拶（_triggerGreeting）は「会話の開始」専用であり、これが true の間は
   * どの経路からも再発火させない（0.3.0-dev.39 の暴走ループ対策の本質ガード）。
   * @returns {boolean}
   */
  _hasConversation() {
    if (this.textChat && Array.isArray(this.textChat.messages) && this.textChat.messages.length > 0) return true;
    if (Array.isArray(this._realtimeMessages) && this._realtimeMessages.length > 0) return true;
    return false;
  }

  /**
   * 音声モードの発話を会話履歴へ記録する (0.3.0-dev.46)。
   * _realtimeMessages に加えて textChat.messages にも積むことで、
   *  - saveSession の保存対象になる（従来は音声のみの会話だと
   *    messages.length === 0 で保存自体がスキップされ、ページ遷移で
   *    履歴が全て消えていた）
   *  - テキストモードへ切り替えた後の /chat コンテキストにも引き継がれる
   *
   * Review batch H (F7/F8/F9) 対応:
   *  - textChat.messages へ積むエントリには常に `_voiceOrigin: true` を付ける。
   *    2026-08 時点: このマーカーを実際に読んで分岐する消費者は現存しない
   *    (dev.59 で唯一の読み手だった text-chat-controller.js の
   *    userTurnCount 計算(F9)が sid/turn 送信撤去とともに削除された)。
   *    今は単なる履歴上のマーカーで、/chat への送信直前に
   *    { role: m.role, content: m.content } へ正規化する際、他の非標準
   *    フィールドと同様に自動的に剥離される(rest.php の {role,content}
   *    契約を守るため)。デバッグ時に「この履歴エントリは音声由来か」を
   *    見分ける目的でのみ残っている。
   *  - textChat.maxMessages の上限をここでも必ず適用する (F7: 従来は
   *    sendMessage() 内でしか適用されず、音声モードの会話履歴が無制限に
   *    増え続けていた)。
   * @param {string} role - "user" | "assistant"
   * @param {string} content
   * @param {{skipTextChat?: boolean}} [options] - skipTextChat: true の場合
   *   textChat.messages には積まない (AI 自動挨拶など、会話開始判定を
   *   汚染したくない発話 — F8)。_realtimeMessages には常に積む。
   */
  _recordRealtimeMessage(role, content, options = {}) {
    this._realtimeMessages.push({ role, content });
    if (options.skipTextChat) return;
    if (this.textChat && content) {
      this.textChat.messages.push({ role, content, _voiceOrigin: true });
      this._enforceTextChatMessageCap();
    }
  }

  /**
   * textChat.messages の件数上限 (maxMessages) を適用する。sendMessage() 内の
   * 上限処理と同じ規則を、音声モード起源の追加 (_recordRealtimeMessage /
   * _reserveRealtimeUserSlot) にも適用する共通経路 (F7)。
   */
  _enforceTextChatMessageCap() {
    if (!this.textChat) return;
    const max = this.textChat.maxMessages || 50;
    if (this.textChat.messages.length > max) {
      this.textChat.messages = this.textChat.messages.slice(-max);
    }
  }

  /**
   * ユーザー音声の commit 時点 (handleUserAudioCommitted) で会話履歴に
   * placeholder を予約する (F2)。commit は対応する transcript 確定より必ず
   * 先に届くため、ここで配列上の位置を確保しておくことで、Whisper の文字
   * 起こしが遅延して AI 応答の確定 (handleRealtimeTextResponse) より後に
   * 届いても、配列の並び順は commit 順 = 実際の発話順を保つ。
   * @param {string} itemId
   */
  _reserveRealtimeUserSlot(itemId) {
    if (!itemId) return;
    if (!this._pendingRealtimeUserSlots) this._pendingRealtimeUserSlots = new Map();
    if (this._pendingRealtimeUserSlots.has(itemId)) return;

    const realtimeEntry = { role: "user", content: "" };
    this._realtimeMessages.push(realtimeEntry);

    let textChatEntry = null;
    if (this.textChat) {
      textChatEntry = { role: "user", content: "", _voiceOrigin: true };
      this.textChat.messages.push(textChatEntry);
      this._enforceTextChatMessageCap();
    }
    this._pendingRealtimeUserSlots.set(itemId, { realtimeEntry, textChatEntry });
  }

  /**
   * 予約済み placeholder (_reserveRealtimeUserSlot) を文字起こし確定内容で
   * 埋める (F2)。commit イベントを経由していなかった場合 (異常系のフォール
   * バック) は通常の追加にフォールバックする。
   * @param {string} itemId
   * @param {string} content
   */
  _resolveRealtimeUserSlot(itemId, content) {
    const pending = itemId && this._pendingRealtimeUserSlots?.get(itemId);
    if (!pending) {
      this._recordRealtimeMessage("user", content);
      return;
    }
    this._pendingRealtimeUserSlots.delete(itemId);
    pending.realtimeEntry.content = content;
    if (pending.textChatEntry) pending.textChatEntry.content = content;
  }

  /**
   * 空白のみの文字起こし等、確定させない場合に予約済み placeholder を撤去する
   * (F6)。対応する reservation が無ければ何もしない。
   * @param {string} itemId
   */
  _discardRealtimeUserSlot(itemId) {
    const pending = itemId && this._pendingRealtimeUserSlots?.get(itemId);
    if (!pending) return;
    this._pendingRealtimeUserSlots.delete(itemId);
    const realtimeIdx = this._realtimeMessages.indexOf(pending.realtimeEntry);
    if (realtimeIdx !== -1) this._realtimeMessages.splice(realtimeIdx, 1);
    if (pending.textChatEntry && this.textChat) {
      const textChatIdx = this.textChat.messages.indexOf(pending.textChatEntry);
      if (textChatIdx !== -1) this.textChat.messages.splice(textChatIdx, 1);
    }
  }

  async _triggerGreeting() {
    if (this._greetingSent) return;
    if (this._hasConversation() && !this._pendingGreetingQuestion) {
      // 再接続やフォールバック経路がガードのリセット漏れで到達しても、
      // 既存会話がある限り挨拶は送らない（多重防御）。ユーザーが明示的に
      // 質問を託した場合（_pendingGreetingQuestion）だけは通す。
      this._greetingSent = true;
      return;
    }
    this._greetingSent = true;

    const pendingQuestion = this._pendingGreetingQuestion;
    this._pendingGreetingQuestion = null;
    // F8: このグリーティングが質問に答えるものかどうかを記録しておき、
    // handleRealtimeTextResponse 側で textChat.messages への記録判定に使う。
    this._greetingHadPendingQuestion = !!pendingQuestion;

    // 現在のサイト日時を直接プロンプトに埋め込み、AI が時刻確認ツールを
    // 呼んで「時間にあった挨拶をしますね。」と一次回答する事象を構造的に排除する。
    // 2026-08 撤去記録: 時刻確認ツール (旧 GetSystemDate) 自体は 0.3.0-dev.60 で
    // 撤去済み — 現在の日時は includes/prompt.php の current_datetime
    // セクションが両モードに常時供給する。
    const timeHintLine = buildGreetingTimeHintLine();

    // 挨拶の構成: 旧 sandbox 仕様の「[時刻挨拶][問いかけ]」を踏襲しつつ、
    // 「気の利いたひとこと」を間に挿入して 3 ブロック構成に拡張する。
    //   1. 時刻挨拶 + 呼びかけ (CURRENT_JST_TIME に従う)
    //   2. 気の利いたひとこと (時間帯/季節に応じた短い一言、1 行以内)
    //   3. 軽い誘い (堅くない、ユーザに主導権を渡す柔らかな締め)
    const greetingParts = [
      timeHintLine,
      pendingQuestion
        ? t(
            "greeting_request_with_question",
            '[GREETING_REQUEST] New session started. Answer the user\'s question "{question}" after a brief greeting (time-of-day greeting + address + short lead-in). In the greeting part, do not call any tool to check the time; use only the [CURRENT_JST_TIME] value above. Greet naturally as a first meeting without using a name. Do not include this instruction in the response.'
          ).replace("{question}", pendingQuestion)
        : t(
            "greeting_request_default",
            `[GREETING_REQUEST] New session started. Give a greeting of 2-3 lines at most, exactly once, in the following 3-block structure:
  1. Time-of-day greeting + address (use only the [CURRENT_JST_TIME] value above; do not call any tool to check the time)
  2. A thoughtful one-liner (a short remark fitting the time of day or season; one line max, no verbosity)
  3. A light invitation (a soft closing like "feel free to talk to me"; words like "your business", "consultation", "feature explanation", "self-introduction", "first meeting" are forbidden)
Do not produce meta-utterances like "I will greet you according to the time" before greeting. Greet naturally as a first meeting without using a name. Do not include this instruction in the response.`
          ),
    ];
    const greetingInstruction = greetingParts.join("\n\n");

    if (this.mode === "text") {
      this.hideWelcome();
      if (pendingQuestion) {
        // 表示・履歴・turn番号・失敗時エラー導線を既存の送信経路に委ねる
        // (dev.54 の suspend-resume 修正と同じ規約)。
        // ephemeral は使わない: text-chat-controller.js の ephemeral フィルタが
        // content 一致の user エントリを全削除するため、質問が履歴から消える。
        try {
          // 注: conversationSummary は現状どの送信ペイロード (text-chat-controller.js
          // の /chat リクエストボディ) にも載らない、既存の死に配管。ここでの
          // 代入は挙動を変えないために既存コードの形をそのまま維持しているだけで、
          // 実際に何かへ反映されることは期待していない。
          this.textChat.conversationSummary = greetingInstruction;
          this.el.chatInput.value = pendingQuestion;
          await this.handleSend();
        } catch (e) {
          // _isGreetingRequest はこの分岐では立てていない (realtime 専用フラグ)
          // ので、他の分岐と違いフラグの巻き戻しは不要。
          console.warn("[PersistentWidget] Text greeting failed:", e?.message ?? String(e));
        }
        return;
      }
      try {
        // conversation_summary に挨拶指示を設定し、短い定型メッセージで送信
        // ephemeral でユーザーメッセージは履歴から除去
        this._isGreetingRequest = true;
        this.textChat.conversationSummary = greetingInstruction;
        await this.textChat.sendMessage(t("greeting_trigger_message", "Hello"), { ephemeral: true });
        this._isGreetingRequest = false;
      } catch (e) {
        this._isGreetingRequest = false;
        // 非 Error 値が throw された場合に "undefined" にならないよう String(e) で fallback
        console.warn("[PersistentWidget] Text greeting failed:", e?.message ?? String(e));
      }
    } else if (this.mode === "realtime" && this.connector?.getIsConnected()) {
      this._isGreetingRequest = true;
      try {
        if (pendingQuestion) {
          // 表示は送信前 (handleSend 1319-1321行と同じ順序) — AI応答より必ず前に並ぶ
          this.showChat();
          this._lastUserText = pendingQuestion;          // エラーカードの retry 用
          this.addMessage(pendingQuestion, "user");
          this.analytics.recordMessage({ role: "user", content: pendingQuestion, mode: "realtime" });

          // conversation.item.create + response.create(instructions) を1回で送る。
          // createResponse を別途呼ぶと isResponseInProgress ガードで挨拶が cancel される。
          //
          // tool_choice: "none" は付けない(質問なしパスと違う点)。チップの質問は
          // サイト内容に関する実質的な問い合わせなので、site_search 等のツール
          // (RAG) を使わせて答えさせる必要がある。instructions 側の「挨拶部では
          // 時刻ツールを使うな」という指示だけで GET_SYSTEM_DATE 等の誤爆は
          // 十分防げるため、tool 呼び出し自体を封じる必要はない。
          const sent = await this.connector.sendTextMessage(pendingQuestion, {
            skipTranscript: true,          // バブルは上で自分で出した
            instructions: greetingInstruction,
          });

          if (sent) {
            this._recordRealtimeMessage("user", pendingQuestion);
            this.toolHandler?.addConversationContext?.("user", pendingQuestion);
          } else {
            // F5規約: 届いていない質問は履歴に入れない。代わりに送信失敗を伝える
            this._isGreetingRequest = false;             // フラグ滞留防止(応答は来ない)
            this.renderErrorBubble({
              title: t("err_stream_title", "A connection problem occurred"),
              body: t("err_stream_body", "The message could not be sent. Please wait a moment and try again."),
              retryHandler: () => this.handleSend(),
              lastUserText: pendingQuestion,
            });
            this.analytics.trackError("realtime_text_send_failed", "greeting_question");
          }
        } else {
          // 質問なし = 従来どおり「挨拶のみ」(conversation item は作らない)
          // 直接 response.create イベントを送ると createResponse の isResponseInProgress
          // ガードをバイパスし、サーバ VAD 自動応答との競合で "active response in progress"
          // エラーになる。必ず createResponse 経由で発火する。
          //
          // tool_choice: "none" は二重防御。instruction で「ツールを使うな」と禁止しても
          // 安全志向の LLM が GET_SYSTEM_DATE 等を呼ぼうとする可能性を構造的に排除する。
          await this.connector.createResponse({ instructions: greetingInstruction, tool_choice: "none" });
        }
        console.log("[PersistentWidget] Realtime greeting triggered");
      } catch (e) {
        // フラグ滞留で後続メッセージが greeting 扱いされる事故を防ぐ (text パスと対称)
        this._isGreetingRequest = false;
        // 非 Error 値が throw された場合に "undefined" にならないよう String(e) で fallback
        console.warn("[PersistentWidget] Realtime greeting failed:", e?.message ?? String(e));
      }
      // Issue #558: greeting の成否に関わらず VAD auto-response を再有効化する。
      // 失敗時に再有効化を怠ると VAD で発話しても応答が走らず音声モードが死ぬ。
      void this._scheduleAutoResponseReenable();
    }
  }

  /**
   * Issue #558: greeting の `response.done` 受信後に VAD 自動 response.create を再有効化する。
   *
   * connection-utils.sendInstructionLayers で接続直後に
   * turn_detection.create_response=false を仕込んでおり (VAD-greeting race 回避)、
   * このメソッドが greeting 完了を観測してから true に戻す。
   *
   * - セッション内で 1 回だけ実行 (`_autoResponseReenabled` ガード)
   * - greeting の response.done を最大 30 秒待つ。タイムアウト時も再有効化を強制
   *   (放置すると VAD で発話しても応答が走らない状態が永続する)
   * - `switchToRealtimeMode` で `_autoResponseAbort.abort()` されたら早期 return し、
   *   旧セッションのタイマー fire が新セッションの VAD 抑制期間を上書きするのを防ぐ
   *   (Issue #558 round-1 low 指摘対応)
   */
  async _scheduleAutoResponseReenable() {
    if (this._autoResponseReenabled) return;
    this._autoResponseReenabled = true;
    if (!this.connector) return;

    const signal = this._autoResponseAbort?.signal;
    if (signal?.aborted) return;

    await new Promise((resolve) => {
      let settled = false;
      let timer;
      const cleanup = () => {
        if (timer) clearTimeout(timer);
        this.connector.removeEventListener?.("response.done", handler);
        signal?.removeEventListener?.("abort", onAbort);
      };
      const handler = () => {
        if (settled) return;
        settled = true;
        cleanup();
        resolve();
      };
      const onAbort = () => {
        if (settled) return;
        settled = true;
        cleanup();
        resolve();
      };
      this.connector.addEventListener("response.done", handler);
      signal?.addEventListener?.("abort", onAbort, { once: true });
      timer = setTimeout(() => {
        if (settled) return;
        settled = true;
        cleanup();
        console.warn("[PersistentWidget] greeting response.done timeout, re-enabling VAD anyway");
        resolve();
      }, 30000);
    });

    // 待機中に abort されていたら再有効化を行わない (新セッション側で改めて kick される)
    if (signal?.aborted) return;

    // Issue #560: ephemeral token で create_response=false を強制したため、
    // 通常のターン応答を駆動するには greeting 後に session.update で復元する必要がある。
    // ただし agent 設定で voice_settings.turn_create_response: false が明示されている場合
    // (= manual/push-to-talk 系の運用) はユーザー意図を尊重して有効化しない。
    const desiredAutoResponse = this._lastSessionConfig?.voice_settings?.turn_create_response ?? true;
    if (!desiredAutoResponse) {
      console.log("[PersistentWidget] voice_settings.turn_create_response=false — leaving VAD auto-response disabled");
      return;
    }
    if (this.connector.setAutoResponseFlag?.(true)) {
      // sendAudioConfigUpdate は Promise<boolean> を返し失敗時 false を返すだけで throw しない
      // (DataChannel closed / SESSION_UPDATED ack タイムアウト)。戻り値を見ずに成功扱いすると
      // turn_detection.create_response が server 上で false のまま固着するため、戻り値検証 + retry。
      if (typeof this.connector.sendAudioConfigUpdate !== "function") {
        // メソッド未定義 (test mock 等) では spurious retry を避けて早期 return
        console.warn("[PersistentWidget] connector.sendAudioConfigUpdate not available — skipping VAD re-enable");
        return;
      }
      const MAX_ATTEMPTS = 3;
      const RETRY_BACKOFF_MS = [500, 1000]; // 最終 attempt 後は sleep しないので MAX_ATTEMPTS - 1 個
      let acked = false;
      let lastError = null;
      for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
        if (signal?.aborted) return;
        try {
          const result = await this.connector.sendAudioConfigUpdate();
          if (result) {
            acked = true;
            console.log(
              `[PersistentWidget] VAD auto-response re-enabled after greeting (attempt ${attempt})`,
            );
            break;
          }
          console.warn(
            `[PersistentWidget] VAD re-enable session.update returned false (attempt ${attempt}/${MAX_ATTEMPTS})`,
          );
        } catch (e) {
          lastError = e;
          console.warn(
            `[PersistentWidget] VAD re-enable session.update threw (attempt ${attempt}/${MAX_ATTEMPTS}):`,
            e?.message ?? String(e),
          );
        }
        if (attempt < MAX_ATTEMPTS) {
          // 中断対応 sleep: signal.aborted で即座に解除。timer 先発時はリスナーを必ず除去する
          // (reconnect ストームで listener が蓄積するのを防ぐ)。
          await new Promise((resolve) => {
            const delay = RETRY_BACKOFF_MS[attempt - 1];
            let timer = null;
            // abort 経由の解除は { once: true } で自動除去されるので removeEventListener は
            // no-op になるが、onTimer 側との対称性のため defensive に呼んでおく。
            const onAbort = () => {
              if (timer) clearTimeout(timer);
              signal?.removeEventListener?.("abort", onAbort);
              resolve();
            };
            // timer 先発時はリスナーが残るので明示的に除去する (本対称性が R4 修正の核心)。
            const onTimer = () => {
              signal?.removeEventListener?.("abort", onAbort);
              resolve();
            };
            timer = setTimeout(onTimer, delay);
            if (signal) {
              if (signal.aborted) {
                onAbort();
              } else {
                signal.addEventListener("abort", onAbort, { once: true });
              }
            }
          });
        }
      }
      if (!acked) {
        const finalError = lastError ?? new Error(
          "sendAudioConfigUpdate returned false for all retries",
        );
        captureException(finalError, "scheduleAutoResponseReenable", {
          severity: "warn",
          attempts: MAX_ATTEMPTS,
          notificationCategory: "vad_reenable_failed",
        });
        console.error(
          "[PersistentWidget] VAD auto-response re-enable failed after retries — response.create may not auto-fire even when VAD detects speech",
        );
      }
    }
  }

  /**
   * DOM 要素をキャッシュ
   */
  cacheElements() {
    this.el = {
      root: document.getElementById("agent-root"),
      chatBody: document.getElementById("chat-body"),
      chatInput: document.getElementById("chat-input"),
      btnSend: document.getElementById("btn-send"),
      btnVoice: document.getElementById("btn-voice"),
      btnMinimize: document.getElementById("btn-minimize"),
      // Issue #288: 会話メニュー (kebab) + 新しい会話を始める
      btnConversationMenu: document.getElementById("btn-conversation-menu"),
      conversationMenuDropdown: document.getElementById("conversation-menu-dropdown"),
      menuItemNewConversation: document.getElementById("menu-item-new-conversation"),
      btnBackToText: document.getElementById("btn-back-to-text"),
      btnMicMute: document.getElementById("btn-mic-mute"),
      btnSpeakerMute: document.getElementById("btn-speaker-mute"),
      // #status-dot/#status-text は home_status_indicator OFF の旧 SSR だと存在しない。
      // 未ガード参照が多数あるため、欠落時は detached 要素で受けて no-op 化する
      // (OFF のまま音声開始すると statusText.textContent への代入で TypeError →
      // 音声モード全体が死ぬ事故の再発防止)。
      statusDot: document.getElementById("status-dot") || document.createElement("span"),
      statusText: document.getElementById("status-text") || document.createElement("span"),
      homeView: document.getElementById("home-view"),
      quickActions: document.getElementById("quick-actions"),
      ctaPanel: document.getElementById("cta-panel"),
      tabHome: document.getElementById("tab-home"),
      tabChat: document.getElementById("tab-chat"),
      waveformBar: document.getElementById("waveform-bar"),
      realtimeControls: document.getElementById("realtime-controls"),
      waveformCanvas: document.getElementById("waveform-canvas"),
      welcomeScreen: document.getElementById("welcome-screen"),
      connectionIndicator: document.getElementById("connection-indicator"),
      btnMicSelect: document.getElementById("btn-mic-select"),
      btnMicRefresh: document.getElementById("btn-mic-refresh"),
      micDevicePopover: document.getElementById("mic-device-popover"),
      micDeviceList: document.getElementById("mic-device-list"),
      micDeviceName: document.getElementById("mic-device-name"),
    };

    // EJS が server-side で描画した global default のクイックアクション HTML を保存。
    // updateQuickActions で page-specific が空のとき、ここに保存した HTML に復元することで
    // 「前ページの page-specific JS 注入ボタンが残留表示される」事故を防ぐ。
    this._initialQuickActionsHTML = this.el.quickActions
      ? this.el.quickActions.innerHTML
      : "";
    // page-navigation が届かないスタンドアロン環境 (postMessage 非対応の親 / iframe 直接アクセス)
    // でも、初期化から FALLBACK_MS 経過した時点で snapshot を表示する safety net。
    // - 正常フロー: parent が widget-ready 後 ~100ms 以内に page-navigation を送る
    //   → updateQuickActions が hidden 解除 → このフォールバックは fire 時 no-op
    // - スタンドアロン: FALLBACK_MS 後に snapshot で復元 → 永久 hidden を回避
    this._scheduleQuickActionsFallback();
  }

  /**
   * __AGENT_CONFIG__.voiceUi (design doc §4.2.1 の voiceUi 行:
   * enabled/visualizer/mute/deviceSelect/textReturn) に従って realtime
   * コントロールの部分表示を切り替える。widget-frame.php は該当要素
   * (#btn-mic-mute / #btn-mic-select / #mic-device-popover /
   * #btn-speaker-mute / #btn-back-to-text) を常に無条件で描画するため
   * (widget-frame.php:1167-1181)、可視性の抑制はクライアント側のこの処理が
   * 唯一の実装箇所になる。cacheElements() 直後、他のイベント配線より先に
   * 呼ぶことで、リアルタイムモードへ切り替わる前に最終的な表示状態が決まる。
   */
  _applyVoiceUiConfig() {
    const voiceUi = this.cfg?.voiceUi || {};

    if (voiceUi.mute === false) {
      this.el.btnMicMute?.classList.add("agent--hidden");
      this.el.btnSpeakerMute?.classList.add("agent--hidden");
    }

    if (voiceUi.deviceSelect === false) {
      this.el.btnMicSelect?.classList.add("agent--hidden");
      this.el.micDevicePopover?.classList.add("agent--hidden");
    }

    if (voiceUi.textReturn === false) {
      this.el.btnBackToText?.classList.add("agent--hidden");
    }

    // voice_ui_visualizer は 'none'|'bars'|'dots' (includes/settings.php)。
    // このファイルには 'bars' 用の周波数バー描画 (drawWaveform()) しか無く、
    // 専用の dot レンダラーは移植範囲外のため 'dots' は 'bars' にフォールバック
    // する (design doc §9 相当の許容差)。'none' は波形バーを一切描画しない
    // — setupWaveformVisualization() 側もこのフラグを見て、マイク権限プロンプト
    // や AudioContext/analyser の構築自体を省略する。
    this._voiceUiVisualizerMode = voiceUi.visualizer === "none" ? "none" : "bars";
    if (this._voiceUiVisualizerMode === "none") {
      this.el.waveformCanvas?.classList.add("agent--hidden");
    }
  }

  // _applyFirstVisitNotice() (first-visit privacy notice, Issue #56) was
  // removed 0.3.0-dev.26 together with its #welcome-notice SSR block and the
  // __NS_NOTICE_ENABLED__ injection — see includes/widget-frame.php.

  _scheduleQuickActionsFallback() {
    const FALLBACK_MS = 500;
    setTimeout(() => {
      const qa = this.el?.quickActions;
      if (!qa) return;
      // page-navigation 経由で既に表示済みなら no-op
      if (!qa.classList.contains("agent--hidden")) return;
      // snapshot が無ければ何も表示できない (現状維持)
      if (!this._initialQuickActionsHTML || this._initialQuickActionsHTML.trim().length === 0) return;
      // 会話中はクイックアクションを出さない (updateQuickActions と同じ規約)
      if (this.textChat && this.textChat.messages.length > 0) return;
      qa.innerHTML = this._initialQuickActionsHTML;
      qa.classList.remove("agent--hidden");
    }, FALLBACK_MS);
  }

  /**
   * TextChatController を初期化
   */
  setupTextChat() {
    this.textChat = new TextChatController({
      onTextChunk: (content) => this.handleTextChunk(content),
      onTextDone: (fullText, doneData) => this.handleTextDone(fullText, doneData),
      onAction: (action) => this.handleAction(action),
      onError: (error) => this.handleError(error),
      onStreamStart: () => this.handleStreamStart(),
      onStreamEnd: () => this.handleStreamEnd(),
    });

    this.textChat.setContext({
      userId: this.userId,
      pageContext: { url: window.location.href, title: document.title },
    });
  }

  /**
   * WebRTC コネクタのコールバック設定
   */
  setupConnectorCallbacks() {
    this.connector.setCallbacks({
      onConnectionStateChange: (state) => this.handleConnectionStateChange(state),
      onTextResponse: (text, isPartial, messageId, metadata) =>
        this.handleRealtimeTextResponse(text, isPartial, messageId, metadata),
      onTranscript: (text, role, itemId) => this.handleTranscript(text, role, itemId),
      onTranscriptDelta: (text, itemId, role) =>
        this.handleTranscriptDelta(text, itemId, role),
      onUserAudioCommitted: (itemId) => this.handleUserAudioCommitted(itemId),
      onOutputAudioStarted: () => this.handleOutputAudioStarted(),
      onOutputAudioStopped: () => this.handleOutputAudioStopped(),
      onFunctionCall: (functionCall) => this.handleFunctionCall(functionCall),
      onError: (error) => this.handleRealtimeError(error),
      onMicrophoneError: (info) => this.handleMicrophoneError(info),
      onMicrophoneLost: (info) => this.handleMicrophoneLost(info),
      onLog: (message) => console.log(`[PersistentWidget][Connector] ${message}`),
      onSessionCreated: () => this.handleSessionCreated(),
      onSpeechStarted: () => this.handleSpeechStarted(),
      onSpeechStopped: () => this.handleSpeechStopped(),
      onResponseCreated: () => this.handleResponseCreated(),
      onResponseFailed: (errorInfo) => this.handleResponseFailed(errorInfo),
      onTextItemCreated: (itemId) => this._sentItemIds.add(itemId),
      onConversationItemTruncated: (event) =>
        this.handleConversationItemTruncated(event),
    });

    this.toolHandler.setOnFunctionResponse((callId, data) => {
      this.connector.sendFunctionCallResponse(callId, data);
    });

    // 音声モードのツールアクション（navigate等）をテキストモードと同じhandleActionで処理
    this.toolHandler.onAction = (action) => this.handleAction(action);
  }

  /**
   * イベントリスナー設定
   */
  setupEventListeners() {
    // 送信ボタン
    this.el.btnSend.addEventListener("click", () => this.handleSend());

    // Issue #287: PC 用 Enter 送信 / Shift+Enter 改行
    // モバイル (タッチデバイス) では Enter は常に改行 (誤送信防止) → 送信ボタンのみ
    const isTouchDevice = (typeof window !== "undefined") &&
      ("ontouchstart" in window || (navigator.maxTouchPoints || 0) > 0);
    // Safari など isComposing が安定しない環境向けの fallback フラグ。
    // setupEventListeners() は構築時 1 回のみ呼ばれる前提で全リスナーが匿名関数。
    // 再初期化が必要になったときは widget-app.mjs と同様に this.eventHandlers
    // にハンドラ参照を保持して removeEventListener で解除する設計に揃えること。
    this._isComposing = false;
    this.el.chatInput.addEventListener("compositionstart", () => {
      this._isComposing = true;
    });
    this.el.chatInput.addEventListener("compositionend", () => {
      this._isComposing = false;
    });

    this.el.chatInput.addEventListener("keydown", (e) => {
      if (e.key !== "Enter") return;
      if (isTouchDevice) return;
      if (e.shiftKey) return; // 改行
      // IME 変換確定 Enter で send すると value="" 直後に IME が候補を挿入して残留する。
      // ガードはキーボード経路に限定する (sendText() 等の programmatic 呼出は素通し)
      if (e.isComposing || e.keyCode === 229 || this._isComposing) return;
      e.preventDefault();
      this.handleSend();
    });

    // Issue #287: textarea auto-resize (最大 4 行)
    this.el.chatInput.addEventListener("input", () => {
      const ta = this.el.chatInput;
      ta.style.height = "auto";
      const maxHeight = parseFloat(getComputedStyle(ta).lineHeight || "20") * 4 + 16;
      ta.style.height = Math.min(ta.scrollHeight, maxHeight) + "px";
    });

    // Issue #287: モバイルキーボード出現時に input を可視領域へ
    this.el.chatInput.addEventListener("focus", () => {
      setTimeout(() => {
        try {
          this.el.chatInput.scrollIntoView({ block: "center", behavior: "smooth" });
        } catch { /* old browser */ }
      }, 200);
    });

    // 音声モード切替
    if (this.el.btnVoice) {
      this.el.btnVoice.addEventListener("click", () =>
        this.switchToRealtimeMode()
      );
    }

    // テキストモードに戻る
    if (this.el.btnBackToText) {
      this.el.btnBackToText.addEventListener("click", () =>
        this.switchToTextMode()
      );
    }

    // マイクミュート切替
    if (this.el.btnMicMute) {
      this.el.btnMicMute.addEventListener("click", () => this.toggleMicMute());
    }

    // マイクデバイス選択ドロップダウン
    if (this.el.btnMicSelect) {
      this.el.btnMicSelect.addEventListener("click", (e) => {
        e.stopPropagation();
        this.toggleMicPopover();
      });
    }

    // デバイス一覧の更新ボタン
    if (this.el.btnMicRefresh) {
      this.el.btnMicRefresh.addEventListener("click", (e) => {
        e.stopPropagation();
        this.populateMicDevices();
      });
    }

    // ポップオーバー外クリックで閉じる
    document.addEventListener("click", (e) => {
      if (
        this.isMicPopoverOpen &&
        !this.el.micDevicePopover?.contains(e.target) &&
        !this.el.btnMicSelect?.contains(e.target)
      ) {
        this.closeMicPopover();
      }
    });

    // デバイス変更検知（マイク抜き差し）
    navigator.mediaDevices?.addEventListener("devicechange", () => {
      if (this.mode === "realtime") {
        this.populateMicDevices();
      }
    });

    // スピーカーミュート切替
    if (this.el.btnSpeakerMute) {
      this.el.btnSpeakerMute.addEventListener("click", () =>
        this.toggleSpeakerMute()
      );
    }

    // 最小化
    this.el.btnMinimize.addEventListener("click", () => {
      this._postToParent({ type: "minimize-widget" });
    });

    // Issue #288: 会話メニュー (kebab) — 開閉 / 外側クリック / ESC / 「新しい会話を始める」
    this._setupConversationMenu();

    // クイックアクション
    this.el.quickActions.addEventListener("click", (e) => {
      const btn = e.target.closest(".agent__quick-btn");
      if (btn) {
        const actionText = btn.dataset.action;
        if (actionText) {
          this.analytics.trackQuickAction(actionText);
          this._sendWidgetEvent('quick_action_click', { label: actionText });

          // 音声モード接続済みならそのまま送信
          if (this.mode === "realtime" && this.connector?.getIsConnected()) {
            this.el.chatInput.value = actionText;
            this.handleSend();
            return;
          }

          // テキストモード or 未接続 → 音声モード切替 + greeting に質問を含める
          this._pendingGreetingQuestion = actionText;
          this.hideWelcome();
          this.switchToRealtimeMode();
        }
      }
    });

    // dev.58 W1: チャット本文(Markdown 由来のリンク・CTA)のクリックを
    // document 委譲で横取りし、同一オリジンのリンクは iframe を離れず
    // 親ウィンドウ側で遷移させる(タブが割れて会話が分断されるのを防ぐ)。
    // preventDefault() は要素の target="_blank" より優先されるため、ここで
    // 一元的に判定すれば markdown-lite.js のテンプレート(通常リンクの
    // [テキスト](URL) 生成部、および dev.58 レビュー修正S1で揃えた [CTA:...]
    // 生成部の両方が全リンクに target="_blank" を付与)は変更不要 — この
    // ハンドラが万一未登録・未実行でも従来どおり別タブが開くだけの
    // フォールバックになり、iframe 自己遷移という最悪の退行には絶対に
    // 落ちない(不変条件が CTA にも成立する)。
    document.addEventListener("click", (e) => this._handleDelegatedLinkClick(e));
  }

  /**
   * dev.58 W1: リンククリックの委譲ハンドラ (setupEventListeners() から
   * document に1本だけ登録)。チャット本文中のリンク([data-cta] 含む、
   * markdown-lite.js が生成する通常リンクも同様)をここで一元判定する。
   *
   * 規則(詳細は各分岐のコメント参照):
   *   - defaultPrevented / 修飾キー / 中クリックは無視(ブラウザ標準動作に委ねる)
   *   - href が無い/`#`始まりのアンカーは無視(ページ内アンカー等)
   *   - [data-cta] は origin/protocol を問わず cta_click を計測(dev.58 レビュー
   *     修正S2。旧・個別リスナー実装の無条件送信を維持する)
   *   - http(s) 以外 (tel:/mailto: 等) は無視(cta_click 計測は上記の通り送信済み)
   *   - 同一オリジン → preventDefault + 履歴保存 + 親へ navigate 委譲
   *   - 外部 (target="_blank" 済み) → 無視(ブラウザ標準の別タブに任せる)
   *   - 外部 (target 無し) → preventDefault + window.open(..., "_blank", "noopener")
   *
   * 親 widget-loader.js は変更しない — navigate メッセージの受信側は
   * 既に同一オリジン限定のブロックを持っており(Issue #208)、ここでの
   * origin 判定が仮に破られても二重に防御される。
   */
  _handleDelegatedLinkClick(e) {
    // (6) 二重発火防止: 他のハンドラが既に preventDefault 済みなら何もしない。
    if (e.defaultPrevented) return;
    // 修飾キー付きクリック(新規タブ/新規ウィンドウ意図)・中クリックはブラウザの
    // 標準動作に委ねる。
    if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return;

    const anchor = e.target?.closest?.("a[href]");
    if (!anchor) return;

    const raw = anchor.getAttribute("href");
    if (!raw || raw.startsWith("#")) return; // ページ内アンカー等は無視

    let url;
    try {
      url = new URL(raw, location.href);
    } catch {
      return; // 不正な URL は無視
    }

    // dev.58 レビュー修正S2: cta_click 計測は origin/protocol を問わず送る。
    // 旧・個別リスナー実装(dev.57 以前)は href があれば無条件で送信していた
    // ため、tel:/mailto: や外部リンクの CTA でも計測が退行しないよう、
    // origin 分岐/protocol フィルタより前に置く(# や href 無しの早期
    // return はそれらより前のまま維持する)。
    if (anchor.hasAttribute("data-cta")) {
      this._sendWidgetEvent('cta_click', { label: anchor.textContent, url: url.href });
    }

    // http(s) 以外 (tel:/mailto: 等) は何もしない。navigate メッセージへ変換
    // すると親側 (widget-loader.js:1086) の protocol 検査で握り潰され、
    // クリックしても何も起きないデッドクリックになってしまうため。
    if (url.protocol !== "http:" && url.protocol !== "https:") return;

    // 内外判定は必ず URL オブジェクトの origin 比較で行う。先頭 "/" の有無で
    // 判定すると "//evil.example/..." (プロトコル相対 URL) を内部リンク扱い
    // してしまう。
    if (url.origin === location.origin) {
      e.preventDefault();
      this._saveSessionNow();
      this._postToParent({ type: "navigate", url: url.href, title: anchor.textContent });
      return;
    }

    // 外部リンク。target="_blank" 済みならブラウザ標準の別タブオープンに
    // 任せて何もしない(preventDefault しない)。
    if (anchor.getAttribute("target") === "_blank") return;

    e.preventDefault();
    window.open(url.href, "_blank", "noopener");
  }

  /**
   * ページ離脱時のクリーンアップハンドラ
   */
  setupUnloadHandlers() {
    const handleUnload = () => this.cleanupOnUnload();
    window.addEventListener("pagehide", handleUnload);
    window.addEventListener("beforeunload", handleUnload);
  }

  /**
   * dev.61: タブ非表示時の音声ブロック。document(iframe 内)の visibilitychange
   * を監視する — 親 widget-loader.js には触らない。
   */
  setupVisibilityHandlers() {
    if (typeof document === "undefined" || typeof document.addEventListener !== "function") return;
    document.addEventListener("visibilitychange", () => this._handleVisibilityChange());
  }

  /** 可視性の読み取りとハンドラ本体を分離する (テストから直接叩けるように)。 */
  _handleVisibilityChange() {
    const hidden = document?.hidden === true || document?.visibilityState === "hidden";
    if (hidden === this._backgroundHidden) return; // 冪等
    this._backgroundHidden = hidden;
    if (hidden) this._onTabHidden();
    else this._onTabVisible();
  }

  _onTabHidden() {
    if (this.mode !== "realtime") return; // テキストモード対象外
    // dev.56 A-2 と同じ二層構造の「解除」層: hidden 中に自動再接続で新しい課金セッションを張らせない。
    this._clearReconnectTimer();
    // レビュー F1(a) (dev.61 blocker): connector.getIsConnected() での門を
    // 置かない。getIsConnected() は connect() でしか true にならない
    // 一方通行ラッチで、ICE 復帰後 ("connected" に戻っても) false のまま
    // ("状態判定に使うな" — dev.57 で確定済み)。この門があると in-flight
    // 接続中 (mode は先に "realtime" になるが isConnected はまだ false) や
    // ICE 復帰後の hidden で Stage1/2 が一切効かず、hidden 中に完走した
    // 接続が素のホットマイクで立ってしまう (レビュー実測)。無条件化しても
    // in-flight 接続は connector.getMediaStream() の choke point で
    // ミュートのまま生まれるので安全。connector が無い (未接続開始前) 場合
    // は setBackgroundMuted 自体が optional chaining で no-op になる。
    // Stage 1: 即時ブロック。デバウンスしない — 誤発火のコストは visible で即復元だけ。
    this.connector?.setBackgroundMuted?.(true, {
      userMicMuted: this.isMicMuted,
      userRemoteMuted: this.isSpeakerMuted,
    });
    this._startBackgroundSuspendTimer();
  }

  /** 契約 (dev.61): 昇格タイマーの開始 — 解除は必ず _clearBackgroundSuspendTimer() で。 */
  _startBackgroundSuspendTimer() {
    this._clearBackgroundSuspendTimer();
    this._backgroundSuspendTimeoutId = setTimeout(() => {
      this._backgroundSuspendTimeoutId = null;
      // F3(b) 流儀の stale 発火ガード: 発火時点の実状態を必ず再検査。
      // レビュー F1(b) (dev.61 blocker): connector.getIsConnected() の条件は
      // 置かない (一方通行ラッチ — dev.57 で「状態判定に使うな」と確定済み)。
      // switchToTextMode() 自身が確立済み接続(suspend分岐)と in-flight 接続
      // (全破棄分岐)の両方を安全に処理するため、ここでラッチに頼る必要が
      // ない。hidden 20秒経過時点で in-flight のままなら全破棄になるのが
      // 正しい挙動。
      if (!this._backgroundHidden) return;
      if (document?.hidden !== true) return;
      if (this.mode !== "realtime") return;
      if (this.connector?.isSuspended?.()) return;
      if (this._resumeInProgress) return;
      console.log("[PersistentWidget] tab hidden too long — suspending the voice session");
      this.switchToTextMode({
        reason: t("reason_tab_hidden", "This tab moved to the background, so the voice session was ended. "),
      });
    }, BACKGROUND_VOICE_SUSPEND_MS);
  }

  _clearBackgroundSuspendTimer() {
    if (this._backgroundSuspendTimeoutId) {
      clearTimeout(this._backgroundSuspendTimeoutId);
      this._backgroundSuspendTimeoutId = null;
    }
  }

  _onTabVisible() {
    this._clearBackgroundSuspendTimer();
    // Stage 1 の解除。無条件・冪等(mediaStream null なら no-op)。
    this.connector?.setBackgroundMuted?.(false, {
      userMicMuted: this.isMicMuted,
      userRemoteMuted: this.isSpeakerMuted,
    });
    // hidden 中に回線が切れ自動再接続を断ったケース → 既存の終端 UI を流用(新規 msgid ゼロ)。
    // レビュー F1(c) (dev.61 blocker): connector.getIsConnected() は使わない
    // (一方通行ラッチ — dev.57 で「状態判定に使うな」と確定済み)。ICE 復帰
    // 後は接続が生きていても false のままなので、これで判定すると復帰済み
    // セッションまで誤って終端 UI に落としてしまう。「実際に死んでいるか」を
    // RTCPeerConnection.connectionState で直接判定する: in-flight
    // ('new'/'connecting') や ICE 復帰済み ('connected') では発火させず、
    // hidden 中に本当に死んだ (pc が無い / 'failed' / 'closed') 場合のみ
    // 終端 UI を出す。suspend 済み(既存マイクボタンが復帰導線)は対象外。
    const pc = this.connector?.peerConnection;
    const pcDead = !pc || pc.connectionState === "failed" || pc.connectionState === "closed";
    if (this.mode === "realtime" && !this._resumeInProgress &&
        !(this.connector?.isSuspended?.() === true) && pcDead) {
      this._showReconnectFailedUI(new Error("tab_hidden_disconnect"));
      return;
    }
    // 昇格済み(suspend 到達)は自動 resume しない(判断2)。既存マイクボタンが復帰導線。
  }

  /**
   * Issue #280: モバイル ソフトキーボード表示時にウィジェット下端 (入力欄) が
   * 隠れる問題への対応。visualViewport.height が縮んだ分だけ
   * `--agent-keyboard-offset` を立てて .agent のレンダリング高さを縮める。
   *
   * - visualViewport 未対応 (古いブラウザ / iframe 親に遮られた等) なら no-op。
   *   100dvh fallback でほとんどの最新ブラウザは挙動を満たすため安全。
   * - rAF で coalesce する。スクロール中に大量に発火しても 1 frame 1 回。
   * - 値は 0px / 整数 px に正規化して px ジャンプを避ける。
   * - prefers-reduced-motion ユーザーには transition なしで即時反映 (CSS 側
   *   .agent には transition を載せていないため、デフォルトで motion-free)。
   */
  setupVisualViewport() {
    const vv = window.visualViewport;
    if (!vv) return;

    // 多重呼び出し対策: 既存 listener があれば先に解除してから再登録する。
    // 現状 init() から 1 度だけ呼ばれるが、将来リセット処理で 2 度呼び出された時
    // の listener leak を防ぐ (AI Review #550 round5 low #2)。
    this._cleanupVisualViewport?.();

    const root = document.documentElement;
    let rafId = 0;

    const apply = () => {
      rafId = 0;
      // ピンチズーム中は vv.height がスケールに応じて縮むだけで、キーボードは
      // 出ていない。誤って正の offset を立ててウィジェットを縮めないよう、
      // scale が概ね 1.0 のときのみ keyboard offset として解釈する。
      // (AI Review #550 round7 low #1)
      if (Math.abs((vv.scale ?? 1) - 1) > 0.05) {
        root.style.setProperty("--agent-keyboard-offset", "0px");
        return;
      }
      // 「window.innerHeight − visualViewport の見える領域」をキーボード高として算出。
      // offsetTop は iOS で viewport が上方スクロールされた分なので加味する。
      const occluded = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
      // ノイズ的な 1px 揺れで再描画を起こさない。8px 未満は 0 扱い。
      const offset = occluded < 8 ? 0 : occluded;
      root.style.setProperty("--agent-keyboard-offset", `${offset}px`);
    };

    const schedule = () => {
      if (rafId) return;
      rafId = requestAnimationFrame(apply);
    };

    vv.addEventListener("resize", schedule);
    // `scroll` は iOS Safari 特有: ソフトキーボード出現時に visualViewport が
    // 上方向にスクロールして `offsetTop` が発生する挙動を拾うため。
    // Android Chrome では offsetTop が 0 のまま resize のみ発火するが、
    // 無害な空 schedule が走るだけなので両プラットフォーム同時に購読する。
    // (AI Review #550 round6 low #2 への可読性対応)
    vv.addEventListener("scroll", schedule);
    // 初回反映
    apply();

    // cleanupOnUnload で外せるように保持
    this._cleanupVisualViewport = () => {
      vv.removeEventListener("resize", schedule);
      vv.removeEventListener("scroll", schedule);
      if (rafId) cancelAnimationFrame(rafId);
      root.style.removeProperty("--agent-keyboard-offset");
    };
  }

  /**
   * postMessage ハンドラ（親ウィンドウとの通信）
   */
  setupPostMessageHandler() {
    window.addEventListener("message", (event) => {
      if (!this._isAllowedOrigin(event.origin)) {
        return;
      }

      const { type, data } = event.data || {};

      switch (type) {
        case "page-navigation":
          this.currentPageContext = data;
          this.toolHandler.setPageContext(data);
          this.textChat.updatePageContext(data);
          this.analytics.trackNavigation(data?.url, data?.title);

          handlePageNavigation(data, {
            onContextUpdate: ({ label }) => {
              console.log(`[Widget] Page navigation: ${label}`);
            },
            onQuickActionsUpdate: (actions) => {
              this.updateQuickActions(actions);
            },
            onWelcomeUpdate: (welcome) => {
              this.updateWelcomeForPage(welcome);
            },
          });

          // NS では instructions はサーバー側 (/realtime-token) で確定済みのため、
          // ページ遷移時の再取得・session.update による instructions 上書きは行わない
          // (session.update に instructions/voice/turn_detection/transcription を
          // 含めてはならない — §3.2 の重要な整合点)。
          break;

        case "set-user-id":
          if (data?.userId) {
            UserIdManager.setUserId(data.userId);
            this.userId = data.userId;
            this.textChat.setContext({ userId: data.userId });
          }
          break;

        case "theme-config":
          this._applyThemeConfig(data);
          break;

        case "auto-connect":
          // ランチャーのauto_connect設定が有効: エフェメラルトークン事前取得とマイクウォームアップを実行
          prefetchEphemeralTokenUtil(
            (msg) => console.log(`[PersistentWidget] auto-connect: ${msg}`),
            { userId: this.userId, pageContext: this.currentPageContext }
          ).catch((e) => console.warn("[PersistentWidget] auto-connect prefetch failed:", e.message));
          break;

        case "switch-view":
          // 親ウィンドウからのビュー切替指示（ページ遷移後の復元等）
          if (data?.view === "chat" && this.textChat?.messages?.length > 0) {
            this.showChat();
          }
          break;
      }
    });
  }

  /**
   * セッション永続化リスナーを登録
   */
  setupSessionPersistence() {
    this._cleanupPersistence = registerPersistenceListeners(() => this._buildSessionState());
  }

  /**
   * 保存用のセッション状態を構築する (0.3.0-dev.47: pagehide リスナーと
   * _saveSessionNow() の共通化)。会話が無ければ null。
   */
  _buildSessionState() {
    if (!this.textChat || this.textChat.messages.length === 0) {
      return null;
    }
    // _reserveRealtimeUserSlot() が commit 時点で積む placeholder
    // ({ content: "" }) は、文字起こし確定 (_resolveRealtimeUserSlot) 前に
    // 保存が走ると中身が空のまま残る。これをそのまま保存すると
    // tryRestoreSession() が _createMessageElement(msg.content, role) で
    // 空バブルを描画してしまう (F6 で潰した症状の別経路からの再発)ため、
    // 保存直前に実質空のエントリを除外する。
    const messages = this.textChat
      .getHistory()
      .filter((msg) => typeof msg.content === "string" && msg.content.trim() !== "");
    if (messages.length === 0) {
      return null;
    }
    return {
      messages,
      mode: this.mode,
      isExpanded: true,
      pageContext: this.currentPageContext,
    };
  }

  /**
   * セッションを今すぐ保存する (0.3.0-dev.47)。
   * AI 主導のページ遷移は自分でタイミングを知っているので、pagehide の
   * 発火タイミングに賭けず、遷移指示を親へ送る直前に明示的に保存する。
   */
  _saveSessionNow() {
    const state = this._buildSessionState();
    if (state) {
      saveSession(state);
    }
  }

  /**
   * セッション復元を試みる
   * 注: pjax 遷移後に音声モードだった場合はテキストモードにフォールバック
   */
  tryRestoreSession() {
    const session = restoreSession();
    if (!session || session.messages.length === 0) return;

    console.log(
      `[Widget] Restoring session: ${session.messages.length} messages`
    );

    // 会話履歴をDOMに復元（ビューはホームのまま維持し、チャットタブで確認可能）
    this.textChat.restoreHistory(session.messages);

    // メッセージを画面に表示（復元メッセージはタイムスタンプなし）
    for (const msg of session.messages) {
      const role = msg.role === "user" ? "user" : "bot";
      const el = this._createMessageElement(msg.content, role);
      this.el.chatBody.appendChild(el);
    }
    this._updateBubbleGrouping();

    // 復元済みセッションをクリア（二重復元防止）
    clearSession();

    // 音声モードで保存されていた場合はテキストモードにフォールバック
    // WebRTC接続はpjax遷移で失われるため
    if (session.mode === "realtime") {
      this.mode = "text";
      this.el.statusText.textContent = t("status_text_mode", "Text mode");
    }
  }

  /**
   * クイックアクションボタンを更新（page-navigation 経路）
   *
   * EJS 初期描画は常に `agent--hidden` で隠されている。
   * 本メソッドは page-navigation を受けて初めて hidden を解除する責務も持つ。
   *
   * 動作:
   *   - 会話中: 何もしない (チャット表示中は非表示維持)
   *   - actions 非空: innerHTML を page-specific アクションで上書き → 表示
   *   - actions 空 (page-context にページ別設定なし) + EJS スナップショット有:
   *     スナップショット (= server-side global default) に復元して表示
   *   - actions 空 + スナップショット無し (空 HTML): hidden 維持 (誤動作回避)
   *
   * スナップショット復元方式の理由:
   *   page-specific を JS で innerHTML 上書きした後、別ページに遷移して page-specific
   *   が空の場合、children.length > 0 で判定すると "前ページの JS 注入ボタン" が残留
   *   表示されてしまう。EJS の初期 HTML を constructor で保存しておき、それに復元する
   *   ことで「常に正しい global default に戻る」を保証する。
   */
  updateQuickActions(actions) {
    if (!this.el.quickActions) return;

    // 会話中はクイックアクションを表示しない
    if (this.textChat && this.textChat.messages.length > 0) return;

    if (actions && actions.length > 0) {
      // page-specific アクションで上書き
      const qaConfig = this.cfg.quick_actions || {};
      const showIcons = qaConfig.show_icons !== false;
      const maxVisible = qaConfig.max_visible || actions.length;

      this.el.quickActions.innerHTML = "";
      const visibleActions = actions.slice(0, maxVisible);
      for (const action of visibleActions) {
        const btn = document.createElement("button");
        btn.className = "agent__quick-btn";
        btn.dataset.action = action.action;
        if (showIcons && action.icon) {
          btn.innerHTML = `<span class="agent__quick-icon"><i class="bi bi-${this.escapeHtml(action.icon)}"></i></span><span>${this.escapeHtml(action.text)}</span>`;
        } else {
          btn.textContent = action.text;
        }
        this.el.quickActions.appendChild(btn);
      }
      this.el.quickActions.classList.remove("agent--hidden");
    } else if (this._initialQuickActionsHTML && this._initialQuickActionsHTML.trim().length > 0) {
      // page-specific 空 + server-side global default のスナップショット有
      // → 前ページの JS 注入を捨てて global default に復元
      this.el.quickActions.innerHTML = this._initialQuickActionsHTML;
      this.el.quickActions.classList.remove("agent--hidden");
    } else {
      // page-specific も server-side global default も空: 表示するアクションが無い
      this.el.quickActions.classList.add("agent--hidden");
    }
  }

  // ============================================
  // メッセージ送信（モード対応）
  // ============================================

  // value="" は input イベントを発火しないので auto-resize 高さを明示リセットする
  _clearChatInput() {
    if (!this.el.chatInput) return;
    this.el.chatInput.value = "";
    this.el.chatInput.style.height = "auto";
    if (this.el.btnSend) {
      this.el.btnSend.disabled = true;
    }
  }

  /**
   * メッセージ送信（テキスト/音声モード対応）
   */
  async handleSend() {
    const text = this.el.chatInput.value.trim();
    if (!text) return;

    // Issue #278: 直前のユーザー入力を保存し、エラー時の再試行で復元できるようにする
    this._lastUserText = text;

    this._sendWidgetEvent('message_sent', { message_length: text.length });

    // 音声モード: DataChannel 経由で送信
    if (this.mode === "realtime" && this.connector?.getIsConnected()) {
      this._clearChatInput();
      this.showChat();

      // ユーザーメッセージを表示
      this.addMessage(text, "user");

      this.analytics.recordMessage({ role: "user", content: text, mode: "realtime" });

      // sendTextMessage → CONVERSATION_ITEM_ADDED → onTextItemCreated で _sentItemIds に自動登録
      const sent = await this.connector.sendTextMessage(text, { skipTranscript: true });

      // F5: DataChannel が open でない一瞬の窓等で送信が失敗した場合、届いて
      // いないメッセージを履歴・保存対象・/chat コンテキストに混入させない。
      // sendTextMessage の成否 (boolean) を確認してからのみ記録する。
      if (sent) {
        // dev.73 バージイン: ユーザーが新しい質問を投げた時点で、旧応答由来の
        // AI主導ページ遷移予約は放棄する (disconnectRealtime F1 / suspend F5 と
        // 同じ方針)。connector 側は音声バッファも clear 済みで stopped が届かない
        // ことがあるため、これを怠ると 12s フェイルセーフだけが生き残り、
        // 新しい会話の最中に無関係な画面へ強制遷移する。
        this._clearPendingNavigation();
        // 音声モード会話履歴に追加 (0.3.0-dev.46: textChat.messages にも記録し保存対象へ)
        this._recordRealtimeMessage("user", text);

        if (this.toolHandler) {
          this.toolHandler.addConversationContext("user", text);
        }
      } else {
        // F5 により吹き出しは表示されたまま履歴には記録されないため、利用者に
        // 送信失敗を伝える。テキストモードの送信失敗 (handleError) と同じ文言・
        // 導線 (共通エラーバブル) を再利用する。吹き出し自体は消さない
        // (text-chat-controller.js の失敗時挙動と同様、履歴からのみ除外する)。
        this.renderErrorBubble({
          title: t("err_stream_title", "A connection problem occurred"),
          body: t("err_stream_body", "The message could not be sent. Please wait a moment and try again."),
          retryHandler: this._lastUserText ? () => this.handleSend() : null,
          lastUserText: this._lastUserText,
        });
        this.analytics.trackError("realtime_text_send_failed", "realtime_send");
      }
      return;
    }

    // テキストモード: SSE 経由
    if (this.textChat.isStreaming) return;

    this.showChat();
    this.addMessage(text, "user");
    this._clearChatInput();

    this.analytics.recordMessage({ role: "user", content: text, mode: this.mode });

    this._lastUserInput = text;

    // 当ページセッションのメッセージとして記録（session_end enqueue 用）
    this._pageSessionMessages.push({ role: "user", content: text });

    // Send button success animation
    this.el.btnSend.classList.add("agent__send-btn--sent");
    this.el.btnSend.addEventListener("animationend", () => {
      this.el.btnSend.classList.remove("agent__send-btn--sent");
    }, { once: true });

    await this.textChat.sendMessage(text);
  }

  // ============================================
  // SSE コールバック（テキストモード）
  // ============================================

  handleStreamStart() {
    this._currentBotBubble = null;
    this._currentBotText = "";
    this.showStreamingIndicator();
  }

  handleTextChunk(content) {
    if (!this._currentBotBubble) {
      this.removeStreamingIndicator();
      this._currentBotBubble = this.addMessage("", "bot");
    }

    this._currentBotText += content;
    const bubble = this._currentBotBubble.querySelector(".agent__bubble");
    if (bubble) {
      // .agent__message--bot .agent__bubble は white-space: pre-wrap なので、
      // LLM delta の先頭に含まれることがある " " / "\n" がそのまま視認化されてしまう
      // (Mac Chrome では system フォントの幅で目立ちやすい)。
      // 確定時 (handleTextDone) は renderMarkdownLite が line.trim() するので
      // 整合性のために streaming 中も leading whitespace を除く。
      bubble.textContent = this._currentBotText.replace(/^[\s ]+/, "");
    }
    this.scrollToBottom();
  }

  handleTextDone(fullText, doneData = null) {
    this.removeStreamingIndicator();

    // text chunk なしで done が来た時に bubble が無く UI 描画が silent miss するのを防ぐ
    if (!this._currentBotBubble && fullText) {
      console.warn(
        "[PersistentWidget] handleTextDone: no streaming bubble — fullText arrived at done without any text chunk, so a new bubble is created",
      );
      this._currentBotBubble = this.addMessage("", "bot");
    }

    if (this._currentBotBubble && fullText) {
      const bubble = this._currentBotBubble.querySelector(".agent__bubble");
      if (bubble) {
        const ctaEnabled = this.cfg.cta?.in_response?.enabled !== false;
        const html = renderMarkdownLite(fullText, { enableCta: ctaEnabled });
        bubble.innerHTML = html;

        // 2026-08 撤去記録: ここにあった [data-cta] 個別クリックリスナー
        // (bubble.querySelectorAll("[data-cta]").forEach(...)) は dev.58 で
        // document 委譲の _handleDelegatedLinkClick() (setupEventListeners()
        // 参照) に統合され撤去した。cfg.cta.in_response.enabled による
        // 描画抑制(上の renderMarkdownLite 呼び出し)は維持している。
      }
    }

    // ストリーミング完了時刻でタイムスタンプを更新
    if (this._currentBotBubble) {
      this._stampTimestamp(this._currentBotBubble);
    }

    this._currentBotBubble = null;
    this._currentBotText = "";

    if (fullText) {
      // 当ページセッションのメッセージとして記録（session_end enqueue 用）
      if (this.mode === "text") {
        this._pageSessionMessages.push({ role: "assistant", content: fullText });
      }

      this.analytics.recordMessage({ role: "assistant", content: fullText, mode: this.mode });
    }
  }

  handleAction(action) {
    switch (action.type) {
      case "navigate":
        this.addNavigationCard(action.title, action.url);
        this._sendWidgetEvent('cta_click', { label: action.title, url: action.url });

        // dev.56 A-3(c): suspend 中(mode==="text" だが接続は裏で生きている)は
        // 契約 B-1/F5 と同じ規約(「音声を離れる = AI 主導の遷移予約を放棄する」)。
        // suspend 中は利用者はテキスト画面にいる。音声ターン由来の遷移は自動実行
        // せず、ナビゲーションカードの明示クリックに委ねる。prefill は対象外
        // (意図的 — フォーム自動入力は自動遷移ではないため無関係)。
        //
        // 注意: handleAction は音声ツール経路(toolHandler.onAction)とテキスト
        // SSE 経路(textChat.onAction)の共用ハンドラであり、このガードは
        // action の発生源(音声由来かテキスト由来か)を区別しない。現在
        // /chat エンドポイントは action フレームを出力しないため、このガードが
        // テキスト由来の navigate を止めることは実際には起きない(latent)。
        // ただし将来 /chat が action を出力するようになった場合、suspend の
        // 60秒窓内で行われたテキスト会話由来の navigate もここで抑制される
        // ことになる点に留意すること。
        if (this.connector?.isSuspended?.()) {
          console.log("[PersistentWidget] navigate action while suspended — card only, no auto-navigation");
          break;
        }

        // 0.3.0-dev.47: 音声モード中は「AI が案内を言い終えてから」遷移する。
        // dev.46 の固定 2.5s は応答の長さに追従できず、再生の途中で iframe ごと
        // 破棄されていた。output_audio_buffer.stopped (再生バッファ枯渇) を
        // トリガーにし、遷移直前に _saveSessionNow() で履歴を明示保存する。
        if (this.mode === "realtime") {
          this._queueRealtimeNavigation(action.url, action.title);
        } else {
          this._saveSessionNow();
          this._postToParent({
            type: "navigate",
            url: action.url,
            title: action.title,
          });
        }
        break;

      case "prefill": {
        // Issue #277: フォーム自動入力時に
        //  - 親ウィンドウに prefill-contact + version=2 を送る (既存互換: data 形式は維持)
        //  - ハイライトとスクロール指示を一緒に送る
        //  - widget 側にも確認バナー (assistant message) を追加
        const fields = (action.data && typeof action.data === 'object')
          ? Object.keys(action.data).filter((k) => action.data[k] != null && action.data[k] !== '')
          : [];
        this._postToParent({
          type: "prefill-contact",
          version: 2, // Issue #304 の compat matrix に従う protocol バージョン
          data: action.data,
          ui: {
            highlight: true,
            scroll: true,
            highlightDurationMs: 4000,
          },
        });
        // ウィジェット内にも確認バナーを表示
        this.addMessage(
          fields.length
            ? t(
                "msg_contact_prefilled",
                "Prefilled {n} fields in the contact form.\nPlease review the content and tap the send button (sending is manual)."
              ).replace("{n}", fields.length)
            : t("msg_contact_navigated", "Moved to the contact form. Please review the content and send it."),
          'assistant'
        );
        this._sendWidgetEvent('contact_intent', { source: 'prefill', field_count: fields.length });
        break;
      }

      case "rich-card":
        this.addRichCard({
          type: action.card_type || "link",
          title: action.title,
          description: action.description,
          imageUrl: action.image_url,
          label: action.label,
          ctaText: action.cta_text,
          url: action.url,
        });
        break;

      case "cta":
        this.addCtaCard({
          text: action.text,
          url: action.url,
          icon: action.icon,
          subtitle: action.subtitle,
        });
        break;

      case "follow-up":
        if (action.actions) {
          this.showFollowUpActions(action.actions);
        }
        break;

    }
  }

  handleError(error) {
    this.removeStreamingIndicator();
    // Issue #286 / #278: ユーザー向け文言 + 再試行 + 問い合わせ導線
    // 内部エラー詳細 (error.stack 等) は UI に出さず analytics 側に記録
    this.renderErrorBubble({
      title: t("err_stream_title", "A connection problem occurred"),
      body: t("err_stream_body", "The message could not be sent. Please wait a moment and try again."),
      retryHandler: this._lastUserText ? () => this.handleSend() : null,
      lastUserText: this._lastUserText,
    });
    this.analytics.trackError(String(error), "stream");
    this._sendWidgetEvent('error', { error_type: 'stream' });
  }

  handleStreamEnd() {
    const endConfig = this.cfg.cta?.end_of_conversation;
    if (endConfig?.enabled && endConfig.actions?.length > 0) {
      const delay = endConfig.delay_ms || 1000;
      setTimeout(() => {
        this.showFollowUpActions(endConfig.actions);
      }, delay);
    }
  }

  // ============================================
  // Realtime コールバック（音声モード）
  // ============================================

  /**
   * WebRTC 接続状態変更
   */
  handleConnectionStateChange(state) {
    console.log(`[PersistentWidget] Connection state: ${state}`);

    switch (state) {
      case "connected":
        this.el.statusDot.classList.remove("agent__status-dot--connecting");
        this.el.statusDot.classList.add("agent__status-dot--connected");
        this.el.statusText.textContent = t("status_voice_connecting", "Connecting voice");
        this._updateMicDeviceName();
        // 自動再接続成功 (Issue #193 / G-013): 試行中だった場合のみ analytics + カウンタリセット
        if (this._reconnectAttempt > 0) {
          const attempts = this._reconnectAttempt;
          this._reconnectAttempt = 0;
          this._isAutoReconnecting = false;
          if (this._reconnectTimeoutId) {
            clearTimeout(this._reconnectTimeoutId);
            this._reconnectTimeoutId = null;
          }
          this.addMessage(t("msg_reconnect_success", "Reconnected (recovered on attempt {n})").replace("{n}", attempts), "system");
          this.analytics.trackEvent?.("reconnect_success_after_retries", { attempts });
          this._sendWidgetEvent("reconnect_success", { attempts });
        }
        break;
      case "connecting":
        this.el.statusDot.classList.add("agent__status-dot--connecting");
        this.el.statusText.textContent = t("status_connecting", "Connecting...");
        break;
      case "disconnected":
      case "failed":
      case "closed":
        // セッション切断時のクリーンアップ
        this.cleanupRealtimeSession();
        break;
    }
  }

  /**
   * Realtime セッション作成完了
   */
  handleSessionCreated() {
    // iOS Safari: 接続完了後にミュート状態を同期
    if (isIOSSafari() && this.isSpeakerMuted) {
      if (this.connector && typeof this.connector.setRemoteAudioMuted === "function") {
        this.connector.setRemoteAudioMuted(true);
        console.log("[PersistentWidget][iOS] syncing mute state after session creation");
      }
    }

  }

  /**
   * Realtime テキスト応答（ストリーミング対応）
   * isPartial=true: デルタ受信中 → ストリーミング表示を更新
   * isPartial=false: 確定 → ストリーミング確定
   */
  handleRealtimeTextResponse(text, isPartial, messageId, metadata = null) {
    // ストリーミング中（デルタ）
    if (isPartial) {
      // Performance計測: 最初のWebRTCテキストdelta受信 = Greeting Text
      if (this._isGreetingRequest && !this._greetingTimingLogged) {
        this._greetingTimingLogged = true;
        const elapsed = getElapsedSinceConnectionStart();
        sessionLogger.logEvent('greeting_displayed', elapsed).catch(() => {});
        // 標準 realtime 音声: delta受信 ≒ 音声ストリーミング開始
        sessionLogger.logEvent('native_audio_started', elapsed).catch(() => {});
      }
      if (messageId) {
        this.removeStreamingIndicator();
        this.updateOrCreateStreamingMessage(text, "bot", messageId);
      }
      return;
    }

    // Performance計測: 挨拶フラグクリア。F8: クリア前に確定値を保持し、
    // このレスポンスが挨拶だったかどうかを後段の textChat.messages 記録判定に使う。
    const wasGreetingRequest = this._isGreetingRequest;
    if (this._isGreetingRequest) {
      this._isGreetingRequest = false;
    }

    // 確定時: 重複排除。Map leak 防止のため early return 前に必ず stale エントリを掃除する。
    if (messageId && this.displayedMessageIds.has(messageId)) {
      if (this._streamingElements && this._streamingElements.has(messageId)) {
        this._streamingElements.delete(messageId);
      }
      // PR #601 v2 AI Review (medium): RESPONSE_DONE 先発火 → 遅延 TEXT_DONE シナリオで、
      // RESPONSE_DONE 経由の finalize ディスパッチは delta 累積テキストで bubble を確定するが、
      // 遅延 TEXT_DONE が event.text (サーバー確定テキスト) を持って到着した場合は
      // 上書きが必要 (例: サーバー側の punctuation 訂正等で delta 累積と確定 text が異なるケース)。
      // dedupe で早期 return する前に、dataset.messageId で bubble を再特定して text を上書きする。
      if (text) {
        const existingMsgEl = this.el.chatBody.querySelector(
          `[data-message-id="${CSS.escape(messageId)}"]`,
        );
        const existingBubble = existingMsgEl?.querySelector(".agent__bubble");
        if (existingBubble && existingBubble.textContent !== text) {
          existingBubble.textContent = text;
        }
      }
      return;
    }

    if (messageId) {
      this.addDisplayedMessageId(messageId);
    }

    // ストリーミング表示を確定（既にストリーミング中ならそれを確定、なければ新規追加）
    if (this._streamingElements && this._streamingElements.has(messageId)) {
      this.finalizeStreamingMessage(messageId, text);
    } else if (text) {
      // PR #601: 既存 streaming bubble が無い + text が非空 のときのみ新規 bubble を作る。
      // text が空のときは connector 側の finalize ディスパッチ (Item 切替時 / RESPONSE_DONE
      // フェイルセーフ) で「未追跡 messageId + 空 text」が発火するケースが含まれ、
      // 旧コードは空 bubble を生成していた。
      this.removeStreamingIndicator();
      this.addMessage(text, "bot");
    }

    // 記録（会話は sessionStorage 経由の session-persistence.js のみで保持する。
    // サーバー側への会話ログ永続化は行わない — D3/D4）
    if (text) {
      // 音声モード会話履歴に追加（モード切替時の要約生成用 + 保存対象）。
      // F8: 質問を伴わない「素の」自動挨拶のみ textChat.messages への記録を
      // skip する (_realtimeMessages には常に残すので要約生成には影響しない)。
      // 挨拶のみで実質的な対話が無いセッションが「会話あり」と誤判定され、
      // 保存・復元されたり初回案内チップが非表示になったりするのを防ぐ。
      // クイックアクション由来の質問に答える挨拶は実質的な対話として通常どおり記録する。
      this._recordRealtimeMessage("assistant", text, {
        skipTextChat: wasGreetingRequest && !this._greetingHadPendingQuestion,
      });
      this.currentToolCalls = [];
    }

    this.analytics.recordMessage({ role: "assistant", content: text, mode: "realtime" });

    if (this.toolHandler && text) {
      this.toolHandler.addConversationContext("assistant", text);
    }
  }

  /**
   * ユーザー音声トランスクリプト（確定）
   */
  handleTranscript(text, role, itemId) {
    if (role !== "user") return;

    // テキスト送信で作成済みのitem_idならサーバーechoとしてスキップ
    if (itemId && this._sentItemIds.has(itemId)) {
      this._sentItemIds.delete(itemId);
      return;
    }

    // F6: 空白のみの文字起こし (Whisper の改行のみ等) はバブルも履歴も残さない。
    // commit 時に予約した placeholder (F2) があれば撤去する。
    const trimmedText = typeof text === "string" ? text.trim() : "";
    if (!trimmedText) {
      if (itemId && this._streamingElements && this._streamingElements.has(itemId)) {
        const el = this._streamingElements.get(itemId);
        this._streamingElements.delete(itemId);
        el?.remove();
        this._updateBubbleGrouping();
      }
      this._discardRealtimeUserSlot(itemId);
      return;
    }

    // ストリーミング表示を確定 (0.3.0-dev.46: placeholder / delta が一度も
    // 届いていなかった場合はここで新規作成してから確定する — 従来この経路では
    // バブルが一切表示されなかった)
    if (itemId && !(this._streamingElements && this._streamingElements.has(itemId))) {
      this.updateOrCreateStreamingMessage(text, "user", itemId);
    }
    this.finalizeStreamingMessage(itemId, text);

    // 音声モード会話履歴に追加（モード切替時の要約生成用 + 保存対象）。
    // F2: commit 時点で予約した placeholder (_reserveRealtimeUserSlot) を確定
    // 内容で埋める。配列内の位置は commit 順のまま保たれるため、AI 応答確定が
    // 文字起こし確定より先に届いても履歴の並び順は発話順を維持する。
    this._resolveRealtimeUserSlot(itemId, text);

    this.analytics.recordMessage({ role: "user", content: text, mode: "realtime" });

    this._lastUserInput = text;

    if (this.toolHandler && text) {
      this.toolHandler.addConversationContext("user", text);
    }
  }

  /**
   * ユーザー音声トランスクリプト（デルタ — リアルタイム表示）
   */
  handleTranscriptDelta(text, itemId, role) {
    if (role !== "user") return;

    // テキスト送信で作成済みのitem_idならechoデルタとしてスキップ
    if (itemId && this._sentItemIds.has(itemId)) return;

    this.updateOrCreateStreamingMessage(text, "user", itemId);
  }

  /**
   * AI 主導のページ遷移を「発話再生完了後」まで保留する (0.3.0-dev.47)。
   *
   * 流れ:
   *   navigate_page ツール実行 → ここで保留登録
   *   → (ツール結果を受けて AI が案内文を発話し始める → output_audio_buffer.started
   *      が grace タイマーをキャンセルして再生完了を待ち直す)
   *   → output_audio_buffer.stopped (再生バッファ枯渇 = 言い終えた)
   *   → 2s の間を置いて履歴を明示保存し遷移
   * フェイルセーフ: stopped が届かないケース (音声を伴わない応答・イベント欠落)
   * に備え、登録から 12s で必ず遷移する。
   */
  _queueRealtimeNavigation(url, title) {
    this._clearPendingNavigation();
    const exec = () => {
      this._clearPendingNavigation();
      this._saveSessionNow();
      this._postToParent({ type: "navigate", url, title });
    };
    this._pendingNavigation = { exec };
    this._pendingNavigationFallbackTimer = setTimeout(exec, 12000);
  }

  _clearPendingNavigation() {
    if (this._pendingNavigationFallbackTimer) {
      clearTimeout(this._pendingNavigationFallbackTimer);
      this._pendingNavigationFallbackTimer = null;
    }
    if (this._pendingNavigationGraceTimer) {
      clearTimeout(this._pendingNavigationGraceTimer);
      this._pendingNavigationGraceTimer = null;
    }
    this._pendingNavigation = null;
  }

  handleOutputAudioStarted() {
    // 新しい発話が始まった: stopped 起点の grace タイマーが走っていたら
    // キャンセルし、この発話の再生完了 (次の stopped) を待ち直す。
    if (this._pendingNavigationGraceTimer) {
      clearTimeout(this._pendingNavigationGraceTimer);
      this._pendingNavigationGraceTimer = null;
    }
  }

  handleOutputAudioStopped() {
    if (!this._pendingNavigation) return;
    const { exec } = this._pendingNavigation;
    // 直後に別応答の再生が始まる可能性があるため即断しない。特にツール結果を
    // 受けた案内文は「stopped (ツール呼び出し側応答の再生完了) → 生成 1〜2s →
    // started (案内文の再生開始)」の順で届くので、その隙間より長い 2s の間を
    // 置き、started が来たら待ち直す。
    if (this._pendingNavigationGraceTimer) clearTimeout(this._pendingNavigationGraceTimer);
    this._pendingNavigationGraceTimer = setTimeout(exec, 2000);
  }

  /**
   * ユーザー音声の commit (input_audio_buffer.committed) — 0.3.0-dev.46。
   * この時点で placeholder バブルを確保して発話の時系列位置を確定する。
   * commit はこの発話への response より必ず先に届くため、transcript の完了が
   * AI 応答の確定より遅れて届いても表示順は正しく保たれる
   * （「Hi と言ったのに応答の後に表示される」タイムライン逆転バグの根本修正）。
   */
  handleUserAudioCommitted(itemId) {
    if (!itemId) return;
    // テキスト送信で作成済み item の echo は対象外
    if (this._sentItemIds.has(itemId)) return;

    // 会話履歴側の placeholder 予約 (F2)。DOM 表示の有無に関わらず、commit が
    // 届いた時点で必ず配列上の位置を確保する。
    this._reserveRealtimeUserSlot(itemId);

    // 既に transcript delta が bubble を作っていれば DOM 側は何もしない
    if (this._streamingElements && this._streamingElements.has(itemId)) return;

    this.updateOrCreateStreamingMessage("…", "user", itemId);

    // transcription failed 等で確定が一度も届かなかった場合の掃除
    // (「…」のままの placeholder を残さない。20s は transcript 完了遅延の実測上限より十分長い)
    // F4対応: 掃除対象の DOM 要素はここで直接キャプチャし、_streamingElements Map
    // への依存を切る。cleanupRealtimeSession はセッション境界で Map を clear するが
    // (DOM の bubble 自体は意図的に残す設計のため)、Map 経由の再取得では「切断後まで
    // 確定しなかった placeholder」がこのタイマーで永久に取り残されていた
    // (Map から消えて `if (el)` が false になり掃除が no-op化するバグ)。
    // 判定を「今も bubble.textContent が『…』のままか」という DOM 側の直接条件に
    // することで、Map の生存・clear 状態に関係なく確実に掃除できるようにする。
    const placeholderEl = this._streamingElements.get(itemId);
    setTimeout(() => {
      const bubble = placeholderEl.querySelector(".agent__bubble");
      if (bubble && bubble.textContent === "…") {
        placeholderEl.remove();
        if (this._streamingElements) {
          this._streamingElements.delete(itemId);
        }
        this._updateBubbleGrouping();
      }
      // DOM 側と同じタイムアウトで、確定しなかった配列側の予約も撤去する
      // (F2/F6: 内容が空のまま履歴・保存対象・/chat コンテキストに残ることを防ぐ)。
      this._discardRealtimeUserSlot(itemId);
    }, 20000);
  }

  /**
   * ストリーミングメッセージの作成/更新
   * messageId別にMap管理し、複数ストリームが同時進行しても重複しない
   */
  updateOrCreateStreamingMessage(text, role, messageId) {
    if (!this._streamingElements) {
      this._streamingElements = new Map();
    }

    const existing = this._streamingElements.get(messageId);
    if (existing) {
      const bubble = existing.querySelector(".agent__bubble");
      if (bubble) {
        bubble.textContent = text;
      }
    } else {
      const displayRole = role === "user" ? "user" : "bot";
      const el = this._createMessageElement(text, displayRole);
      // PR #601 v2 AI Review (medium) 対応: messageId を data 属性として保持し、
      // 後続の TEXT_DONE 遅延到着時に bubble を再特定して text を上書きできるようにする。
      el.dataset.messageId = messageId;

      // ユーザーメッセージの場合: 最新の AI 応答ストリーミング要素より前に挿入。
      // Realtime API では AI 応答のストリーミングがユーザーの transcript より先に
      // 始まるため、視覚的に「ユーザーが先に話した」を保つ。
      //
      // 重要: 中断 (CONVERSATION_ITEM_TRUNCATED) などで掃除できなかった stale な
      // 古い bot エントリを誤って選ぶと、新規ユーザー発話が会話履歴の先頭付近に
      // 挿入され時系列が逆転する。Map は insertion order を保つため、必ず
      // **最後 (最新)** の bot エントリを採用する。
      let inserted = false;
      if (role === "user" && this._streamingElements.size > 0) {
        let latestBotStreamEl = null;
        for (const [id, streamEl] of this._streamingElements) {
          if (id === messageId) continue;
          if (streamEl.classList.contains("agent__message--bot")) {
            latestBotStreamEl = streamEl;
          }
        }
        if (latestBotStreamEl) {
          this.el.chatBody.insertBefore(el, latestBotStreamEl);
          inserted = true;
        }
      }
      if (!inserted) {
        this.el.chatBody.appendChild(el);
      }

      this._streamingElements.set(messageId, el);
      this._updateBubbleGrouping();
    }
    this.scrollToBottom();
  }

  /**
   * ストリーミングメッセージの確定
   */
  finalizeStreamingMessage(messageId, finalText) {
    if (!this._streamingElements) return;

    const el = this._streamingElements.get(messageId);
    if (el) {
      const bubble = el.querySelector(".agent__bubble");
      if (bubble && finalText) {
        bubble.textContent = finalText;
      }
      this._stampTimestamp(el);
      this._streamingElements.delete(messageId);
    }
    this.scrollToBottom();
  }

  /**
   * ユーザー割込 (CONVERSATION_ITEM_TRUNCATED) によりストリーミング中の
   * AI 応答が中断された際の後始末。
   *
   * 中断された bot bubble は DONE イベントが完了形では届かないため、
   * `_streamingElements` Map に残留し、その後に到着するユーザー発話の
   * `insertBefore` 対象として誤って選ばれて時系列を破壊する原因となる。
   * 残留した bubble に現在時刻のタイムスタンプを付け、Map から削除する。
   *
   * @param {object} event - サーバー event
   *        event.item_id : 中断対象 item の id (assistant 側)
   */
  handleConversationItemTruncated(event) {
    if (!this._streamingElements) return;
    const itemId = event?.item_id;
    if (!itemId) return;
    const messageId = `assistant-${itemId}`;

    const el = this._streamingElements.get(messageId);
    if (el) {
      // 中断時点までの delta は bubble.textContent に既に反映されているので
      // 内容には触らない。タイムスタンプだけ確定して Map から外す。
      this._stampTimestamp(el);
      this._streamingElements.delete(messageId);
      console.log(`[PersistentWidget] TRUNCATED bubble cleanup: messageId=${messageId}`);
    } else {
      // TRUNCATED が finalize より遅れて届く / 別経路で既に掃除済みの場合。
      // 障害ではないが、調査用に痕跡を残す。
      console.log(`[PersistentWidget] TRUNCATED received but bubble already gone: messageId=${messageId}`);
    }
  }

  /**
   * ファンクションコール処理
   */
  handleFunctionCall(functionCall) {
    console.log(`[PersistentWidget] Function call: ${functionCall.name}`);

    this.currentToolCalls.push({
      name: functionCall.name,
      call_id: functionCall.call_id,
      arguments: functionCall.arguments,
    });

    this.toolHandler.handleFunctionCall(functionCall);
  }

  /**
   * 音声入力開始（波形色変更）
   */
  handleSpeechStarted() {
    this.isSpeechActive = true;
  }

  /**
   * 音声入力終了
   */
  handleSpeechStopped() {
    this.isSpeechActive = false;
  }

  /**
   * レスポンス生成開始 → ローディング表示
   */
  handleResponseCreated() {
    this.showStreamingIndicator();
  }

  /**
   * レスポンス生成失敗 → エラー表示
   */
  handleResponseFailed(errorInfo) {
    const { errorType, errorCode, errorMessage, userMessage } = errorInfo || {};
    console.warn(
      `[PersistentWidget] Response failed: type=${errorType}, code=${errorCode}, message=${errorMessage}`
    );
    this.removeStreamingIndicator();
    // Issue #286: 共通エラーバブルで「もう一度試す」+ 問い合わせ導線を提示
    this.renderErrorBubble({
      title: t("err_response_failed_title", "Response failed"),
      body: userMessage || t("err_response_failed_body", "Failed to generate a response. Please wait a moment and try again."),
      retryHandler: this._lastUserText ? () => this.handleSend() : null,
      lastUserText: this._lastUserText,
    });
  }

  /**
   * Realtime エラー
   */
  handleRealtimeError(error) {
    // F7 fix (敵対的検証): suspend 中は裏で RTCPeerConnection が生きているだけで、
    // 利用者から見た現在の会話はテキストモード。裏の PC が failed/closed に
    // 遷移してここに来ても、テキスト会話の最中に無関係な「Voice mode error」を
    // 差し込んではいけない。ユーザー向けメッセージは出さず console.warn のみ、
    // disconnectRealtime() で静かに完全破棄する (F3(c) により待機タイマーも
    // 併せて解除される)。次回の音声切替は通常の完全接続として自然に成立する。
    if (this.connector?.isSuspended?.()) {
      console.warn("[PersistentWidget] Realtime error while suspended — tearing down silently:", error?.message || error);
      this.disconnectRealtime();
      return;
    }

    // 自動再接続中は重複エラーメッセージを抑制 (statusText "再接続中 (n/3)..." で十分)
    if (!this._isAutoReconnecting) {
      this.addMessage(t("err_voice_mode", "Voice mode error: {msg}").replace("{msg}", error.message || error), "system");
    }
    this.analytics.trackError(String(error.message || error), "realtime");
    this._sendWidgetEvent('error', { error_type: 'realtime' });

    // 自動再接続 (Issue #193 / G-013): realtime モード中の切断検知時のみ発火
    if (this.mode === "realtime") {
      this._maybeAttemptReconnect(error);
    }
  }

  /**
   * 自動再接続の試行スケジュール (Issue #193 / G-013)
   *
   * 呼び元: handleRealtimeError (WebRTC state=failed/disconnected/closed、datachannel close)
   * 終端条件:
   *   - MAX_RETRIES 到達 → _showReconnectFailedUI で手動 retry CTA を提示
   *   - マルチタブ排他で他タブが voice session active → 即座に中止
   *   - 再接続成功 → handleConnectionStateChange('connected') 内でカウンタリセット
   *
   * Backoff + jitter は reconnect-config.js の calculateBackoffDelayWithJitterMs() に委譲 (Issue #429)。
   * 同時切断した複数クライアントの再接続を分散させ、realtime-token エンドポイントへの
   * Thundering Herd を抑制する。
   */
  _maybeAttemptReconnect(lastError) {
    // 契約 B-5: httpStatus 429 (レート制限) / 503 (realtime_limit_per_min 超過等の
    // サーバー保護) は「一時的な通信断」ではなくレート制限起因の失敗であり、
    // 自動再接続で叩き直すとトークン発行を連打してさらに悪化させる
    // (トークン発行上限超過→503→自動再接続→503…の連鎖)。数値プロパティのみで
    // 判定する — ローカライズ済みメッセージ文字列のパースは日本語環境で壊れるため
    // 使わない。httpStatus が undefined (従来の WebRTC 通信断等) は従来どおり
    // 自動再接続する。
    if (lastError?.httpStatus === 429 || lastError?.httpStatus === 503) {
      console.log(`[PersistentWidget] Skipping auto-reconnect for httpStatus=${lastError.httpStatus}`);
      this._showReconnectFailedUI(lastError);
      return;
    }

    if (this._reconnectAttempt >= RECONNECT_MAX_RETRIES) {
      this._showReconnectFailedUI(lastError);
      return;
    }

    // 既存の予約をクリア (切断イベントが複数経路から飛んでくる可能性あり)
    if (this._reconnectTimeoutId) {
      clearTimeout(this._reconnectTimeoutId);
      this._reconnectTimeoutId = null;
    }

    this._reconnectAttempt += 1;
    this._isAutoReconnecting = true;
    const delayMs = calculateBackoffDelayWithJitterMs(this._reconnectAttempt);

    if (this.el.statusText) {
      this.el.statusText.textContent = formatReconnectStatus(this._reconnectAttempt);
    }

    console.log(`[PersistentWidget] Scheduling reconnect attempt ${this._reconnectAttempt}/${RECONNECT_MAX_RETRIES} in ${delayMs}ms`);

    this._reconnectTimeoutId = setTimeout(async () => {
      this._reconnectTimeoutId = null;
      await this._performReconnect();
    }, delayMs);
  }

  /**
   * 実際の再接続動作 (マルチタブ排他チェック + switchToRealtimeMode 再実行)
   */
  async _performReconnect() {
    // dev.56 A-2(b): 発火側の防御。switchToTextMode()/disconnectRealtime() の
    // 「解除」層 (_clearReconnectTimer()) と対になる第2層。予約時点では
    // realtime だったが、タイマー発火までの間にテキストへ戻った/suspend された
    // 場合、ここで即座に中断する(解除層のクリア漏れ・タイミング競合の保険)。
    // dev.61: _backgroundHidden も同じ発火側ガードに追加。_onTabHidden() の
    // _clearReconnectTimer() (解除層) と対になる第2層 — 予約後にタブが
    // hidden になった競合を塞ぐ (hidden 中に新しい課金セッションを張らせない)。
    if (this.mode !== "realtime" || this.connector?.isSuspended?.() || this._backgroundHidden) {
      console.log("[PersistentWidget][Reconnect] no longer in voice mode — aborting scheduled reconnect");
      this._isAutoReconnecting = false;
      this._reconnectAttempt = 0;
      return;
    }

    // マルチタブ排他: 別タブで voice session が握られたら中止
    if (await this._isOtherTabVoiceActive()) {
      console.log("[PersistentWidget][Reconnect] Other tab active, aborting reconnect");
      this._showReconnectFailedUI(
        new Error(t("err_reconnect_other_tab", "Reconnection was canceled because a voice session is active in another tab"))
      );
      return;
    }

    // switchToRealtimeMode の early-return ガードを回避するためモードをリセット
    this.mode = "text";
    try {
      await this.switchToRealtimeMode({ silentReconnect: true });
    } catch (e) {
      // onError → handleRealtimeError → _maybeAttemptReconnect が再帰的に動く
      console.warn("[PersistentWidget][Reconnect] attempt threw:", e?.message);
    }
  }

  /**
   * 自動再接続が max_retries を使い切った時の UI。
   * 既存の error メッセージに続けて手動 retry ボタンを提示する (handleMicDeniedFallback と同パターン)。
   */
  _showReconnectFailedUI(lastError) {
    this._reconnectAttempt = 0;
    this._isAutoReconnecting = false;
    if (this._reconnectTimeoutId) {
      clearTimeout(this._reconnectTimeoutId);
      this._reconnectTimeoutId = null;
    }

    // F1 fix (0.3.0-dev.48): この経路は disconnectRealtime() / switchToTextMode()
    // のどちらも呼ばずに mode を直接 "text" へ戻す (自動再接続を使い切った終端)。
    // disconnectRealtime() 側の解除に乗れないため、ここでも明示的に保留中の
    // フェイルセーフ/grace タイマーを解除する。
    this._clearPendingNavigation();

    // UI をテキストモードに戻す
    this.mode = "text";
    this._hideRealtimeUI();
    if (this.el.statusText) this.el.statusText.textContent = t("status_text_mode", "Text mode");
    if (this.el.statusDot) {
      this.el.statusDot.classList.remove("agent__status-dot--connecting", "agent__status-dot--connected");
    }

    // 手動 retry ボタンをチャットに差し込む
    if (!this.el.chatBody) return;
    const retryEl = document.createElement("div");
    retryEl.className = "agent__realtime-retry";
    retryEl.style.cssText = "display:flex;align-items:center;gap:8px;padding:8px 12px;margin:4px 0;background:var(--agent-system-bg,#f1f5f9);border-radius:8px;font-size:13px;";
    retryEl.innerHTML =
      '<span style="color:var(--agent-text-muted,#64748b);">' +
      this.escapeHtml(t("msg_auto_reconnect_failed", "Automatic reconnection failed.")) +
      '</span>' +
      '<button type="button" class="agent__realtime-retry-btn" style="appearance:none;border:none;background:var(--agent-primary,#0D9488);color:#fff;padding:4px 12px;border-radius:6px;font-size:13px;cursor:pointer;">' +
      this.escapeHtml(t("btn_reconnect", "Reconnect")) +
      '</button>';
    retryEl.querySelector(".agent__realtime-retry-btn").addEventListener("click", () => {
      retryEl.remove();
      this.mode = "text";
      this.switchToRealtimeMode();
    });
    this.el.chatBody.appendChild(retryEl);

    this.analytics.trackError?.(`reconnect_exhausted: ${lastError?.message || "unknown"}`, "realtime");
    this._sendWidgetEvent('reconnect_exhausted', { last_error: String(lastError?.message || "") });
  }

  /**
   * マイクエラー（詳細メッセージ表示 — connector内コールバック用）
   */
  handleMicrophoneError(info) {
    // connect()内でマイク拒否時にコールバック経由で呼ばれるが、
    // テキストモードフォールバックは handleMicDeniedFallback() で一元管理するため、
    // ここではログ記録のみ行う
    console.warn(`[PersistentWidget] Microphone error: ${info.errorType} - ${info.errorMessage}`);
  }

  /**
   * dev.57 W2 修正1: 使用中マイクの物理切断検知 (connector._watchMicTrackEnded
   * の onMicrophoneLost コールバック)。抜線・OS側デバイス無効化等で発火する。
   * 同一デバイス再選択ガード (handleMicDeviceChange) を解除しないと、ユーザーが
   * 同じ deviceId を選び直しても "同一デバイスだから" と無視されて詰む。
   */
  handleMicrophoneLost(info) {
    if (this.mode !== "realtime") return;
    console.warn(`[PersistentWidget] microphone lost: ${info?.label || "(unknown device)"}`);
    this.currentMicDeviceId = null; // 同一デバイス再選択ガードを解除
    this._updateMicDeviceName();
    // dev.57 W2 レビュー修正N3: voiceUi.deviceSelect===false のテナントは
    // マイク選択UI自体を非表示にする設計 (_applyVoiceUiConfig())。
    // populateMicDevices() は btnMicSelect の agent--hidden を無条件で
    // 外してしまう既存の挙動があるため、ここから呼ぶと隠しているはずの
    // 選択ボタンが復活してしまう。deviceSelect が有効なテナントに限定する。
    if (this.cfg?.voiceUi?.deviceSelect !== false) {
      this.populateMicDevices();
    }
    this.addMessage(
      t("msg_mic_disconnected", "The microphone was disconnected. Select a microphone from the mic menu."),
      "system"
    );
  }

  /**
   * マイク拒否時: テキストモードにフォールバック + リトライボタン表示
   */
  handleMicDeniedFallback(errorType) {
    const messages = {
      permission_denied: t("err_mic_permission_denied", "Microphone access is not allowed."),
      device_not_found: t("err_mic_not_found", "Microphone not found."),
      device_in_use: t("err_mic_in_use", "The microphone is in use by another app."),
      constraints_error: t("err_mic_constraints", "A microphone configuration error occurred."),
    };
    const reason = messages[errorType] || t("err_mic_failed", "Failed to access the microphone.");

    // 接続インジケーター非表示
    if (this.el.connectionIndicator) {
      this.el.connectionIndicator.classList.add("agent--hidden");
    }

    // テキストモードにフォールバック
    this.mode = "text";
    this._hideRealtimeUI();
    this.el.statusText.textContent = t("status_text_mode", "Text mode");
    this.el.statusDot.classList.remove("agent__status-dot--connecting", "agent__status-dot--connected");

    // メッセージ（矛盾する「接続しました」は表示されない）
    this.addMessage(t("msg_switched_to_text_reason", "⚠️ {reason}Switched to text mode.").replace("{reason}", reason), "system");

    // リトライボタンをチャットに表示
    const retryEl = document.createElement("div");
    retryEl.className = "agent__mic-retry";
    retryEl.style.cssText = "display:flex;align-items:center;gap:8px;padding:8px 12px;margin:4px 0;background:var(--agent-system-bg,#f1f5f9);border-radius:8px;font-size:13px;";
    retryEl.innerHTML =
      '<span style="color:var(--agent-text-muted,#64748b);">' +
      this.escapeHtml(t("msg_mic_retry_prompt", "Allow microphone access, then")) +
      '</span>' +
      '<button type="button" class="agent__mic-retry-btn" style="appearance:none;border:none;background:var(--agent-primary,#0D9488);color:#fff;padding:4px 12px;border-radius:6px;font-size:13px;cursor:pointer;">' +
      this.escapeHtml(t("btn_retry_voice_mode", "Retry voice mode")) +
      '</button>';
    retryEl.querySelector(".agent__mic-retry-btn").addEventListener("click", () => {
      retryEl.remove();
      this.switchToRealtimeMode();
    });
    this.el.chatBody.appendChild(retryEl);
    this.el.chatBody.scrollTop = this.el.chatBody.scrollHeight;

    // テキスト greeting を送信 (fire-and-forget: 失敗は _triggerGreeting 内で catch して console.warn でログ出力)
    if (!this._greetingSent) {
      void this._triggerGreeting();
    }

    this.analytics.trackError(`mic_denied:${errorType}`, "microphone");
    this._sendWidgetEvent('mic_denied', { error_type: errorType });
  }

  // ============================================
  // モード切替
  // ============================================

  /**
   * 音声モードに切り替え → WebRTC 接続開始
   */
  async switchToRealtimeMode({ silentReconnect = false } = {}) {
    // 既に音声モードなら重複接続を防止。
    // 契約 B-2 (suspend/resume 対応): 旧ガードは `this.connector?.getIsConnected()` も
    // 条件に含めていたが、これは suspend 中 (mode==="text" だが
    // connector.getIsConnected()===true — suspendSession() は isConnected を
    // 落とさない) を「既に音声モード」と誤判定し、何もせず return してしまう
    // バグを持っていた。判定は this.mode のみで行う。
    if (this.mode === "realtime") {
      // dev.56 B-1: 既に realtime モードでも(=このガードに来る時点で接続自体は
      // 継続中)、ユーザーがホーム画面にいる可能性がある(例: チップ押下、
      // 別タブ操作からの復帰)。確実にトーク画面へ切り替える。
      this.showChat();
      console.log("[PersistentWidget] Already in realtime mode, skipping");
      // F9 fix: 接続 in-flight 中 (mode は既に "realtime" だが
      // connector.getIsConnected() はまだ false) のチップ押下まで質問を
      // 握り潰すと、接続完了時の _triggerGreeting が拾えたはずの質問が
      // 無言でドロップされる。stale な質問を捨てたいのは「既に接続済みで
      // 再度ここに来た」場合だけなので、null化は getIsConnected()===true の
      // ときに限定する。in-flight ならそのまま残し、接続完了時の挨拶に乗せる。
      if (this.connector?.getIsConnected?.()) {
        this._pendingGreetingQuestion = null;
      }
      return;
    }

    // suspend 中 (mode==="text" だが接続は裏で生きている) なら再開経路へ。
    // マルチタブ排他チェックより先に分岐する — 自タブの suspend セッションを
    // 「他タブが音声中」と誤って弾かせないため (下記 _resumeSuspendedSession
    // 内のコメント参照)。
    if (this.connector?.isSuspended?.()) {
      await this._resumeSuspendedSession();
      return;
    }

    // マルチタブ排他制御: 他タブで音声セッション中なら開始を拒否
    if (await this._isOtherTabVoiceActive()) {
      console.log("[PersistentWidget][TabExclusion] a voice session is active in another tab");
      // dev.56 B-1: ブロックされた旨のメッセージもトーク画面に表示されないと
      // 利用者から見えない(ホーム画面のままだと素通りしてしまう)。
      this.showChat();
      this.addMessage(t("msg_voice_blocked_other_tab", "A voice session is active in another tab. Please close that tab and try again."), "system");
      this._pendingGreetingQuestion = null;
      return;
    }

    const fromMode = this.mode;
    this.mode = "realtime";
    // モード epoch を +1: in-flight な旧接続 chain (前回の switchToRealtimeMode 残骸など)
    // をローカル epoch ズレで bail させる。新規接続のローカル epoch をクロージャに閉じる。
    const epoch = (this._connectEpoch = (this._connectEpoch || 0) + 1);
    // 0.3.0-dev.39: 挨拶ガードのリセットは「会話がまだ無い、手動での音声開始」
    // のみ。従来は無条件リセット（「音声セッション開始時に挨拶を再送」）で、
    // モバイルの画面ロック/回線断→自動再接続や接続失敗→テキストフォール
    // バック（handleConnectionFailure / handleMicDeniedFallback も
    // _triggerGreeting を呼ぶ）のたびに非表示トリガーが再送され、
    // 「Hello! How can I help you today?」が延々と積み上がる暴走が起きていた
    // （実機スクリーンショットで確認）。会話が存在する時点で挨拶の再送に
    // 意味は無いため、_hasConversation() を本質ガードとする。
    // 例外: _pendingGreetingQuestion がある場合はユーザーの明示操作
    // （ホームのチップ→音声開始で質問を託した）なので、会話が既にあっても
    // greeting-with-question を発火させる。
    if ( ! silentReconnect && ( ! this._hasConversation() || this._pendingGreetingQuestion ) ) {
      this._greetingSent = false;
    }
    this._greetingTimingLogged = false;
    this._greetingAudioLogged = false;
    // Issue #558: 旧セッションの再有効化タイマー (最大 30s 生存) が新セッションを
    // 上書きする経路を塞ぐ。abort してから新 controller を割当てる。
    this._autoResponseAbort?.abort();
    this._autoResponseAbort = new AbortController();
    this._autoResponseReenabled = false;
    this._broadcastVoiceSessionActive();
    this._showRealtimeUI();
    this.el.statusText.textContent = silentReconnect
      ? formatReconnectStatus(this._reconnectAttempt)
      : t("status_connecting", "Connecting...");
    this.el.statusDot.classList.add("agent__status-dot--connecting");

    this.showChat();
    // 自動再接続中は既存のエラーメッセージ (handleRealtimeError) で十分なので
    // ceremony 系の message / analytics は抑制する (Issue #193)
    if (!silentReconnect) {
      this.addMessage(t("msg_switching_to_voice", "Switching to voice mode..."), "system");
      this.analytics.trackModeSwitch(fromMode, "realtime");
      this._sendWidgetEvent('voice_start');
    }

    // 接続インジケーター表示
    if (this.el.connectionIndicator) {
      this.el.connectionIndicator.classList.remove("agent--hidden");
    }

    // WebRTC 接続開始 (epoch をクロージャで渡し、in-flight chain が後段で bail できるようにする)
    await this.connectToOpenAI(epoch);
  }

  /**
   * 契約 B-2: suspend 中 (switchToTextMode の suspend 分岐で mode==="text" に
   * なった後も connector.isSuspended()===true) に switchToRealtimeMode() が
   * 呼ばれた場合の再開経路。
   *
   * - マルチタブ排他チェック (_isOtherTabVoiceActive) はここでは呼ばない。
   *   suspend 中も _broadcastVoiceSessionActive() 済みの状態を維持している
   *   (switchToTextMode の suspend 分岐は disconnectRealtime() を呼ばないため
   *   _broadcastVoiceSessionEnded() も発火しない — セッションは実際に生きている)。
   *   このセッション自体が「音声セッションの保有者」なので、他タブが握っていない
   *   かを確認する意味が無い。加えて BroadcastChannel は postMessage を送信元
   *   自身には配送しない仕様なので、仮に呼んでも自分の active broadcast で
   *   自分が弾かれる実害は無いが、ping 応答 (_isOtherTabVoiceActive 内の
   *   voice-session-ping → 応答側の `this.mode === "realtime"` 判定) は
   *   suspend 中 mode==="text" なので false を返してしまい、無意味な待機
   *   (100ms) が発生するだけで何の判定にもならない。呼ばない方が正しい。
   * - トークン発行・SDP交換・msg_switching_to_voice / msg_voice_connected の
   *   儀式は発生させない: 接続は生きたままで即時に戻るだけなので、
   *   「切り替えます...」という文言は嘘になる。
   * - 挨拶 (_triggerGreeting) は再送しない。_greetingSent 等のガードにも触れない
   *   — 会話は継続しているため、トリガーのリセットも行わない。ただし
   *   _pendingGreetingQuestion (ホームのチップ押下で託された質問) は
   *   resume 成功後に消費する (F2 参照)。
   *
   * F4 fix (敵対的検証): resumeSession() の await 中に switchToTextMode() /
   * _handleNewConversation() 等が走ると、この継続が mode="realtime" と
   * 接続済み UI を、既に破棄された接続の上に被せてしまう race があった。
   * このファイルの既存流儀 (connectToOpenAI 等) に合わせ、開始時に
   * _connectEpoch を +1 して capture し、await 後に不一致なら bail する。
   */
  async _resumeSuspendedSession() {
    // 多重起動防止: 冷間接続と違い、resume 経路は await の間じゅう
    // this.mode==="text" / connector.isSuspended()===true のままで mode 由来の
    // ガードが効かない。連打の2回目以降はここで即座に無視する。
    if (this._resumeInProgress) {
      console.warn("[PersistentWidget] _resumeSuspendedSession() already in progress — ignoring duplicate call");
      return;
    }
    this._resumeInProgress = true;

    this._clearSuspendTimer();

    // F4: await をまたぐ世代ガード。ローカル epoch をクロージャに閉じる。
    const epoch = (this._connectEpoch = (this._connectEpoch || 0) + 1);

    let resumed = false;
    try {
      try {
        // dev.56 A-4: suspend 前のミュート意図(スピーカー/マイク)を resume に
        // も引き継ぐ。指定しないと resume は常に unmute/enabled で復帰してしまい、
        // 「ミュートしていたはずなのに resume したら音が出る/喋ってしまう」となる。
        resumed = await this.connector.resumeSession(this._preferredMicDeviceId(), {
          remoteMuted: this.isSpeakerMuted,
          micMuted: this.isMicMuted,
        });
      } catch (e) {
        // 契約 A-2: resumeSession() は throw せず boolean を返す契約だが、
        // 想定外の例外はフォールバック (false 相当) 側に倒す。
        console.warn("[PersistentWidget] resumeSession() threw unexpectedly:", e?.message ?? String(e));
        resumed = false;
      }

      if (this._connectEpoch !== epoch) {
        // F4: 世代ズレ — await 中に別の遷移が完全破棄まで進んでいる。
        // mode / UI には一切触れない。resumed===true で新しいマイクが
        // 生きたまま残ってしまったケースだけ後始末する。connector が既に
        // disconnect 済みなら isSuspended()/getIsConnected() は false になっており、
        // suspendSession() は内部ガードで無条件に no-op になるため安全。
        if (resumed && this.connector?.getIsConnected?.() === true && !this.connector.isSuspended?.()) {
          this.connector.suspendSession();
        }
        console.warn("[PersistentWidget] _resumeSuspendedSession() bailed due to epoch mismatch (superseded by a newer transition)");
        return;
      }

      if (resumed) {
        this.mode = "realtime";
        // dev.56 B-1: resume 成功時に確実にトーク画面(chat view)へ切り替える。
        // ホームのチップ押下等、ユーザーがホーム画面にいたまま resume が完走する
        // 経路では、mode を書き換えるだけでは view が home のまま残ってしまう。
        this.showChat();
        this._showRealtimeUI();
        // handleConnectionStateChange('connected') と同じ表示 (既存文言を再利用、
        // 新規 msgid は追加しない)。suspend→resume は WebRTC の
        // connectionstatechange イベントを発火させない (renegotiation 無し) ため、
        // ここで明示的に「接続済み」の見た目に揃える。
        this.el.statusDot.classList.remove("agent__status-dot--connecting");
        this.el.statusDot.classList.add("agent__status-dot--connected");
        this.el.statusText.textContent = t("status_voice_connecting", "Connecting voice");
        this._updateMicDeviceName();
        this.populateMicDevices();
        // F8: cold connect の成功分岐 (startWebRTCConnection) と同じ扱いで
        // 波形ビジュアライザを新しいマイクに再接続する。suspend 分岐で
        // stopWaveformVisualization() 済みなので多重起動にはならない。
        this.setupWaveformVisualization();
        console.log("[PersistentWidget] realtime session resumed from suspend (no token reissue, no ceremony)");

        // F2 fix (実機で発覚): ホームのクイックアクション（チップ）押下で
        // _pendingGreetingQuestion に質問が入って switchToRealtimeMode() が
        // 呼ばれても、suspend 経由の resume はこれを消費せず、質問が無言で
        // 消えていた (さらに stale 残留で後日暴発するリスクもあった)。
        // 「接続済み音声でのチップ押下」(quickActions ハンドラ) と同じ経路
        // (chatInput.value → handleSend()) で送る — ユーザーバブル表示・
        // 履歴記録・送信失敗時のエラーカードが全て既存のまま効く。
        // _triggerGreeting は使わない: 会話は継続しているため「新規セッション
        // 開始の挨拶」の体裁は不要。
        if (this._pendingGreetingQuestion) {
          const question = this._pendingGreetingQuestion;
          this._pendingGreetingQuestion = null;
          this.el.chatInput.value = question;
          void this.handleSend();
        }
        return;
      }
    } finally {
      // 成功・epoch ミスマッチ・例外のいずれの終了経路でも、フォールバック
      // (下記の再帰呼び出し) に入る "前" に必ず解除する。再帰は冷間接続経路
      // (switchToRealtimeMode) に入り、そちらは独自の "realtime" mode ガードで
      // 連打を弾くため、このフラグを持ち越す必要はない。
      this._resumeInProgress = false;
    }

    // resumeSession() が false: 完全破棄してから従来の接続経路へフォールバックする。
    // この場合は儀式・トークン発行も従来どおり発生させる (this.mode はまだ "text" の
    // ままなので、再帰呼び出しは isSuspended()===false の通常経路を通る)。
    console.warn("[PersistentWidget] resumeSession() returned false — falling back to full reconnect");
    this.disconnectRealtime();
    await this.switchToRealtimeMode();
  }

  /**
   * 契約 B-1: suspendSession() 開始とペアで管理する待機タイマー
   * (REALTIME_SUSPEND_TIMEOUT_MS 経過で disconnectRealtime() の完全破棄)。
   * suspend の開始経路が switchToTextMode 以外に増えても解除漏れが起きないよう、
   * 開始・解除は必ずこのペアのヘルパーを経由する。
   */
  _startSuspendTimer() {
    this._clearSuspendTimer();
    this._suspendTimeoutId = setTimeout(() => {
      this._suspendTimeoutId = null;
      // F3(b) fix (敵対的検証): この closure は「今回の suspend」に紐づく
      // つもりのタイマーだが、破棄漏れで孤児化する経路が別途あり得る
      // (実際 F3(a) 修正前は switchToTextMode() の再入がその経路だった)。
      // 発火時点で connector が実際に suspend 中でなければ、それは自分の
      // suspend にひもづくタイマーではない — 何もせず抜ける。これを怠ると、
      // 後から (別の入り口で) 張られた無関係な音声セッションを巻き込んで
      // 強制切断してしまう。
      if (!this.connector?.isSuspended?.()) {
        console.log("[PersistentWidget] suspend timeout fired but session is no longer suspended — ignoring (stale timer)");
        return;
      }
      console.log("[PersistentWidget] suspend timeout reached — disconnecting the realtime session");
      this.disconnectRealtime();
    }, REALTIME_SUSPEND_TIMEOUT_MS);
  }

  /** _startSuspendTimer() で張った待機タイマーを解除する (冪等)。 */
  _clearSuspendTimer() {
    if (this._suspendTimeoutId) {
      clearTimeout(this._suspendTimeoutId);
      this._suspendTimeoutId = null;
    }
  }

  /**
   * dev.56 A-2: 予約済みの自動再接続タイマーの生存を断つ (冪等)。
   * suspend 中 (mode==="text" だが接続は裏で生きている) に、旧セッションの
   * 切断イベントから予約された自動再接続 (_scheduleReconnect) が生き残ると、
   * 待機タイマー満了後に無関係な新セッションへ向けて _performReconnect() が
   * 発火し、suspend 中のマイク停止/リモートミュートを台無しにする。
   * switchToTextMode() / disconnectRealtime() の両方から呼ぶ「解除」層。
   * _performReconnect() 側の「発火側ガード」(A-2(b)) とセットで初めて漏れなく塞げる。
   */
  _clearReconnectTimer() {
    if (this._reconnectTimeoutId) {
      clearTimeout(this._reconnectTimeoutId);
      this._reconnectTimeoutId = null;
    }
    this._isAutoReconnecting = false;
    this._reconnectAttempt = 0;
  }

  /**
   * テキストモードに戻る → 確立済み接続は suspend (セッション保持)、
   * in-flight な接続は従来どおり WebRTC 切断
   */
  switchToTextMode({ reason = "" } = {}) {
    // dev.56 A-2: 予約済みの自動再接続タイマーをここで断つ。suspend 分岐
    // (下の isSuspended 早期 return や wasEstablishedConnection 分岐) は
    // disconnectRealtime() を経由しないことがあるため、ここが最初の choke point。
    this._clearReconnectTimer();

    // F3(a) fix (敵対的検証): 再入ガード。suspend 開始後は this.mode==="text"
    // だが接続は裏で生きている (connector.isSuspended()===true)。この状態で
    // switchToTextMode() が再度呼ばれると、下の wasEstablishedConnection は
    // fromMode==="realtime" を要求するため false になり、生きている suspended
    // セッションが「in-flight 扱い」で disconnectRealtime() により誤って
    // 破棄されてしまう。しかも待機タイマーは _startSuspendTimer() 経由でしか
    // 解除されないため孤児化し、約60秒後にその時点で使われている無関係な
    // 音声セッションを強制切断する。既に suspend 済みならセッションには
    // 一切触れず、何もせず return する。
    if (this.connector?.isSuspended?.()) {
      // dev.56 A-7 (latent 予防): 将来 switchToTextMode() をプログラム的に
      // 呼ぶ経路が増えた時のための防御。resume 進行中 (_resumeInProgress) な
      // ら epoch を進めて F4 の巻き戻しガードを発火させる。
      if (this._resumeInProgress) this._connectEpoch = (this._connectEpoch || 0) + 1;
      return;
    }

    // 契約 B-1: 接続が確立済み (getIsConnected()===true) かどうかを this.mode
    // 書き換え前に判定しておく。in-flight な接続 (connect が進行中でまだ
    // isConnected===false) を suspend してはいけない — 中途半端なセッションを
    // suspend すると mic stream / PeerConnection の破棄漏れになるため、
    // その場合は下の従来どおりの全破棄経路 (disconnectRealtime()) に流す。
    const fromMode = this.mode;
    const wasEstablishedConnection =
      fromMode === "realtime" &&
      this.connector?.getIsConnected?.() === true &&
      typeof this.connector.suspendSession === "function";

    // モード epoch を +1: in-flight な connectToOpenAI / startWebRTCConnection /
    // _sessionTimingPromise.then が後段の epoch チェックで bail し、
    // mode=text のまま mic stream / PeerConnection / greeting が完成してしまう race を防ぐ。
    // disconnectRealtime() の前に bump することで、await 中のコールバックが
    // 「epoch ズレ → bail」へ即座に分岐できる (確立済み接続を suspend する経路では
    // 使われないが、無害なので常に bump する)。
    this._connectEpoch = (this._connectEpoch || 0) + 1;
    this.mode = "text";
    this._pendingGreetingQuestion = null;
    this._hideRealtimeUI();
    this.closeMicPopover();
    this.el.statusText.textContent = t("status_text_mode", "Text mode");
    this.el.statusDot.classList.remove("agent__status-dot--connecting", "agent__status-dot--connected");

    this.showChat();
    this.addMessage(
      reason
        ? t("msg_switched_to_text_reason", "⚠️ {reason}Switched to text mode.").replace("{reason}", reason)
        : t("msg_switched_to_text", "Returned to text mode."),
      "system"
    );
    this.analytics.trackModeSwitch(fromMode, "text");
    this._sendWidgetEvent('voice_end');

    // 音声→テキスト切替: 新しいテキストセッションを開始
    if (fromMode === "realtime") {
      this.textChat.startNewSession();

      // 音声モードの会話要約をテキストモードに引き継ぎ
      if (this._realtimeMessages.length > 0) {
        const recent = this._realtimeMessages.slice(-6);
        const summary = recent
          // "User"/"AI" are structural role markers inside an LLM-facing
          // summary, not UI copy — plain ASCII labels, never translated
          // (same convention as text-chat-controller.js's own summary).
          .map((m) => `${m.role === "user" ? "User" : "AI"}: ${m.content.substring(0, 80)}`)
          .join("\n");
        this.textChat.conversationSummary = summary;
        this._realtimeMessages = [];
      }
    }

    // 契約 B-1: 確立済み接続は suspendSession() でセッションを保持したまま
    // マイクだけ物理停止し、待機タイマー (REALTIME_SUSPEND_TIMEOUT_MS) を開始する。
    // タイマー発火時に完全破棄 (disconnectRealtime()) する。
    if (wasEstablishedConnection) {
      const suspended = this.connector.suspendSession();
      if (suspended) {
        // F5 fix (敵対的検証): disconnectRealtime() 側の方針
        // (「音声を離れる = AI主導の遷移予約を放棄する」) と揃える。ここを
        // 素通りすると、遷移予約が suspend を跨いで生き残り、テキストモードに
        // 戻った後に勝手に無関係な画面へ遷移する。
        this._clearPendingNavigation();
        // F8 fix (敵対的検証): suspend 中も古い rAF ループ / AudioContext が
        // 死んだストリームに繋がったまま走り続けるのを止める。cold connect の
        // 成功分岐 (setupWaveformVisualization) と対になる停止処理。
        this.stopWaveformVisualization();
        this._startSuspendTimer();
        return;
      }
      // suspendSession() が false (connector 側が「接続なし」と判定した異常系) は
      // 従来の全破棄にフォールバックする。
      console.warn("[PersistentWidget] suspendSession() returned false — falling back to full disconnect");
    }

    // WebRTC 切断 (in-flight 接続 / suspend 非対応・失敗時の全破棄)
    this.disconnectRealtime();
  }

  // ============================================
  // WebRTC 接続フロー
  // ============================================

  /**
   * OpenAI Realtime API に WebRTC 接続
   *
   * @param {number} [epoch] - switchToRealtimeMode が capture した接続 epoch。
   *   await の合間に switchToTextMode / _handleNewConversation で epoch が +1 されると、
   *   `this._connectEpoch !== epoch` で bail し、cleanup 後の async chain が継続して
   *   mic stream / PeerConnection / greeting を完成させてしまう race を防ぐ。
   */
  async connectToOpenAI(epoch = null) {
    if (epoch === null) epoch = this._connectEpoch;
    const connectionStartTime = performance.now();
    console.log("[PersistentWidget] connectToOpenAI start");

    // セッションログ開始（非ブロッキング）
    // Issue #616: Promise を保持。handleTranscript 等で session ID 確定を短時間 await
    // できるようにし、確定前に到着した voice transcript が session_id=NULL で保存される
    // race を防ぐ。
    this._sessionTimingPromise = startConnectionTiming(this.userId)
      .then((sessionId) => {
        // epoch ズレ = この .then 着火前にユーザーが「テキストモードに戻る」等を押下した。
        // 旧 sessionId で state を更新すると cleanup 後の新セッションに漏れるため、ここで止める。
        if (this._connectEpoch !== epoch) {
          return null;
        }
        if (sessionId) {
          this.currentSessionId = sessionId;
          this.toolHandler.setSessionId(sessionId);
          console.log(`[PersistentWidget] Session ID: ${sessionId}`);
          return sessionId;
        }
        // PR #618 AI Review R2 low #1: falsy sessionId 時に明示的に null を返し、
        // Promise<string|null> という JSDoc 型注記と挙動を一致させる (旧コードは undefined を返していた)。
        return null;
      })
      .catch((err) => {
        console.warn("[PersistentWidget] Session logging failed:", err.message);
        return null;
      });

    // iOS Safari 対応: ジェスチャーコンテキスト内で AudioContext を並列初期化
    const audioInitPromises = [];

    if (this.connector && typeof this.connector.initRemoteAudioForIOS === "function") {
      audioInitPromises.push(
        this.connector
          .initRemoteAudioForIOS()
          .then(() =>
            console.log(
              `[PersistentWidget] iOS remote audio initialized (+${Math.round(performance.now() - connectionStartTime)}ms)`
            )
          )
          .catch((e) =>
            console.warn(`[PersistentWidget] iOS remote audio failed: ${e.message}`)
          )
      );
    }

    if (audioInitPromises.length > 0) {
      await Promise.all(audioInitPromises);
    }

    // audio init の合間にユーザーがテキストモードに戻った場合は WebRTC 接続を開始しない。
    // (mic stream / PC を確立する前にここで bail することで、最も軽量な分岐になる)
    if (this._connectEpoch !== epoch) {
      console.log("[PersistentWidget] connectToOpenAI: epoch changed after audio init, bailing");
      return;
    }

    // WebRTC 接続開始
    await this.startWebRTCConnection(epoch);
  }

  /**
   * WebRTC 接続（タイムアウト付き）
   *
   * @param {number} [epoch] - connectToOpenAI が capture した接続 epoch。
   *   各 await の後に `this._connectEpoch !== epoch` を確認して、ユーザーが
   *   テキストモードに戻った場合は in-flight な connect 完了 / greeting trigger を
   *   bail させる。
   */
  async startWebRTCConnection(epoch = null) {
    if (epoch === null) epoch = this._connectEpoch;
    const CONNECTION_TIMEOUT_MS = 20000;

    // サーバーからセッション設定を取得（プロンプト・モデル・ツール統一）
    try {
      const fullSummary = this.textChat?.conversationSummary || null;

      const sessionConfig = await fetchSessionConfig({
        userId: this.userId,
        pageContext: this.currentPageContext || null,
        conversationSummary: fullSummary,
      });
      // fetchSessionConfig 中にユーザーがテキストモードに戻った場合は bail する。
      if (this._connectEpoch !== epoch) {
        console.log("[PersistentWidget] startWebRTCConnection: epoch changed after fetchSessionConfig, bailing");
        return;
      }
      this._lastSessionConfig = sessionConfig;
      this.currentInstructions = sessionConfig.instructions;

      // プロンプトソース検証: DBフォールバック時は警告表示
      if (sessionConfig.prompt_source === "esm_fallback") {
        console.error("[PersistentWidget] the prompt could not be loaded from the DB; the fallback is in use:", sessionConfig.fallback_reason);
        this.addMessage(t("msg_prompt_fallback_warning", "There is a problem loading the settings. Response quality may differ from normal."), "system");
      }

      console.log("[PersistentWidget] session config loaded:", {
        model: sessionConfig.model,
        instructionsLength: sessionConfig.instructions?.length,
        toolCount: sessionConfig.tools?.length,
        promptSource: sessionConfig.prompt_source,
      });
    } catch (error) {
      console.error("[PersistentWidget] session config load failed:", error.message);
      this.handleConnectionFailure(t("err_session_config_failed", "Failed to load session settings. Please try again."));
      return;
    }

    console.log(
      `[PersistentWidget] WebRTC connection start (timeout: ${CONNECTION_TIMEOUT_MS}ms)`
    );

    try {
      const result = await this.connectWithTimeout(CONNECTION_TIMEOUT_MS);

      // connectWithTimeout 中にユーザーがテキストモードに戻った場合: 完成した接続を即座に
      // 解放する。disconnectRealtime() は switchToTextMode 内で既に呼ばれているが、
      // その時点では mediaStream / PeerConnection が in-flight (まだ未確立) だったため
      // 大部分が no-op だった。connect 完成後に再度 disconnect しないと mic が hot に残る。
      if (this._connectEpoch !== epoch) {
        console.log("[PersistentWidget] startWebRTCConnection: epoch changed after connectWithTimeout, bailing");
        try { this.connector?.disconnect?.(); } catch (e) {
          console.warn("[PersistentWidget] connector.disconnect on epoch-bail failed:", e?.message);
        }
        if (this.el.connectionIndicator) {
          this.el.connectionIndicator.classList.add("agent--hidden");
        }
        return;
      }

      // 接続インジケーター非表示
      if (this.el.connectionIndicator) {
        this.el.connectionIndicator.classList.add("agent--hidden");
      }

      // マイク拒否時: WebRTC接続を中断してテキストモードにフォールバック
      if (result && result.micDenied) {
        console.log("[PersistentWidget] microphone denied → falling back to text mode");
        this.handleMicDeniedFallback(result.errorType);
        return;
      }

      if (result && result.connected) {
        console.log("[PersistentWidget] WebRTC connection succeeded");
        this.addMessage(t("msg_voice_connected", "Connected. Please start talking."), "system");

        // iOS Safari: スピーカーミュート案内
        if (isIOSSafari() && this.isSpeakerMuted) {
          this.addMessage(t("msg_ios_speaker_muted", "🔇 The speaker is muted on iOS. Tap the speaker button to hear audio."), "system");
        }

        // 波形表示開始
        this.setupWaveformVisualization();

        // マイクデバイス選択の初期化
        this.populateMicDevices();

        // Instruction レイヤーを送信 → 挨拶トリガー
        // (fire-and-forget: greeting 失敗時のリトライ手段は持たず log のみ)
        this.sendInstructionLayers().then(() => {
          // sendInstructionLayers の await 中にユーザーがテキストモードに戻った場合は
          // greeting を発火させない。発火すると assistant メッセージが新しい text session
          // (textChat.startNewSession で発番された session) の履歴に混入し、
          // 「AI の返答のみが記録される」状態を再生産してしまう。
          if (this._connectEpoch !== epoch) {
            return;
          }
          void this._triggerGreeting();
        });
      } else {
        this.handleConnectionFailure(t("err_connect_failed_retry", "Connection failed. Please try again."));
      }
    } catch (error) {
      // 新しい接続試行に supersede された / 意図的な abort は実害なし。
      // (connectWithTimeout が attempt id ミスマッチ時に投げる "connect attempt superseded" 等)
      // 新試行が別途進行中なので、error 報告も失敗 UI も出さずに終了する。
      // connector.connect() 側の isAbort 抑制と対をなす層。
      if (error.name === "AbortError") {
        console.log(`[PersistentWidget] startWebRTCConnection aborted (superseded): ${error.message}`);
        return;
      }

      console.error("[PersistentWidget] WebRTC connection error:", error);
      captureException(error, "startWebRTCConnection", {
        timeout: CONNECTION_TIMEOUT_MS,
        isTimeout: error.name === "TimeoutError",
      });

      if (error.name === "TimeoutError") {
        this.handleConnectionFailure(
          t("err_connect_timeout", "The connection timed out. Please check your network and try again.")
        );
      } else {
        this.handleConnectionFailure(
          t("err_connection_generic", "Connection error: {msg}").replace("{msg}", error.message)
        );
      }
    }
  }

  /**
   * タイムアウト付きで接続を実行 (Issue #413)
   *
   * timeout 時:
   *   1. AbortController.abort() で内部 fetch / startSession を中止
   *   2. connector.disconnect() で PeerConnection / MediaStream を確実に解放
   *   3. 連続 connect / reconnect 時に古い接続が残らないことを担保
   *
   * 古い接続試行の遅延成功を無視するため attempt id を持たせる。
   */
  async connectWithTimeout(timeoutMs) {
    const attemptId = (this._connectAttemptCounter = (this._connectAttemptCounter || 0) + 1);
    const ac = new AbortController();
    return new Promise((resolve, reject) => {
      let timeoutId = null;
      let isResolved = false;

      const finalize = (fn, payload) => {
        if (isResolved) return;
        isResolved = true;
        if (timeoutId) clearTimeout(timeoutId);
        // 古い試行 (attempt id ミスマッチ) なら成功扱いせず Abort で必ず settle する。
        // (旧: `if (fn === resolve) return;` は Promise を pending のまま残すバグ)
        if (this._connectAttemptCounter !== attemptId) {
          ac.abort();
          if (fn === resolve) {
            reject(new DOMException("connect attempt superseded", "AbortError"));
            return;
          }
        }
        fn(payload);
      };

      timeoutId = setTimeout(() => {
        if (isResolved) return;
        const error = new Error(
          t("err_connect_timeout_internal", "Connection timed out ({ms}ms)").replace("{ms}", timeoutMs)
        );
        error.name = "TimeoutError";
        // SDP fetch / ICE 収集を abort し、PeerConnection を解放
        ac.abort();
        try {
          this.connector?.disconnect?.();
        } catch (e) {
          // disconnect は best-effort
          if (typeof console !== "undefined") console.warn("[connectWithTimeout] disconnect error:", e?.message);
        }
        finalize(reject, error);
      }, timeoutMs);

      this.connector
        .connect({
          instructions: this.currentInstructions,
          // dev.63: 保存済みの明示選択マイクで接続を開始する(ideal 制約なので
          // デバイス不在時は既定マイクへフォールバックし接続は成立する)。
          deviceId: this._preferredMicDeviceId() || null,
          sessionConfig: this._lastSessionConfig,
          toolsEnabled: this.cfg.tools_enabled,
          voiceSettings: {
            voice: this._lastSessionConfig?.voice_settings?.voice || this.cfg.realtime_voice,
            transcription_language: this._lastSessionConfig?.voice_settings?.transcription_language || this.cfg.realtime_transcription_language,
            turn_eagerness: this._lastSessionConfig?.voice_settings?.turn_eagerness || this.cfg.realtime_turn_eagerness,
          },
          signal: ac.signal,
        })
        .then((success) => finalize(resolve, success))
        .catch((error) => finalize(reject, error));
    });
  }

  /**
   * 接続失敗時の共通処理 → テキストモードにフォールバック
   */
  handleConnectionFailure(message) {
    if (this.el.connectionIndicator) {
      this.el.connectionIndicator.classList.add("agent--hidden");
    }
    this.addMessage(`⚠️ ${message}`, "system");

    // テキストモードにフォールバック
    this.mode = "text";
    this._hideRealtimeUI();
    this.el.statusText.textContent = t("status_text_mode", "Text mode");
    this.el.statusDot.classList.remove("agent__status-dot--connecting", "agent__status-dot--connected");

    // 音声直接開始モードからのフォールバック時: テキスト greeting を送信
    // (fire-and-forget: 接続失敗経路なので追加の error handling は不要)
    if (!this._greetingSent) {
      void this._triggerGreeting();
    }
  }

  // ============================================
  // Instruction Layers & Context
  // ============================================

  /**
   * Instruction レイヤーを一括送信
   */
  async sendInstructionLayers() {
    this.currentInstructions = await sendInstructionLayersUtil({
      currentInstructions: this.currentInstructions,
      connector: this.connector,
      log: (msg) => console.log(`[PersistentWidget] ${msg}`),
      loadContextLater: () => this.loadContextLater(),
      onInstructionsUpdated: (updated) => {
        this.currentInstructions = updated;
      },
    });
  }

  /**
   * ユーザーコンテキストを後追い取得して送信
   */
  async loadContextLater() {
    this.currentInstructions = await loadContextLaterUtil({
      currentInstructions: this.currentInstructions,
      connector: this.connector,
      log: (msg) => console.log(`[PersistentWidget] ${msg}`),
      fetchUserContext: (userId) => this.fetchUserContext(userId),
      onInstructionsUpdated: (updated) => {
        this.currentInstructions = updated;
      },
    });
  }

  /**
   * ユーザーコンテキスト取得（記憶機能・チャットログ機能は削除済み — 常に null。
   * サーバー側 (realtime-token エンドポイント) で instructions が確定するため、
   * クライアント側での事後コンテキスト注入は不要 — D3/D4）
   */
  async fetchUserContext(_userId) {
    return null;
  }

  // ============================================
  // Waveform 波形表示
  // ============================================

  async setupWaveformVisualization() {
    // voiceUi.visualizer === 'none' (_applyVoiceUiConfig()): マイク権限プロンプトも
    // AudioContext/analyser の構築も一切行わない。
    if (this._voiceUiVisualizerMode === "none") return;
    try {
      if (!this.audioContext) {
        this.audioContext = new (globalThis.AudioContext || globalThis.webkitAudioContext)();
      }

      // suspend→resume を繰り返すたびに毎回 createMediaStreamSource() /
      // createAnalyser() で新しいノードを作るため、古いノードを disconnect
      // せずに参照だけ上書きすると AudioContext 内に孤児ノードが蓄積し続ける
      // (AudioContext は disconnectRealtime() で完全破棄されるまで使い回される)。
      // 新規ノード作成前に必ず切り離す。disconnect() は既に閉じた context 等で
      // 例外を投げうるため try/catch で握りつぶす (reconnectWaveformSource() と同じ流儀)。
      if (this.micSourceNode) {
        try {
          this.micSourceNode.disconnect();
        } catch (e) {
          console.warn("[PersistentWidget] failed to disconnect stale micSourceNode:", e?.message ?? String(e));
        }
        this.micSourceNode = null;
      }
      if (this.analyser) {
        try {
          this.analyser.disconnect();
        } catch (e) {
          console.warn("[PersistentWidget] failed to disconnect stale analyser:", e?.message ?? String(e));
        }
        this.analyser = null;
      }

      // コネクタのマイクストリームを使用
      if (this.connector?.mediaStream) {
        this.micStream = this.connector.mediaStream;
      } else if (navigator.mediaDevices?.getUserMedia) {
        this.micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
      } else {
        console.warn("[PersistentWidget] mediaDevices is not available (insecure context or missing iframe permission)");
        return;
      }

      this.micSourceNode = this.audioContext.createMediaStreamSource(this.micStream);
      this.analyser = this.audioContext.createAnalyser();
      this.analyser.fftSize = 256;
      this.micSourceNode.connect(this.analyser);

      // High-DPI canvas setup
      this._setupHiDpiCanvas();

      this.drawWaveform();
      console.log("[PersistentWidget] waveform visualization started");
    } catch (error) {
      console.warn("[PersistentWidget] waveform visualization failed to initialize:", error.message);
      captureException(error, "setupWaveformVisualization");
    }
  }

  _setupHiDpiCanvas() {
    const canvas = this.el.waveformCanvas;
    if (!canvas) return;
    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.parentElement.getBoundingClientRect();
    const cssWidth = rect.width > 0 ? rect.width : 200;
    const cssHeight = 28;
    canvas.width = Math.round(cssWidth * dpr);
    canvas.height = Math.round(cssHeight * dpr);
    canvas.style.width = cssWidth + "px";
    canvas.style.height = cssHeight + "px";
    const ctx = canvas.getContext("2d");
    if (ctx) ctx.scale(dpr, dpr);
  }

  /** CSS 変数からカラーを取得（canvas 描画用 — ヘッダーと同配色） */
  _getWaveformColors() {
    const root = document.documentElement;
    const style = getComputedStyle(root);
    const headerText = style.getPropertyValue("--agent-header-text").trim() || "#ffffff";
    return {
      barActive: headerText,
      barInactive: headerText,
      barActiveAlpha: 1.0,
      barInactiveAlpha: 0.4,
    };
  }

  drawWaveform() {
    if (!this.analyser || !this.el.waveformCanvas) return;

    const canvas = this.el.waveformCanvas;
    const ctx = canvas.getContext("2d");
    const bufferLength = this.analyser.frequencyBinCount;
    const dataArray = new Uint8Array(bufferLength);

    // CSS変数からカラー取得（起動時1回のみ）
    const colors = this._getWaveformColors();

    let lastFrameTime = 0;
    const frameInterval = 1000 / 30;

    const draw = (currentTime) => {
      // teardown (stopWaveformVisualization / cleanupRealtimeSession で analyser=null) 後に
      // 既にキュー済みの frame が発火して this.analyser を null 参照するのを防ぐ。
      // また drawWaveform が複数回呼ばれて orphan loop が生じても、analyser 消失で自己終了する。
      // analyser が無ければ再スケジュールせず loop を止める。
      if (!this.analyser || !this.el.waveformCanvas) {
        this.animationFrameId = null;
        return;
      }
      this.animationFrameId = requestAnimationFrame(draw);

      if (currentTime - lastFrameTime < frameInterval) return;
      lastFrameTime = currentTime;

      this.analyser.getByteFrequencyData(dataArray);

      // 背景クリア
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      const barWidth = (canvas.width / bufferLength) * 2.5;
      let x = 0;

      ctx.globalAlpha = this.isSpeechActive
        ? colors.barActiveAlpha
        : colors.barInactiveAlpha;
      ctx.fillStyle = colors.barActive;

      for (let i = 0; i < bufferLength; i++) {
        const barHeight = (dataArray[i] / 255) * canvas.height;
        const radius = Math.min(barWidth / 2, 2);
        if (barHeight > 0) {
          ctx.beginPath();
          ctx.roundRect(x, canvas.height - barHeight, barWidth, barHeight, radius);
          ctx.fill();
        }
        x += barWidth + 1;
      }
      ctx.globalAlpha = 1.0;
    };

    // 初回 frame も id を追跡し、stopWaveformVisualization で確実に cancel できるようにする。
    this.animationFrameId = requestAnimationFrame(draw);
  }

  stopWaveformVisualization() {
    if (this.animationFrameId) {
      cancelAnimationFrame(this.animationFrameId);
      this.animationFrameId = null;
    }
  }

  // ============================================
  // ミュート切替
  // ============================================

  toggleMicMute() {
    this.isMicMuted = !this.isMicMuted;
    if (this.el.btnMicMute) {
      this.el.btnMicMute.classList.toggle("agent__mute-btn--muted", this.isMicMuted);
      this._setIcon(this.el.btnMicMute, this.isMicMuted ? "mic-mute-fill" : "mic-fill");
      this.el.btnMicMute.title = this.isMicMuted ? t("btn_mic_muted_title", "Microphone muted") : t("btn_mic_on_title", "Microphone on");
    }

    this.connector?.mediaStream?.getAudioTracks().forEach((track) => {
      track.enabled = !this.isMicMuted;
    });
    console.log(`[PersistentWidget] microphone ${this.isMicMuted ? "muted" : "unmuted"}`);
  }

  toggleSpeakerMute() {
    this.isSpeakerMuted = !this.isSpeakerMuted;
    if (this.el.btnSpeakerMute) {
      this.el.btnSpeakerMute.classList.toggle("agent__mute-btn--muted", this.isSpeakerMuted);
      this._setIcon(this.el.btnSpeakerMute, this.isSpeakerMuted ? "volume-mute-fill" : "volume-up-fill");
      this.el.btnSpeakerMute.title = this.isSpeakerMuted ? t("btn_speaker_muted_title", "Speaker muted") : t("btn_speaker_on_title", "Speaker on");
    }

    if (this.connector && typeof this.connector.setRemoteAudioMuted === "function") {
      this.connector.setRemoteAudioMuted(this.isSpeakerMuted);
    }
    console.log(
      `[PersistentWidget] speaker ${this.isSpeakerMuted ? "muted" : "unmuted"}`
    );
  }

  // ミュート UI 状態 (フラグ + ボタンの class/icon/title) を初期状態に戻す (Issue #525)。
  // connector への sync は呼び出し側 disconnect が担うのでここでは触らない。
  _resetMuteUiState() {
    this.isMicMuted = false;
    this.isSpeakerMuted = false;
    if (this.el.btnMicMute) {
      this.el.btnMicMute.classList.remove("agent__mute-btn--muted");
      this._setIcon(this.el.btnMicMute, "mic-fill");
      this.el.btnMicMute.title = t("btn_mic_on_title", "Microphone on");
    }
    if (this.el.btnSpeakerMute) {
      this.el.btnSpeakerMute.classList.remove("agent__mute-btn--muted");
      this._setIcon(this.el.btnSpeakerMute, "volume-up-fill");
      this.el.btnSpeakerMute.title = t("btn_speaker_on_title", "Speaker on");
    }
  }

  // ============================================
  // マイクデバイス選択
  // ============================================

  /** dev.63: 明示選択の読み出し。Storage 不可環境では null(=既定追従)。 */
  _loadStoredMicDeviceId() {
    try {
      return localStorage.getItem(MIC_DEVICE_STORAGE_KEY) || null;
    } catch (e) {
      return null;
    }
  }

  /** dev.63: 明示選択の保存。null でシステム既定追従に戻す(キー削除)。 */
  _storeMicDeviceId(deviceId) {
    this._storedMicDeviceId = deviceId || null;
    try {
      if (deviceId) {
        localStorage.setItem(MIC_DEVICE_STORAGE_KEY, deviceId);
      } else {
        localStorage.removeItem(MIC_DEVICE_STORAGE_KEY);
      }
    } catch (e) {
      // Storage 不可環境はメモリ上の _storedMicDeviceId だけで動く
      // (このセッション中は有効・次回訪問では既定追従に戻る)。
    }
  }

  /**
   * dev.63: 接続/復帰に渡すマイクデバイスID。セッション中の現用IDを最優先、
   * なければ永続化された明示選択。どちらも無ければ undefined(既定マイク)。
   * 呼び出し先は ideal 制約なので、保存デバイスが抜かれていても既定へ
   * フォールバックして会話は始まる(その場合の表示名は実トラックに追従)。
   */
  _preferredMicDeviceId() {
    return this.currentMicDeviceId || this._storedMicDeviceId || undefined;
  }

  async populateMicDevices() {
    if (!navigator.mediaDevices?.enumerateDevices) {
      console.warn("[PersistentWidget] cannot enumerate devices because mediaDevices is unavailable");
      return;
    }
    try {
      const devices = await navigator.mediaDevices.enumerateDevices();
      // dev.63: Chrome の仮想エントリ ("default" / Windows の "communications")
      // は物理デバイスの複製で、選んでも getSettings() は物理IDに解決される
      // ため一覧のハイライトがずれる元だった。物理デバイスだけを列挙し、
      // 「システム既定に従う」は下で合成する専用エントリが担う。
      const micDevices = devices.filter(
        (d) =>
          d.kind === "audioinput" &&
          d.deviceId &&
          d.deviceId !== "default" &&
          d.deviceId !== "communications"
      );

      // キャレットボタンを表示（デバイス数によらず常時表示）
      if (this.el.btnMicSelect) {
        this.el.btnMicSelect.classList.remove("agent--hidden");
      }

      // マイクグループのスタイル切替
      const micGroup = this.el.btnMicSelect?.closest(".agent__mic-group");
      if (micGroup) {
        micGroup.classList.add("agent__mic-group--multi");
      }

      // 現在のデバイスIDを取得。dev.57 W2 レビュー修正S2: トラックが live の
      // ときだけ復元する — handleMicrophoneLost() が currentMicDeviceId を
      // null 化した直後にここが呼ばれると、死んだ(ended)トラックの
      // getSettings().deviceId で即座に復元してしまい、null 化した意味
      // (同一デバイス再選択ガードの解除) が失われる。
      if (!this.currentMicDeviceId && this.connector?.mediaStream) {
        const tracks = this.connector.mediaStream.getAudioTracks();
        if (tracks.length > 0 && tracks[0].readyState === "live") {
          const settings = tracks[0].getSettings();
          this.currentMicDeviceId = settings.deviceId || null;
        }
      }

      // リスト生成
      if (!this.el.micDeviceList) return;
      this.el.micDeviceList.innerHTML = "";

      if (micDevices.length === 0) {
        const li = document.createElement("li");
        li.className = "agent__mic-device-item";
        li.textContent = t("mic_not_found", "No microphone found");
        li.style.opacity = "0.5";
        this.el.micDeviceList.appendChild(li);
        return;
      }

      // dev.63: 「システム既定」を常に先頭に置く(Zoom/Meet と同じ概念)。
      // 明示選択が保存されていない間はこれをハイライトする。選択すると
      // 保存をクリアし deviceId 制約なしで取り直す(=OS の既定に追従)。
      const defaultLi = document.createElement("li");
      defaultLi.className = "agent__mic-device-item";
      if (!this._storedMicDeviceId) {
        defaultLi.classList.add("agent__mic-device-item--active");
      }
      defaultLi.textContent = t("mic_system_default", "System default");
      defaultLi.addEventListener("click", () =>
        this.handleMicDeviceChange(null, { systemDefault: true })
      );
      this.el.micDeviceList.appendChild(defaultLi);

      micDevices.forEach((device, index) => {
        const li = document.createElement("li");
        li.className = "agent__mic-device-item";
        // dev.63: ハイライトは「明示選択あり」の場合だけ物理デバイスに付ける。
        // 既定追従中は currentMicDeviceId が既定マイクの物理IDを指していても
        // 「システム既定」側が active(上) — 実デバイス名はヘッダー表示が担う。
        if (this._storedMicDeviceId && device.deviceId === this.currentMicDeviceId) {
          li.classList.add("agent__mic-device-item--active");
        }
        li.textContent = device.label || t("mic_label_fallback", "Microphone {n}").replace("{n}", index + 1);
        li.addEventListener("click", () =>
          this.handleMicDeviceChange(device.deviceId)
        );
        this.el.micDeviceList.appendChild(li);
      });

      console.log(
        `[PersistentWidget] microphone devices: ${micDevices.length}`
      );
    } catch (error) {
      console.warn(
        "[PersistentWidget] failed to enumerate microphone devices:",
        error.message
      );
    }
  }

  toggleMicPopover() {
    if (this.isMicPopoverOpen) {
      this.closeMicPopover();
    } else {
      this.openMicPopover();
    }
  }

  async openMicPopover() {
    await this.populateMicDevices();
    if (this.el.micDevicePopover) {
      this.el.micDevicePopover.classList.remove("agent--hidden");
    }
    this.isMicPopoverOpen = true;
  }

  closeMicPopover() {
    if (this.el.micDevicePopover) {
      this.el.micDevicePopover.classList.add("agent--hidden");
    }
    this.isMicPopoverOpen = false;
  }

  /**
   * Issue #288: 会話メニュー (kebab) のセットアップ。
   * - kebab ボタンクリックで dropdown 開閉 (aria-expanded を同期)
   * - dropdown 外クリック / ESC で閉じる
   * - 「新しい会話を始める」クリックで chatBody クリア + sessionId リセット
   *
   * EJS テンプレートに当該要素が無い (旧テナント) 場合は no-op で抜ける。
   */
  _setupConversationMenu() {
    const btn = this.el.btnConversationMenu;
    const dropdown = this.el.conversationMenuDropdown;
    if (!btn || !dropdown) return;

    // PR #553 AI Review round1 low #2: document リスナーを匿名クロージャで
    // 登録すると removeEventListener できず将来 SPA 化・再初期化時にリークする。
    // AbortController で参照を保持し、cleanupOnUnload で abort できるようにする。
    // (既存 popover 系の document リスナーは line 595-603 に同様の匿名登録があるが、
    //  本機能は新規追加なのでクリーンアップ可能な設計を最初から採用する)
    this._conversationMenuAbort?.abort();
    const ac = new AbortController();
    this._conversationMenuAbort = ac;
    const opts = { signal: ac.signal };

    btn.addEventListener("click", (e) => {
      e.stopPropagation();
      const hidden = dropdown.classList.contains("agent--hidden");
      if (hidden) {
        this._openConversationMenu();
      } else {
        this._closeConversationMenu();
      }
    }, opts);

    // dropdown 外クリックで閉じる
    document.addEventListener("click", (e) => {
      if (dropdown.classList.contains("agent--hidden")) return;
      if (dropdown.contains(e.target) || btn.contains(e.target)) return;
      this._closeConversationMenu();
    }, opts);

    // ESC で閉じる
    document.addEventListener("keydown", (e) => {
      if (e.key !== "Escape") return;
      if (dropdown.classList.contains("agent--hidden")) return;
      this._closeConversationMenu();
      btn.focus();
    }, opts);

    // 「新しい会話を始める」
    this.el.menuItemNewConversation?.addEventListener("click", () => {
      this._closeConversationMenu();
      this._handleNewConversation();
    }, opts);
  }

  _openConversationMenu() {
    const btn = this.el.btnConversationMenu;
    const dropdown = this.el.conversationMenuDropdown;
    if (!btn || !dropdown) return;
    dropdown.classList.remove("agent--hidden");
    btn.setAttribute("aria-expanded", "true");
    // 1 番目の menuitem にフォーカスして a11y 上の操作起点を作る
    const firstItem = dropdown.querySelector('[role="menuitem"]');
    firstItem?.focus();
  }

  _closeConversationMenu() {
    const btn = this.el.btnConversationMenu;
    const dropdown = this.el.conversationMenuDropdown;
    if (!btn || !dropdown) return;
    dropdown.classList.add("agent--hidden");
    btn.setAttribute("aria-expanded", "false");
  }

  /**
   * Issue #288: 「新しい会話を始める」のアクション本体。
   *
   * 仕様:
   *   - chatBody から表示中の bot / user / system メッセージを全て撤去する
   *     (welcome / quickActions など home-view 配下は別 DOM なので影響なし)
   *   - currentSessionId / textChat.sessionId をリセットし、次回送信時に新規発番させる
   *   - メモリポーリングや beacon は触らない (既存セッションの後片付けは onbeforeunload 等に委譲)
   *
   * 注意: DB 側の会話履歴 (chat_logs) は本機能では削除しない。
   *       UI から見える履歴を消すだけ。完全削除は別 issue (#248) で扱う。
   *
   * PR #553 AI Review round2 medium #1:
   *   音声モード中に押された場合、WebRTC DataChannel を切らないと旧セッションの
   *   AI 応答 (音声 / テキスト) がクリア後の UI に混入する。kebab はヘッダー常設で
   *   音声中でも押せるため、ここで明示的に disconnectRealtime() を呼んで切断する。
   */
  _handleNewConversation() {
    // 契約 B-3: suspend 中 (mode==="text" だが connector がセッションを保持中) の
    // 待機タイマーは、ここで完全破棄する以上もう不要なので解除する
    // (非 suspend 時も含め常に呼んでよい — タイマー未設定なら no-op)。
    this._clearSuspendTimer();

    // realtime セッションが進行中、または suspend 中 (裏でセッション保持中) なら切断
    // (DataChannel の混入を防ぐ)。suspend 中のセッションはサーバー側に会話コンテキストを
    // 保持しているため、新しい会話で使い回すと文脈が汚染される (契約 B-3)。
    // PR #553 AI Review round3 medium #1:
    //   disconnectRealtime() だけだと this.mode が "realtime" のまま残り、
    //   waveformBar / realtimeControls の UI が表示されたままになる。
    //   他の realtime→text 遷移 (例: 音声→テキスト戻り line 1857-1859) と
    //   揃え、mode を "text" にリセット + _hideRealtimeUI() を呼ぶ。
    const isSuspended = this.connector?.isSuspended?.() === true;
    if (this.mode === "realtime" || isSuspended) {
      // switchToTextMode と同様: in-flight な接続 chain を bail させるため epoch を +1。
      // disconnectRealtime() の前に bump する。
      this._connectEpoch = (this._connectEpoch || 0) + 1;
      try {
        this.disconnectRealtime();
      } catch (e) {
        console.warn("[PersistentWidget] disconnectRealtime during new-conversation failed:", e);
      }
      this.mode = "text";
      this._hideRealtimeUI();
    }

    // チャット表示のクリア
    if (this.el.chatBody) {
      this.el.chatBody.querySelectorAll(".agent__message").forEach((el) => el.remove());
      // welcome 系の補助 DOM (retry card 等) も併せて消す
      this.el.chatBody.querySelectorAll(".agent__error-card, .agent__follow-up, .agent__realtime-retry, .agent__mic-retry").forEach((el) => el.remove());
    }

    // ストリーミング状態のリセット (新セッション分の bubble が古い id と衝突しないよう defensively)
    this.currentStreamingElement = null;
    this.currentStreamingMessageId = null;
    if (this._streamingElements) {
      this._streamingElements.clear();
    }
    this.displayedMessageIds?.clear?.();

    // session id を破棄 (次回 send で新規発番)
    this.currentSessionId = null;
    // Issue #616: pending な startConnectionTiming Promise も破棄 (新規発番後の旧 ID 返却を防止)
    this._sessionTimingPromise = null;
    if (this.textChat) {
      this.textChat.sessionId = null;
    }
    this.toolHandler?.setSessionId?.(null);

    // 0.3.0-dev.45: 会話履歴を本当にクリアする。従来は chatBody の DOM だけ消して
    // textChat.messages / _realtimeMessages が残っていたため、
    //  (1) _hasConversation() が true のままでクイックアクションが復活しない
    //  (2) unload 時の saveSession が旧履歴を再保存し、リロードで「消したはずの
    //      会話」が復元される
    // という不整合が起きていた (clearHistory() は定義のみで未使用だった)。
    this.textChat?.clearHistory?.();
    this._realtimeMessages = [];
    clearSession();
    // 会話ゼロに戻ったので、次の会話で挨拶を再送できるようにする
    this._greetingSent = false;
    this._pendingGreetingQuestion = null;

    // クイックアクションチップを復活 (「会話中は出さない」規約を再び満たすため)。
    // _scheduleQuickActionsFallback と同じスナップショット復元方式。
    const qa = this.el.quickActions;
    if (qa && this._initialQuickActionsHTML && this._initialQuickActionsHTML.trim().length > 0) {
      qa.innerHTML = this._initialQuickActionsHTML;
      qa.classList.remove("agent--hidden");
    }
  }

  /**
   * Issue #288: bot メッセージのコピーボタンハンドラ。
   *
   * - bubble.textContent を clipboard に書き込む (markdown は plain text 化される)
   * - clipboard API 不可 (古いブラウザ / 安全でない context) は execCommand fallback
   * - 成功時は 1.5 秒だけ `--copied` クラス + ✓ アイコンに切替えて視覚フィードバック
   * - 1500ms ウィンドウ内の連打は `dataset.copying` でガードする
   *   (PR #553 AI Review round1 low #1: 旧実装は prevHtml = btn.innerHTML を
   *    `--copied` 適用後に取得していたため、2 回目クリックの prevHtml が
   *    `<i class="bi bi-check2">` になりアイコンがチェックのまま固定される
   *    競合状態があった。復元 HTML を定数化 + dataset ガードの 2 重で防ぐ)
   */
  async _handleCopyMessage(btn, bubble) {
    if (!bubble || !btn) return;
    if (btn.dataset.copying === "1") return;
    const text = (bubble.textContent || "").trim();
    if (!text) return;

    let ok = false;
    // Issue #288: Mac/Chromium の cross-origin iframe で Permissions-Policy により
    // navigator.clipboard.writeText が NotAllowedError で reject されるケースがある
    // (parent 側 iframe の allow="clipboard-write" delegate 漏れが本来の原因)。
    // 親側修正に加えて、当 widget 側でも writeText 失敗を catch して execCommand
    // fallback に落とすことで Permissions-Policy が剥奪されている環境でもコピー可能にする。
    if (navigator.clipboard?.writeText) {
      try {
        await navigator.clipboard.writeText(text);
        ok = true;
      } catch (e) {
        console.warn("[PersistentWidget] navigator.clipboard.writeText failed, falling back to execCommand:", e);
      }
    }

    if (!ok) {
      // legacy / Permissions-Policy ブロック時の fallback。
      // PR #553 AI Review round3 low #1: document.execCommand("copy") は MDN で
      // deprecated 指定だが、現時点で主要ブラウザは互換性のために実装維持中。
      // サポート対象を引き上げた場合 (iOS Safari 13.4 切り捨て等) は当 fallback ごと削除を検討。
      try {
        const ta = document.createElement("textarea");
        ta.value = text;
        ta.setAttribute("readonly", "");
        ta.style.position = "absolute";
        ta.style.left = "-9999px";
        document.body.appendChild(ta);
        ta.select();
        try {
          ok = document.execCommand("copy");
        } finally {
          ta.remove();
        }
      } catch (e) {
        // execCommand("copy") への代入式が throw した場合は代入未完了のため
        // ok は false のまま維持される (再代入不要)。
        console.warn("[PersistentWidget] execCommand copy fallback failed:", e);
      }
    }

    if (ok) {
      btn.dataset.copying = "1";
      btn.classList.add("agent__msg-action-btn--copied");
      btn.innerHTML = '<i class="bi bi-check2" aria-hidden="true"></i>';
      btn.setAttribute("aria-label", t("aria_copied", "Copied"));
      setTimeout(() => {
        // PR #553 AI Review round2 low #1: setTimeout コールバック中に
        // _handleNewConversation 等で btn が DOM から detach されている可能性がある。
        // detach 済みノードを書き換えても実害は無いがゴミ操作なので skip する。
        if (!btn.isConnected) return;
        btn.classList.remove("agent__msg-action-btn--copied");
        // 復元 HTML はソース上の定数 (初期描画と同一) を使う。
        // btn.innerHTML から取得すると競合時にチェックアイコンを誤キャプチャする。
        btn.innerHTML = '<i class="bi bi-clipboard" aria-hidden="true"></i>';
        btn.setAttribute("aria-label", t("aria_copy_message", "Copy message"));
        delete btn.dataset.copying;
      }, 1500);
    }
  }

  /**
   * リアルタイムUI（ウェーブバー + コントロール行）を表示
   *
   * M fix (0.3.0-dev.48): マイクボタン (btnVoice) の表示切替をここに一元化する。
   * 従来は switchToRealtimeMode() / switchToTextMode() / handleConnectionFailure() /
   * handleMicDeniedFallback() / _showReconnectFailedUI() の5箇所それぞれで
   * `if (this.el.btnVoice) this.el.btnVoice.classList.add/remove("agent--hidden")` を
   * 個別に書いていたため、_handleNewConversation() の realtime→text 遷移だけが
   * この対応処理を書き忘れ、「新しい会話を始める」後にマイクボタンが復活しない
   * 不具合が発生していた。_showRealtimeUI() / _hideRealtimeUI() は必ず
   * 「音声UIを表示するときはマイクボタンを隠す／隠すときはマイクボタンを表示する」
   * というペアで呼ばれる不変条件があるため（全呼び出し箇所を確認済み）、
   * ここに寄せることで呼び出し漏れが構造的に起きなくなる。
   */
  _showRealtimeUI() {
    this.el.waveformBar?.classList.remove("agent--hidden");
    this.el.realtimeControls?.classList.remove("agent--hidden");
    if (this.el.btnVoice) this.el.btnVoice.classList.add("agent--hidden");
  }

  /** リアルタイムUI を非表示 (マイクボタンの再表示も含む。上記 _showRealtimeUI のコメント参照) */
  _hideRealtimeUI() {
    this.el.waveformBar?.classList.add("agent--hidden");
    this.el.realtimeControls?.classList.add("agent--hidden");
    if (this.el.btnVoice) this.el.btnVoice.classList.remove("agent--hidden");
  }

  /** 使用中マイクのデバイス名を表示 */
  _updateMicDeviceName() {
    if (!this.el.micDeviceName) return;
    const stream = this.connector?.mediaStream || this.micStream;
    if (!stream) {
      this.el.micDeviceName.textContent = t("mic_device_name_default", "Microphone");
      return;
    }
    const track = stream.getAudioTracks()[0];
    if (track) {
      const label = track.label || t("mic_device_name_default", "Microphone");
      this.el.micDeviceName.textContent = label;
      this.el.micDeviceName.title = label;
    }
  }

  /**
   * dev.57 W2 修正4: connector.getMediaStream() にタイムアウト (判断事項D:
   * 20秒) を付けるラッパー。デバイス列挙/取得が OS 側で詰まって無期限に
   * 待たされる (=画面フリーズに見える) のを防ぐ。タイムアウト後に遅れて
   * 解決したストリームは孤児化させず即座に停止する。
   */
  _getMicStreamWithTimeout(deviceId, timeoutMs, gumOptions) {
    const pending = this.connector.getMediaStream(deviceId, gumOptions);
    let timeoutId = null;
    let timedOut = false;
    const timeout = new Promise((_, reject) => {
      timeoutId = setTimeout(() => {
        timedOut = true;
        reject(new Error(`microphone switch timed out after ${timeoutMs}ms`));
      }, timeoutMs);
    });
    return Promise.race([pending, timeout]).finally(() => {
      clearTimeout(timeoutId);
      if (timedOut) {
        // 遅れて解決した孤児ストリームを止める（例外は握りつぶす — 既に
        // タイムアウトエラーで呼び出し元へは通知済み）。
        pending
          .then((s) => s?.getTracks?.().forEach((tr) => tr.stop()))
          .catch(() => {});
      }
    });
  }

  /**
   * dev.57 W2 修正5: マイク切替中の軽量ビジーUI。一覧を操作不能にし、
   * 支援技術には aria-busy で伝える。
   */
  _setMicSwitchBusy(busy) {
    if (this.el.micDeviceList) {
      this.el.micDeviceList.classList.toggle("agent__mic-device-list--busy", busy);
      if (busy) {
        this.el.micDeviceList.setAttribute("aria-busy", "true");
      } else {
        this.el.micDeviceList.removeAttribute("aria-busy");
      }
    }
    if (this.el.btnMicSelect) {
      this.el.btnMicSelect.disabled = busy;
    }
  }

  async handleMicDeviceChange(deviceId, { systemDefault = false } = {}) {
    // dev.63: systemDefault=true は「システム既定に従う」の明示選択。
    // deviceId なしで取得し直し、永続化をクリアする。
    if (!deviceId && !systemDefault) {
      this.closeMicPopover();
      return;
    }

    // dev.57 W2 修正2: 同一デバイス再選択ガードは「現用トラックがまだ
    // live の場合だけ」no-op にする。物理切断 (ended) 後にユーザーが同じ
    // デバイスを選び直すケース (handleMicrophoneLost() が
    // currentMicDeviceId を null にする分は無関係だが、null 化前に連打
    // された場合や ended 未検知の場合の保険) では再取得を許可しないと
    // 詰んだままになる。
    // dev.63: システム既定は「既に既定追従中(明示選択の保存なし)」なら no-op。
    const activeTrack = this.connector?.mediaStream?.getAudioTracks?.()[0];
    const sameAsCurrent = systemDefault
      ? !this._storedMicDeviceId
      : deviceId === this.currentMicDeviceId;
    if (sameAsCurrent && activeTrack && activeTrack.readyState === "live") {
      this.closeMicPopover();
      return;
    }

    // dev.57 W2 修正3: 再入ガード。切替が完走する前に別デバイスをもう一度
    // 選ばれても gUM() を二重に走らせない。
    if (this._micSwitchInProgress) {
      console.warn("[PersistentWidget] handleMicDeviceChange() already in progress — ignoring duplicate call");
      return;
    }
    this._micSwitchInProgress = true;
    // ★ _connectEpoch は capture のみでインクリメントしない。ここで増やすと
    // 同時に進行中の接続/resume 側の epoch ガードまで巻き添えで bail させて
    // しまう（マイク切替はそれらとは独立した操作のため）。
    const epoch = this._connectEpoch;
    this._setMicSwitchBusy(true);

    console.log(`[PersistentWidget] switching microphone device: ${systemDefault ? "(system default)" : deviceId}`);

    // catch スコープからも参照できるよう try の外で宣言する(下記 catch 参照)。
    let newStream = null;
    try {
      // 新しいストリームを取得（20秒タイムアウト付き）。
      // dev.63: 物理デバイスの明示選択は exact 制約 — ideal だとブラウザが
      // エラーなしで別デバイスへ黙ってフォールバックでき、「切り替えたのに
      // 変わらない」の根本原因だった。exact の失敗(OverconstrainedError)は
      // 下の catch が「切替失敗」表示+旧マイク維持で受ける。
      newStream = await this._getMicStreamWithTimeout(
        systemDefault ? null : deviceId,
        MIC_SWITCH_TIMEOUT_MS,
        { exactDevice: !systemDefault }
      );

      // post-await ガード: await 中に suspend/より新しい切替/画面遷移が
      // 割り込んでいたら、取得済みの新ストリームを即座に止めて何もしない
      // (dev.56 A-3 の強化: suspend 済みセッションへ replaceTrack してしまい、
      // テキスト画面のまま音声が OpenAI へ流れ続ける事故を塞ぐ)。
      // ★ connector.getIsConnected() は条件に含めない — isConnected は
      // connect() 成功時にのみ true になる一方通行ラッチで、PeerConnection が
      // 一時的に "disconnected" から "connected" へ復帰した後も false のまま
      // 戻らない。含めると、実際には接続済み(UI表示も音声も生きている)状態の
      // マイク切替が毎回無言で bail する退行になる(レビュー実測)。suspend 済み
      // セッションへの誤 replaceTrack 防止という本来の目的は isSuspended() と
      // mode の2条件で十分満たされている。
      if (
        this._connectEpoch !== epoch ||
        this.mode !== "realtime" ||
        this.connector?.isSuspended?.() === true
      ) {
        newStream.getTracks().forEach((tr) => tr.stop());
        this.closeMicPopover();
        return;
      }

      const newTrack = newStream.getAudioTracks()[0];

      // WebRTC peer connection のオーディオトラックを置換。旧トラックの停止
      // より必ず先に行う — 失敗時に旧マイクを生かしたままロールバックできる
      // ようにするため。
      if (this.connector?.peerConnection) {
        const senders = this.connector.peerConnection.getSenders();
        const audioSender = senders.find(
          (s) => s.track?.kind === "audio"
        );
        if (audioSender) {
          await audioSender.replaceTrack(newTrack);

          // ミュート状態を新トラックに反映
          if (this.isMicMuted) {
            newTrack.enabled = false;
          }

          // replaceTrack() の await 中に再度状態が変わっていないか検査する。
          // ★ ここも getIsConnected() は見ない(理由は上の post-await ガードと
          // 同じ — 一方通行ラッチのため復帰後の切替を殺してしまう)。
          if (
            this._connectEpoch !== epoch ||
            this.mode !== "realtime" ||
            this.connector?.isSuspended?.() === true
          ) {
            newStream.getTracks().forEach((tr) => tr.stop());
            this.closeMicPopover();
            return;
          }
        }
      }

      // 旧マイクトラックを停止。dev.56 A-8: visualizer='none' 時は this.micStream
      // が未設定のまま connector.mediaStream だけが実際のトラックを保持している
      // ことがあり、micStream だけを見ると旧マイクが孤児化する(録音インジ
      // ケーターが消えない)。両方の参照先を(重複排除して)まとめて停止する。
      // ★ 参照を新ストリームへ更新した後に停止する。順序を戻すと
      // connector._watchMicTrackEnded() の「現用トラックか」同一性チェックが
      // 破れる (修正1 の安全性の要)。
      const oldStreams = new Set([this.micStream, this.connector?.mediaStream].filter(Boolean));

      // 参照を更新。実際に取得できたデバイスIDを記録する。
      this.connector.mediaStream = newStream;
      this.micStream = newStream;
      const settings = typeof newTrack.getSettings === "function" ? newTrack.getSettings() : {};
      this.currentMicDeviceId = settings.deviceId || deviceId;

      // dev.63: exact 制約なので実取得IDは原理上一致するはずだが、仕様外の
      // 挙動をするブラウザの検知網として不一致を警告に残す(UI名は実トラック
      // 追従なので利用者に嘘は出ない)。
      if (!systemDefault && settings.deviceId && settings.deviceId !== deviceId) {
        console.warn(
          `[PersistentWidget] obtained device (${settings.deviceId}) differs from the requested one (${deviceId})`
        );
      }

      // dev.63: 明示選択を永続化(次回接続・resume も同デバイスで開始)。
      // システム既定選択は保存をクリアして OS の既定に追従する。
      this._storeMicDeviceId(systemDefault ? null : (settings.deviceId || deviceId));

      oldStreams.forEach((s) => s.getAudioTracks().forEach((track) => track.stop()));

      // 波形の再接続
      this.reconnectWaveformSource(newStream);

      console.log("[PersistentWidget] microphone device switch complete");
      this._updateMicDeviceName();
      // dev.63: 切替結果の明示フィードバック(成功時は従来無言だった)。
      this.addMessage(
        t("msg_mic_switched", 'Switched the microphone to "{label}".').replace(
          "{label}",
          newTrack.label || t("mic_device_name_default", "Microphone")
        ),
        "system"
      );
      this.closeMicPopover();
    } catch (error) {
      console.error(
        "[PersistentWidget] microphone device switch failed:",
        error.message
      );
      // replaceTrack() 等が throw した場合、newStream が取得済みだが
      // peerConnection にも this.connector.mediaStream にも組み込まれず
      // 孤児化する(録音インジケーターが点いたまま残る)。ここで確実に停止する。
      // currentMicDeviceId は更新しない(同一デバイスガードに阻まれず、
      // 同じデバイスへの再試行が通る性質を維持する)。
      if (newStream) {
        newStream.getAudioTracks().forEach((track) => track.stop());
      }
      this.addMessage(t("msg_mic_switch_failed", "Failed to switch microphone."), "system");
      this.closeMicPopover();
    } finally {
      this._micSwitchInProgress = false;
      this._setMicSwitchBusy(false);
    }
  }

  reconnectWaveformSource(newStream) {
    try {
      if (this.micSourceNode) {
        this.micSourceNode.disconnect();
      }
      if (this.audioContext && this.analyser) {
        this.micSourceNode =
          this.audioContext.createMediaStreamSource(newStream);
        this.micSourceNode.connect(this.analyser);
      }
    } catch (error) {
      console.warn(
        "[PersistentWidget] failed to reconnect the waveform source:",
        error.message
      );
    }
  }

  // ============================================
  // メッセージID管理（重複排除）
  // ============================================

  addDisplayedMessageId(messageId) {
    if (this.displayedMessageIds.size >= this.maxDisplayedMessageIds) {
      const iterator = this.displayedMessageIds.values();
      const oldestId = iterator.next().value;
      this.displayedMessageIds.delete(oldestId);
    }
    this.displayedMessageIds.add(messageId);
  }

  // ============================================
  // WebRTC 切断 & クリーンアップ
  // ============================================

  /**
   * WebRTC 接続を切断
   */
  disconnectRealtime() {
    // F1 fix (0.3.0-dev.48): switchToTextMode() / _handleNewConversation() は
    // (realtime モード時) いずれもここを経由してセッションを終える。ユーザーが
    // ページ遷移を放棄したとみなし、_queueRealtimeNavigation が張った
    // フェイルセーフ(12s)/grace(2s) タイマーを解除する。解除を忘れると
    // ユーザーが会話をやめた後も裏で最大12秒後に無関係な画面へ強制遷移する。
    this._clearPendingNavigation();

    // F3(c) fix (敵対的検証): _clearPendingNavigation() と同じ理由で choke
    // point に置く。全破棄経路 (switchToTextMode の in-flight フォールバック、
    // handleRealtimeError の suspend 中サイレント破棄 (F7)、ページ離脱等) の
    // どこから disconnectRealtime() が呼ばれても、suspend 待機タイマーが
    // 孤児化しない — 呼び忘れ 1 箇所が「約60秒後に無関係な音声セッションを
    // 強制切断する」事故に直結するため、個別呼び出し元に頼らず構造的に防ぐ。
    this._clearSuspendTimer();

    // dev.56 A-2: 同じ choke point の考え方で、予約済みの自動再接続タイマー
    // も生存を断つ。ここを経由する全破棄のどの経路でも孤児化しない。
    this._clearReconnectTimer();

    // マルチタブ排他制御: セッション終了をブロードキャスト
    this._broadcastVoiceSessionEnded();

    // 波形表示停止
    this.stopWaveformVisualization();

    // マイクストリーム停止
    if (this.micStream) {
      this.micStream.getTracks().forEach((track) => track.stop());
      this.micStream = null;
    }

    // AudioContext クローズ
    if (this.audioContext) {
      this.audioContext.close().catch(() => {});
      this.audioContext = null;
      this.analyser = null;
    }

    // WebRTC 切断
    if (this.connector) {
      this.connector.disconnect();
    }

    // Realtime セッションクリーンアップ
    this.cleanupRealtimeSession();
  }

  /**
   * Realtime セッションのクリーンアップ（データのみ）
   */
  cleanupRealtimeSession() {
    // マイクデバイス選択のクリーンアップ
    this.currentMicDeviceId = null;
    this.closeMicPopover();

    // 状態リセット
    this.displayedMessageIds.clear();
    this._sentItemIds.clear();
    this.currentStreamingElement = null;
    this.currentStreamingMessageId = null;
    // Issue #526 再発対応: _streamingElements Map も session 境界で clear する。
    // session 中に finalize されなかった stale entry が新 session の itemId と
    // 偶然一致すると updateOrCreateStreamingMessage が古い bubble に textContent を
    // 書き込んで上書き表示になる。defense-in-depth として明示的に空にする。
    // (DOM 上の bubble 自体は残す — chat history を破壊しない)
    if (this._streamingElements) {
      this._streamingElements.clear();
    }
    this.currentSessionId = null;
    // Issue #616: pending な startConnectionTiming Promise も破棄
    // (新セッション開始前に旧 session ID が返らないようにする)
    this._sessionTimingPromise = null;
    this.toolHandler.setSessionId(null);
    this.currentToolCalls = [];
    this._realtimeMessages = [];

    // ミュート UI 状態を初期状態に戻す (Issue #525)
    // realtime → text 切替や明示的切断、自動再接続前の cleanup でも常に通る経路。
    this._resetMuteUiState();

    // UI更新（まだ realtime モードの場合）
    if (this.mode === "realtime") {
      this.el.statusDot.classList.remove("agent__status-dot--connected", "agent__status-dot--connecting");
      this.el.statusText.textContent = t("status_disconnected", "Disconnected");
    }
  }

  /**
   * ページ離脱時のクリーンアップ（sendBeacon版）
   */
  cleanupOnUnload() {
    if (this._unloadCleanupDone) return;
    this._unloadCleanupDone = true;

    // 契約 B-3: suspend 中の待機タイマーはページ離脱後に発火しても意味が無いが、
    // 下の disconnectRealtime() 呼び出しと二重に破棄処理が走らないよう明示的に解除する。
    this._clearSuspendTimer();
    // dev.61: Stage2 昇格タイマーも同じ理由でページ離脱時に解除する — 発火しても
    // switchToTextMode() が意味を持たない状態(下の disconnectRealtime()/破棄処理と
    // 競合し得る)。
    this._clearBackgroundSuspendTimer();

    // Issue #280: visualViewport listener と --agent-keyboard-offset を解除
    try {
      this._cleanupVisualViewport?.();
    } catch (_e) { /* best-effort */ }

    // Issue #288: 会話メニュー (kebab) の document リスナーを AbortController で解除
    try {
      this._conversationMenuAbort?.abort();
    } catch (_e) { /* best-effort */ }

    // マルチタブ排他制御: セッション終了をブロードキャスト
    this._broadcastVoiceSessionEnded();

    // セッションが未開始の場合はスキップ（音声モード）
    if (!this.currentSessionId && this.mode === "text") return;

    // 1. セッションログの終了記録
    if (sessionLogger.isCreated()) {
      sessionLogger.endSessionBeacon();
    }

    // 2. WebRTC 切断 + Realtime セッションクリーンアップ
    // 契約 B-3: suspend 中 (mode==="text" だが connector がセッションを裏で保持中)
    // のセッションも、ページ離脱時はここで disconnectRealtime() により完全破棄する。
    // connector.disconnect() は mediaStream が既に null (suspend 済み) でも安全に
    // peerConnection / dataChannel を閉じ、_suspended フラグもリセットする。
    this.disconnectRealtime();
  }

  // ============================================
  // DOM 操作
  // ============================================

  /** クライアント設定を取得 */
  get cfg() {
    return globalThis.__AGENT_CONFIG__ || {};
  }

  /** Botアバター要素を生成 */
  _createAvatar() {
    const ui = this.cfg.ui || {};
    const avatar = document.createElement("div");
    avatar.className = "agent__avatar";
    if (ui.avatar_image_url) {
      const img = document.createElement("img");
      img.src = ui.avatar_image_url;
      img.alt = "AI";
      img.className = "agent__avatar-img";
      avatar.appendChild(img);
    } else {
      const iconName = ui.avatar_emoji || "robot";
      avatar.innerHTML = `<i class="bi bi-${this.escapeHtml(iconName)}"></i>`;
    }
    return avatar;
  }

  // ============================================
  // メッセージ描画
  // ============================================

  /**
   * 連続バブルのグルーピング（iMessage風角丸）
   * 同一送信者の連続メッセージに --first / --middle / --last クラスを付与
   */
  _updateBubbleGrouping() {
    const msgs = this.el.chatBody.querySelectorAll(".agent__message:not(.agent__message--system)");
    msgs.forEach((msg, i) => {
      msg.classList.remove("agent__message--first", "agent__message--middle", "agent__message--last");
      const isUser = msg.classList.contains("agent__message--user");
      const prevIsUser = i > 0 && msgs[i - 1].classList.contains("agent__message--user");
      const nextIsUser = i < msgs.length - 1 && msgs[i + 1]?.classList.contains("agent__message--user");
      const prevSame = i > 0 && prevIsUser === isUser;
      const nextSame = i < msgs.length - 1 && nextIsUser === isUser;
      if (!prevSame && nextSame) msg.classList.add("agent__message--first");
      else if (prevSame && nextSame) msg.classList.add("agent__message--middle");
      else if (prevSame && !nextSame) msg.classList.add("agent__message--last");
    });
  }

  /**
   * メッセージ要素を作成（DOMに追加しない）
   */
  _createMessageElement(text, role) {
    const msgDiv = document.createElement("div");
    msgDiv.className = `agent__message agent__message--${role}`;

    if (role === "bot") {
      msgDiv.appendChild(this._createAvatar());
    }

    const bubble = document.createElement("div");
    bubble.className = "agent__bubble";
    if (role === "bot") {
      bubble.innerHTML = renderMarkdownLite(text);
    } else {
      bubble.textContent = text;
    }
    msgDiv.appendChild(bubble);

    // タイムスタンプ（確定時に時刻をセット）
    const ts = document.createElement("div");
    ts.className = "agent__timestamp";
    msgDiv.appendChild(ts);

    // Issue #288 + UI: bot メッセージにアクション行を付与 (1 行に集約)。
    // 2026-08 撤去記録: この行にあった 👍/👎 評価ボタンと、その下段にぶら下がる
    // .agent__feedback フォローアップ行 (低評価の理由 chip / 次アクション /
    // コメント入力) は、AI応答評価機能を無料版から提供しない製品判断により削除した。
    // 現在は [コピー] のみが .agent__msg-actions 内に並ぶ。
    if (role === "bot") {
      const actions = document.createElement("div");
      actions.className = "agent__msg-actions";

      const copyBtn = document.createElement("button");
      copyBtn.type = "button";
      copyBtn.className = "agent__msg-action-btn agent__msg-action-btn--copy";
      copyBtn.setAttribute("aria-label", t("aria_copy_message", "Copy message"));
      copyBtn.title = t("copy_title", "Copy");
      copyBtn.innerHTML = '<i class="bi bi-clipboard" aria-hidden="true"></i>';
      copyBtn.addEventListener("click", () => this._handleCopyMessage(copyBtn, bubble));
      actions.appendChild(copyBtn);

      msgDiv.appendChild(actions);
    }

    return msgDiv;
  }

  addMessage(text, role) {
    const msgDiv = this._createMessageElement(text, role);
    this._stampTimestamp(msgDiv);
    this.el.chatBody.appendChild(msgDiv);
    this._updateBubbleGrouping();
    this.scrollToBottom();
    return msgDiv;
  }

  /**
   * メッセージ要素にタイムスタンプをセット（確定時刻）
   */
  _stampTimestamp(msgEl) {
    const ts = msgEl?.querySelector(".agent__timestamp");
    if (ts) {
      const now = new Date();
      ts.textContent = now.toLocaleTimeString("ja-JP", { hour: "2-digit", minute: "2-digit" });
    }
  }

  /**
   * ナビゲーションカードを追加
   */
  addNavigationCard(title, url) {
    // セッション永続化用: 会話履歴にナビゲーション案内を追加。
    // F3: 音声モードでは AI の実際の発話 (handleRealtimeTextResponse) が既に
    // assistant 履歴として記録されるため、ここで定型文も積むと navigate_page
    // ツール実行1回につき assistant 履歴が2件重複する。テキストモードのみ
    // 定型文を積む。
    if (this.textChat && this.mode !== "realtime") {
      this.textChat.messages.push({
        role: "assistant",
        content: t("msg_navigated_page", 'Guided you to the "{title}" page.').replace("{title}", title),
      });
    }

    const cardsConfig = this.cfg.cards || {};
    const useRich = cardsConfig.navigation_card_style === "rich";

    if (useRich) {
      this.addRichCard({ type: "link", title, url, ctaText: cardsConfig.cta_text_default });
      return;
    }

    const msgDiv = document.createElement("div");
    msgDiv.className = "agent__message agent__message--bot";
    msgDiv.appendChild(this._createAvatar());

    const card = document.createElement("div");
    card.className = "agent__card";
    card.innerHTML = `
      <span class="agent__card-icon"><i class="bi bi-file-earmark-text"></i></span>
      <span class="agent__card-text">${this.escapeHtml(title)}</span>
    `;
    card.addEventListener("click", () => {
      this._postToParent({ type: "navigate", url, title });
    });

    msgDiv.appendChild(card);
    this.el.chatBody.appendChild(msgDiv);
    this._updateBubbleGrouping();
    this.scrollToBottom();
  }

  /**
   * リッチカードを追加
   */
  addRichCard(opts = {}) {
    const msgDiv = document.createElement("div");
    msgDiv.className = "agent__message agent__message--bot";
    msgDiv.appendChild(this._createAvatar());

    const card = document.createElement("div");
    card.className = "agent__rich-card";

    const cardsConfig = this.cfg.cards || {};
    if (cardsConfig.show_thumbnail !== false && opts.imageUrl) {
      const thumb = document.createElement("div");
      thumb.className = "agent__rich-card-thumb";
      const img = document.createElement("img");
      img.src = opts.imageUrl;
      img.alt = opts.title || "";
      img.loading = "lazy";
      thumb.appendChild(img);
      card.appendChild(thumb);
    }

    const body = document.createElement("div");
    body.className = "agent__rich-card-body";

    if (cardsConfig.show_label !== false && opts.label) {
      const label = document.createElement("span");
      label.className = "agent__rich-card-label";
      label.textContent = opts.label;
      body.appendChild(label);
    }

    if (opts.title) {
      const title = document.createElement("div");
      title.className = "agent__rich-card-title";
      title.textContent = opts.title;
      body.appendChild(title);
    }

    if (cardsConfig.show_description !== false && opts.description) {
      const desc = document.createElement("div");
      desc.className = "agent__rich-card-desc";
      desc.textContent = opts.description;
      body.appendChild(desc);
    }

    card.appendChild(body);

    const ctaText = opts.ctaText || cardsConfig.cta_text_default || t("btn_card_cta_default", "Learn more →");
    const action = document.createElement("div");
    action.className = "agent__rich-card-action";
    const btn = document.createElement("button");
    btn.className = "agent__rich-card-btn";
    btn.textContent = ctaText;
    action.appendChild(btn);
    card.appendChild(action);

    if (opts.url) {
      card.addEventListener("click", () => {
        this._postToParent({ type: "navigate", url: opts.url, title: opts.title || "" });
      });
    }

    msgDiv.appendChild(card);
    this.el.chatBody.appendChild(msgDiv);
    this._updateBubbleGrouping();
    this.scrollToBottom();
  }

  /**
   * CTAカードを追加
   */
  addCtaCard(opts = {}) {
    const card = document.createElement("div");
    card.className = "agent__cta-card";

    if (opts.icon) {
      const icon = document.createElement("span");
      icon.className = "agent__cta-card-icon";
      icon.innerHTML = `<i class="bi bi-${this.escapeHtml(opts.icon)}"></i>`;
      card.appendChild(icon);
    }

    const body = document.createElement("div");
    body.className = "agent__cta-card-body";

    const text = document.createElement("div");
    text.className = "agent__cta-card-text";
    text.textContent = opts.text || "";
    body.appendChild(text);

    if (opts.subtitle) {
      const sub = document.createElement("div");
      sub.className = "agent__cta-card-sub";
      sub.textContent = opts.subtitle;
      body.appendChild(sub);
    }

    card.appendChild(body);

    if (opts.url) {
      card.addEventListener("click", () => {
        this._postToParent({ type: "navigate", url: opts.url, title: opts.text || "" });
      });
    }

    this.el.chatBody.appendChild(card);
    this.scrollToBottom();
  }

  /**
   * フォローアップアクションを表示
   */
  showFollowUpActions(actions) {
    if (!actions || actions.length === 0) return;

    // 既存のフォローアップボタンを削除（最新の回答にのみ表示する）
    this.el.chatBody.querySelectorAll(".agent__follow-up").forEach(el => el.remove());

    const container = document.createElement("div");
    container.className = "agent__follow-up";

    for (const act of actions) {
      const btn = document.createElement("button");
      btn.className = "agent__follow-up-btn";
      btn.textContent = act.text;

      if (act.type === "reset") {
        btn.addEventListener("click", () => {
          container.remove();
          this.el.chatInput?.focus();
        });
      } else if (act.type === "voice") {
        btn.addEventListener("click", () => {
          container.remove();
          this.switchToRealtimeMode();
        });
      } else if (act.type === "link" && act.url) {
        btn.addEventListener("click", () => {
          this._postToParent({ type: "navigate", url: act.url, title: act.text });
        });
      } else if (act.action) {
        btn.addEventListener("click", () => {
          container.remove();
          this.sendText(act.action);
        });
      }

      container.appendChild(btn);
    }

    this.el.chatBody.appendChild(container);
    this.scrollToBottom();
  }

  /**
   * ストリーミングインジケーター表示
   */
  showStreamingIndicator() {
    this.removeStreamingIndicator();
    const loadingConfig = this.cfg.loading || {};
    const useSkeleton = loadingConfig.style === "skeleton";

    const msgDiv = document.createElement("div");
    msgDiv.id = "streaming-indicator";

    if (useSkeleton) {
      msgDiv.className = "agent__skeleton";

      const skAvatar = document.createElement("div");
      skAvatar.className = "agent__skeleton-avatar";
      msgDiv.appendChild(skAvatar);

      const skBody = document.createElement("div");
      skBody.className = "agent__skeleton-body";

      if (loadingConfig.show_status_text) {
        const status = document.createElement("div");
        status.className = "agent__skeleton-status";
        status.textContent = loadingConfig.status_messages?.default || t("loading_default_status", "Preparing an answer...");
        skBody.appendChild(status);
      }

      const lines = loadingConfig.skeleton_lines || 3;
      for (let i = 0; i < lines; i++) {
        const line = document.createElement("div");
        line.className = "agent__skeleton-line";
        skBody.appendChild(line);
      }

      msgDiv.appendChild(skBody);
    } else {
      msgDiv.className = "agent__message agent__message--bot";
      msgDiv.appendChild(this._createAvatar());

      const dots = document.createElement("div");
      dots.className = "agent__streaming";
      dots.innerHTML = "<span></span><span></span><span></span>";
      msgDiv.appendChild(dots);
    }

    this.el.chatBody.appendChild(msgDiv);
    this._updateBubbleGrouping();
    this.scrollToBottom();
  }

  removeStreamingIndicator() {
    this.el.chatBody
      ?.querySelectorAll("#streaming-indicator")
      .forEach(el => el.remove());
  }

  updateWelcomeForPage(welcome) {
    if (!this.el.welcomeScreen) return;
    if (!welcome.message) return;

    // EJS テンプレートの実 DOM クラスは .agent__welcome-message。
    const messageEl = this.el.welcomeScreen.querySelector(".agent__welcome-message");
    if (messageEl) messageEl.textContent = welcome.message;

    const iconEl = this.el.welcomeScreen.querySelector(".agent__welcome-icon");
    if (iconEl) {
      // ページコンテキストに icon_image_url がない場合はグローバル設定を保持
      const globalWelcome = this.cfg?.welcome || {};
      const iconImageUrl = welcome.icon_image_url || globalWelcome.icon_image_url;
      if (iconImageUrl) {
        iconEl.innerHTML = `<img src="${this.escapeHtml(iconImageUrl)}" alt="" class="agent__welcome-icon-img">`;
      } else if (welcome.icon) {
        iconEl.innerHTML = `<i class="bi bi-${this.escapeHtml(welcome.icon)}"></i>`;
      }
    }
  }

  /**
   * CTAパネルに primary / secondary CTA を表示
   * #cta-panel はウェルカムスクリーンとは独立した要素で、
   * 会話開始時に hideCtas() で非表示にする。
   * リピーター（セッション復元済み）でも表示される。
   */
  renderWelcomeCtas() {
    const ctaCfg = this.cfg.cta;
    if (!ctaCfg || !this.el.ctaPanel) return;

    const hasPrimary = ctaCfg.primary?.enabled && ctaCfg.primary.text;
    const hasSecondary = Array.isArray(ctaCfg.secondary) && ctaCfg.secondary.length > 0;
    if (!hasPrimary && !hasSecondary) return;

    // primary CTA — カード形式
    if (hasPrimary) {
      const p = ctaCfg.primary;
      const card = document.createElement("div");
      card.className = "agent__cta-card agent__welcome-cta";

      if (p.icon) {
        const icon = document.createElement("span");
        icon.className = "agent__cta-card-icon";
        icon.innerHTML = `<i class="bi bi-${this.escapeHtml(p.icon)}"></i>`;
        card.appendChild(icon);
      }

      const body = document.createElement("div");
      body.className = "agent__cta-card-body";
      const text = document.createElement("div");
      text.className = "agent__cta-card-text";
      text.textContent = p.text;
      body.appendChild(text);
      if (p.subtitle) {
        const sub = document.createElement("div");
        sub.className = "agent__cta-card-sub";
        sub.textContent = p.subtitle;
        body.appendChild(sub);
      }
      card.appendChild(body);

      const arrow = document.createElement("span");
      arrow.className = "agent__cta-card-arrow";
      arrow.innerHTML = `<i class="bi bi-chevron-right"></i>`;
      card.appendChild(arrow);

      if (p.action_type === "voice") {
        card.style.cursor = "pointer";
        card.addEventListener("click", () => {
          this._sendWidgetEvent("cta_click", { cta_type: "primary", action: "voice" });
          this.switchToRealtimeMode();
        });
      } else if (p.url) {
        card.style.cursor = "pointer";
        card.addEventListener("click", () => {
          this._sendWidgetEvent("cta_click", { cta_type: "primary", url: p.url });
          if (/^(tel:|mailto:)/i.test(p.url)) {
            window.open(p.url, "_blank");
          } else {
            this._postToParent({ type: "navigate", url: p.url, title: p.text });
          }
        });
      }
      this.el.ctaPanel.appendChild(card);
    }

    // secondary CTAs — コンパクトなボタン行
    if (hasSecondary) {
      const row = document.createElement("div");
      row.className = "agent__welcome-cta-row";

      for (const s of ctaCfg.secondary) {
        if (!s.text) continue;
        const btn = document.createElement("button");
        btn.className = "agent__welcome-cta-btn";
        if (s.icon) {
          btn.innerHTML = `<i class="bi bi-${this.escapeHtml(s.icon)}"></i> `;
          const span = document.createElement("span");
          span.textContent = s.text;
          btn.appendChild(span);
        } else {
          btn.textContent = s.text;
        }
        if (s.action_type === "voice") {
          btn.addEventListener("click", () => {
            this._sendWidgetEvent("cta_click", { cta_type: "secondary", action: "voice" });
            this.switchToRealtimeMode();
          });
        } else if (s.url) {
          btn.addEventListener("click", () => {
            this._sendWidgetEvent("cta_click", { cta_type: "secondary", url: s.url });
            if (/^(tel:|mailto:)/i.test(s.url)) {
              window.open(s.url, "_blank");
            } else {
              this._postToParent({ type: "navigate", url: s.url, title: s.text });
            }
          });
        }
        row.appendChild(btn);
      }
      this.el.ctaPanel.appendChild(row);
    }
  }


  /**
   * チャットビューに切り替え（会話開始時に呼ばれる）
   */
  /**
   * ビュー切替共通: すべてのビューとタブをリセットし、指定ビューを表示
   */
  _switchView(view) {
    this.currentView = view;
    // すべてのビューを非表示
    if (this.el.homeView) this.el.homeView.classList.add("agent--hidden");
    if (this.el.chatBody) this.el.chatBody.classList.add("agent--hidden");
    // すべてのタブを非アクティブ + aria-selected同期
    if (this.el.tabHome) { this.el.tabHome.classList.remove("agent__view-tab--active"); this.el.tabHome.setAttribute("aria-selected", "false"); }
    if (this.el.tabChat) { this.el.tabChat.classList.remove("agent__view-tab--active"); this.el.tabChat.setAttribute("aria-selected", "false"); }
    // 指定ビューを表示
    switch (view) {
      case "home":
        if (this.el.homeView) this.el.homeView.classList.remove("agent--hidden");
        if (this.el.tabHome) { this.el.tabHome.classList.add("agent__view-tab--active"); this.el.tabHome.setAttribute("aria-selected", "true"); }
        break;
      case "chat":
        if (this.el.chatBody) this.el.chatBody.classList.remove("agent--hidden");
        if (this.el.tabChat) { this.el.tabChat.classList.add("agent__view-tab--active"); this.el.tabChat.setAttribute("aria-selected", "true"); }
        break;
    }
  }

  showChat() {
    // Issue #336: 初回 chat tab 切替で重い初期化 (sessionLogger / token prefetch / mic warmup) を起動
    this._scheduleDeferredInit?.();
    this._switchView("chat");
  }

  /**
   * ホームビューに切り替え（ウェルカム + CTA + クイックアクション表示）
   */
  showHome() {
    this._switchView("home");
  }

  hideWelcome() {
    this.showChat();
  }

  /**
   * Issue #337: scroll を throttle (streaming 中の連続呼出しを抑制)
   * 80ms 単位で 1 回に絞る + DOM ノード上限を超えていれば古いメッセージを除去。
   */
  scrollToBottom() {
    if (this._scrollPending) return;
    this._scrollPending = true;
    setTimeout(() => {
      this._scrollPending = false;
      requestAnimationFrame(() => {
        if (!this.el.chatBody) return;
        this._trimChatDom();
        this.el.chatBody.scrollTop = this.el.chatBody.scrollHeight;
      });
    }, 80);
  }

  /**
   * Issue #337: DOM 上限を超えたメッセージは古い順に間引く (デフォルト 100)
   * 内部配列 (_pageSessionMessages / _realtimeMessages) も 200 件 cap する。
   */
  _trimChatDom() {
    const MAX_DOM = 100;
    const MAX_HISTORY_ARRAY = 200;
    const list = this.el.chatBody?.querySelectorAll(".agent__message");
    if (list && list.length > MAX_DOM) {
      const removeCount = list.length - MAX_DOM;
      for (let i = 0; i < removeCount; i++) {
        list[i].remove();
      }
    }
    if (Array.isArray(this._pageSessionMessages) && this._pageSessionMessages.length > MAX_HISTORY_ARRAY) {
      this._pageSessionMessages = this._pageSessionMessages.slice(-MAX_HISTORY_ARRAY);
    }
    if (Array.isArray(this._realtimeMessages) && this._realtimeMessages.length > MAX_HISTORY_ARRAY) {
      this._realtimeMessages = this._realtimeMessages.slice(-MAX_HISTORY_ARRAY);
    }
  }

  /**
   * テキストメッセージをプログラムから送信
   */
  sendText(text) {
    this.el.chatInput.value = text;
    this.handleSend();
  }

  escapeHtml(text) {
    // regex 置換で 5 文字すべて (& < > " ') を実体参照化する。
    // 旧実装 (textContent → innerHTML) は " / ' を素通しするため属性 context で XSS を許してしまう。
    // 参照: Issue #192 / IMPLEMENTATION_GAPS.md G-008
    return String(text).replace(/[&<>"']/g, (c) => ({
      "&": "&amp;",
      "<": "&lt;",
      ">": "&gt;",
      '"': "&quot;",
      "'": "&#39;",
    })[c]);
  }

  // ============================================
  // Issue #286 / #278: 共通エラーバブル
  // ============================================

  /**
   * すべてのエラー導線で共通利用するエラーバブルを描画する。
   *  - title: 簡潔なエラータイトル (ユーザー向け)
   *  - body: 説明文
   *  - retryHandler: 「もう一度試す」ボタンの click 時 callback (任意)
   *  - lastUserText: retry 時に input に復元するテキスト (#278)
   *
   * 必ず以下の 3 アクション (利用可能なものだけ) を表示する:
   *  - もう一度試す (retryHandler 提供時)
   *  - お問い合わせフォームへ (cfg.contact.url 設定時)
   *  - 電話する (cfg.contact.phone 設定時)
   */
  renderErrorBubble({ title, body, retryHandler, lastUserText }) {
    const contactCfg = this.cfg?.contact || {};
    const contactUrl = contactCfg.url || "";
    const phone = contactCfg.phone || "";

    const safe = (s) => this.escapeHtml(String(s || ""));
    const actions = [];
    if (retryHandler) {
      actions.push(`<button type="button" class="agent__error-retry">${safe(t("btn_retry", "Try again"))}</button>`);
    }
    if (contactUrl) {
      actions.push(`<a href="${safe(contactUrl)}" class="agent__error-contact" target="_blank" rel="noopener">${safe(t("btn_contact_form", "Go to contact form"))}</a>`);
    }
    if (phone) {
      actions.push(`<a href="tel:${safe(phone)}" class="agent__error-phone">${safe(t("btn_call", "Call"))}: ${safe(phone)}</a>`);
    }

    const html = `
      <div class="agent__error-card" role="alert">
        <div class="agent__error-title">${safe(title)}</div>
        <div class="agent__error-body">${safe(body)}</div>
        ${actions.length ? `<div class="agent__error-actions">${actions.join("")}</div>` : ""}
      </div>`;

    // メッセージとして DOM に挿入
    const wrap = document.createElement("div");
    wrap.className = "agent__message agent__message--system";
    wrap.innerHTML = html;
    this.el.chatBody?.appendChild(wrap);

    // retry click ハンドラ
    if (retryHandler) {
      const btn = wrap.querySelector(".agent__error-retry");
      btn?.addEventListener("click", () => {
        if (lastUserText && this.el.chatInput) {
          this.el.chatInput.value = lastUserText;
        }
        try { retryHandler(); } catch (e) { console.warn("[ErrorBubble] retry failed:", e.message); }
        wrap.remove();
      });
    }

    this.scrollToBottom();
  }

  // ============================================
  // ユーティリティ
  // ============================================

  getOrCreateUserId() {
    return UserIdManager.getUserId();
  }

  _isAllowedOrigin(origin) {
    if (!origin) return false;
    if (origin === this.parentOrigin) return true;
    const allowedDomains = globalThis.__ALLOWED_ORIGINS__ || [];
    return allowedDomains.includes(origin);
  }

  // ============================================
  // マルチタブ排他制御（BroadcastChannel + localStorage fallback）
  // Issue #447: BroadcastChannel 未対応 (Safari Private Mode 等) では
  //   localStorage に TTL 付き lock を書いて代替する。fallback 失敗時 (Storage 自体
  //   不可) は排他なしで進む (やむなし) が、FinOps メトリクスで可視化する。
  // ============================================

  // localStorage lock 仕様
  static get _TAB_LOCK_KEY() { return "ai-agent-widget-voice-lock"; }
  static get _TAB_LOCK_TTL_MS() { return 30_000; }
  static get _TAB_LOCK_HEARTBEAT_MS() { return 10_000; }

  /**
   * 複数タブでの同時マイク使用を防止する
   */
  _initTabVoiceExclusion() {
    // BroadcastChannel を優先 (multi-tab event push が即座に届く)
    if (typeof BroadcastChannel !== "undefined") {
      try {
        this._tabVoiceChannel = new BroadcastChannel("ai-agent-widget-voice");
        this._tabVoiceChannel.onmessage = (event) => {
          const { type } = event.data || {};
          if (type === "voice-session-active") {
            this._voiceSessionBlockedByOtherTab = true;
          } else if (type === "voice-session-ended") {
            this._voiceSessionBlockedByOtherTab = false;
          } else if (type === "voice-session-ping") {
            // F6 fix (敵対的検証): suspend 中 (mode==="text" だが接続は裏で
            // 生きている) もセッションは課金対象で維持されている。判定を
            // this.mode==="realtime" だけに頼ると suspend 中は「セッション
            // 無し」と誤答し、他タブの同時開始を誤って許してしまう。
            if ((this.mode === "realtime" || this.connector?.isSuspended?.()) && this.currentSessionId) {
              this._tabVoiceChannel.postMessage({ type: "voice-session-active" });
            }
          }
        };
        return;
      } catch {
        // 初期化失敗 → localStorage fallback へ
      }
    }
    // BroadcastChannel 未対応 / 初期化失敗 → localStorage fallback
    this._initTabVoiceExclusionLocalStorageFallback();
  }

  /**
   * localStorage ベースの排他制御 (Safari Private Mode / 旧ブラウザ用)
   * 仕様:
   *   - lock key: `ai-agent-widget-voice-lock`
   *   - value: `{ sessionId, expiresAt }` (JSON)
   *   - TTL: 30秒、10秒ごとに heartbeat で延長
   *   - beforeunload で lock 解放
   *   - 取得時に expiresAt < now なら期限切れと見なし上書き可
   */
  _initTabVoiceExclusionLocalStorageFallback() {
    // Storage 自体が使えるかは取得時に try で判定する。eager probe は不要
    // (Safari Private Mode は setItem で QuotaExceededError を throw)。
    this._tabVoiceLockMode = "localStorage";

    // storage イベントで他タブの lock 変更を購読 → 状態を反映
    try {
      window.addEventListener("storage", (event) => {
        if (event.key !== PersistentWidgetApp._TAB_LOCK_KEY) return;
        if (!event.newValue) {
          this._voiceSessionBlockedByOtherTab = false;
          return;
        }
        try {
          const v = JSON.parse(event.newValue);
          // 自タブの session が書いたものは無視
          if (v.sessionId === this.currentSessionId) return;
          this._voiceSessionBlockedByOtherTab = (v.expiresAt > Date.now());
        } catch {
          // 不正値は無視
        }
      });
    } catch {
      // window 未定義 (SSR) は無視
    }
  }

  /**
   * 他タブが lock を保持中か判定 (BroadcastChannel / localStorage 両対応)
   */
  async _isOtherTabVoiceActive() {
    // BroadcastChannel モード
    if (this._tabVoiceChannel) {
      if (this._voiceSessionBlockedByOtherTab) return true;
      this._tabVoiceChannel.postMessage({ type: "voice-session-ping" });
      await new Promise((r) => setTimeout(r, 100));
      return this._voiceSessionBlockedByOtherTab;
    }
    // localStorage モード
    if (this._tabVoiceLockMode === "localStorage") {
      try {
        const raw = localStorage.getItem(PersistentWidgetApp._TAB_LOCK_KEY);
        if (!raw) return false;
        const v = JSON.parse(raw);
        if (v.sessionId === this.currentSessionId) return false; // 自タブ
        return v.expiresAt > Date.now();
      } catch {
        // localStorage 使用不可 (Safari Private Mode 等) → 排他なし
        return false;
      }
    }
    return false;
  }

  _broadcastVoiceSessionActive() {
    if (this._tabVoiceChannel) {
      try { this._tabVoiceChannel.postMessage({ type: "voice-session-active" }); } catch { /* ignore */ }
      return;
    }
    if (this._tabVoiceLockMode !== "localStorage") return;
    // localStorage lock 取得 + heartbeat 開始
    this._acquireLocalStorageLock();
  }

  _broadcastVoiceSessionEnded() {
    if (this._tabVoiceChannel) {
      try { this._tabVoiceChannel.postMessage({ type: "voice-session-ended" }); } catch { /* ignore */ }
      return;
    }
    if (this._tabVoiceLockMode !== "localStorage") return;
    this._releaseLocalStorageLock();
  }

  _acquireLocalStorageLock() {
    try {
      const now = Date.now();
      const value = JSON.stringify({
        sessionId: this.currentSessionId,
        acquiredAt: now,
        expiresAt: now + PersistentWidgetApp._TAB_LOCK_TTL_MS,
      });
      localStorage.setItem(PersistentWidgetApp._TAB_LOCK_KEY, value);

      // heartbeat (10秒ごとに TTL を延長)
      if (this._tabVoiceLockHeartbeat) clearInterval(this._tabVoiceLockHeartbeat);
      this._tabVoiceLockHeartbeat = setInterval(() => {
        try {
          const raw = localStorage.getItem(PersistentWidgetApp._TAB_LOCK_KEY);
          if (!raw) {
            // 他タブにすり替えられた可能性 — heartbeat 停止
            clearInterval(this._tabVoiceLockHeartbeat);
            this._tabVoiceLockHeartbeat = null;
            return;
          }
          const v = JSON.parse(raw);
          if (v.sessionId !== this.currentSessionId) {
            // 他タブが上書きした (TTL 切れ後の競合) — 自分の lock を諦める
            clearInterval(this._tabVoiceLockHeartbeat);
            this._tabVoiceLockHeartbeat = null;
            return;
          }
          v.expiresAt = Date.now() + PersistentWidgetApp._TAB_LOCK_TTL_MS;
          localStorage.setItem(PersistentWidgetApp._TAB_LOCK_KEY, JSON.stringify(v));
        } catch {
          // storage 使用不可になったら heartbeat 停止 (排他は諦め)
          clearInterval(this._tabVoiceLockHeartbeat);
          this._tabVoiceLockHeartbeat = null;
        }
      }, PersistentWidgetApp._TAB_LOCK_HEARTBEAT_MS);

      // beforeunload で lock 解放 (1 度だけ登録)
      if (!this._tabVoiceLockUnloadHandler) {
        this._tabVoiceLockUnloadHandler = () => this._releaseLocalStorageLock();
        window.addEventListener("beforeunload", this._tabVoiceLockUnloadHandler);
      }
    } catch {
      // Storage 使用不可 (Safari Private Mode の writing QuotaExceededError 等)
      // → 排他なしで進む (このコードパスは Safari Private Mode の最終 fallback)
    }
  }

  _releaseLocalStorageLock() {
    try {
      if (this._tabVoiceLockHeartbeat) {
        clearInterval(this._tabVoiceLockHeartbeat);
        this._tabVoiceLockHeartbeat = null;
      }
      const raw = localStorage.getItem(PersistentWidgetApp._TAB_LOCK_KEY);
      if (!raw) return;
      const v = JSON.parse(raw);
      if (v.sessionId === this.currentSessionId) {
        localStorage.removeItem(PersistentWidgetApp._TAB_LOCK_KEY);
      }
    } catch {
      // ignore
    }
  }

  _resolveParentOrigin() {
    // NS のフレームは常に同一オリジンで配信される (D2: iframe は
    // ?neccospeak_frame=1 でホスト WP サイト自身が返す)。pocxr 原本の
    // ancestorOrigins → referrer → "https://neccos.jp" という推定チェーンは
    // クロスオリジン埋め込み (Vercel フレーム × neccos.jp) の遺物で、
    // Firefox (ancestorOrigins 未サポート) × referrer 非公開サイトでは
    // 最終フォールバックの neccos.jp が targetOrigin 不一致となり
    // postMessage が全て破棄される事故があった (0.3.0-dev.18 で修正)。
    return location.origin;
  }

  _postToParent(data) {
    try {
      window.parent?.postMessage?.(data, this.parentOrigin);
    } catch {
      // cross-origin guard
    }
  }

  // ============================================
  // 親ウィンドウへのイベント送信
  // ============================================

  _sendWidgetEvent(eventType, data = {}) {
    this._postToParent({
      type: "widget-event",
      data: {
        event_type: eventType,
        ...data,
        timestamp: Date.now(),
      },
    });
  }

  // ============================================
  // テーマ設定の適用（親ウィンドウから受信）
  // ============================================

  _applyThemeConfig(config) {
    if (!config) return;

    const root = document.documentElement;

    // ブランドカラー上書き
    if (config.brandColor) {
      root.style.setProperty('--agent-primary', config.brandColor);
      root.style.setProperty('--agent-user-bg', config.brandColor);
      // ホバー色を少し暗くする（簡易計算）
      const darker = this._darkenColor(config.brandColor, 15);
      if (darker) {
        root.style.setProperty('--agent-primary-dark', darker);
        root.style.setProperty('--agent-primary-hover', darker);
      }
    }

    // theme_overrides による CSS 変数上書き
    if (config.themeOverrides) {
      this._applyThemeOverrides(config.themeOverrides);
    }

    // ウィジェットタイトル上書き
    if (config.widgetTitle) {
      const titleEl = document.querySelector('.agent__header-title');
      if (titleEl) titleEl.textContent = config.widgetTitle;
    }

    // ウェルカムメッセージ上書き
    if (config.welcomeMessage) {
      const msgEl = document.querySelector('.agent__welcome-message');
      if (msgEl) msgEl.textContent = config.welcomeMessage;
    }

    // クイックアクション上書き
    if (Array.isArray(config.quickActions) && config.quickActions.length > 0) {
      this._updateQuickActions(config.quickActions);
    }
  }

  /**
   * theme_overrides を CSS 変数として適用
   */
  _applyThemeOverrides(overrides) {
    if (!overrides || typeof overrides !== 'object') return;
    const root = document.documentElement;

    // カラー設定
    const colorMap = {
      background: '--agent-bg',
      background_chat: '--agent-bg-chat',
      text: '--agent-text',
      text_muted: '--agent-text-muted',
      border: '--agent-border',
      user_bubble_bg: '--agent-user-bg',
      user_bubble_text: '--agent-user-text',
      bot_bubble_bg: '--agent-bot-bg',
      bot_bubble_text: '--agent-bot-text',
      system_bg: '--agent-system-bg',
      system_text: '--agent-system-text',
      footer_bg: '--agent-footer-bg',
    };
    if (overrides.colors) {
      for (const [key, cssVar] of Object.entries(colorMap)) {
        if (overrides.colors[key] && /^#[0-9a-fA-F]{3,8}$/.test(overrides.colors[key])) {
          root.style.setProperty(cssVar, overrides.colors[key]);
        }
      }
    }

    // 角丸設定
    const radiusMap = {
      widget: '--agent-radius',
      bubble: '--agent-bubble-radius',
      card: '--agent-card-radius',
      button: '--agent-button-radius',
      input: '--agent-input-radius',
    };
    if (overrides.border_radius) {
      for (const [key, cssVar] of Object.entries(radiusMap)) {
        const val = overrides.border_radius[key];
        if (val !== undefined && val !== null) {
          root.style.setProperty(cssVar, `${val}px`);
        }
      }
    }

    // フォント設定
    if (overrides.font) {
      if (overrides.font.family) root.style.setProperty('--agent-font-family', overrides.font.family);
      if (overrides.font.size_base) root.style.setProperty('--agent-font-size', `${overrides.font.size_base}px`);
      if (overrides.font.size_small) root.style.setProperty('--agent-font-size-small', `${overrides.font.size_small}px`);
      if (overrides.font.size_header) root.style.setProperty('--agent-font-size-header', `${overrides.font.size_header}px`);
    }

    // シャドウ設定
    const shadowMap = {
      widget: '--agent-shadow',
      bubble: '--agent-bubble-shadow',
      card: '--agent-card-shadow',
      card_hover: '--agent-card-shadow-hover',
    };
    if (overrides.shadows) {
      for (const [key, cssVar] of Object.entries(shadowMap)) {
        if (overrides.shadows[key]) {
          root.style.setProperty(cssVar, overrides.shadows[key]);
        }
      }
    }

    // カスタム CSS
    if (overrides.custom_css) {
      let styleEl = document.getElementById('agent-custom-css');
      if (!styleEl) {
        styleEl = document.createElement('style');
        styleEl.id = 'agent-custom-css';
        document.head.appendChild(styleEl);
      }
      styleEl.textContent = overrides.custom_css;
    }
  }

  /**
   * ボタン等の要素内のアイコンを Bootstrap Icons に差し替え
   * @param {HTMLElement} el - 対象要素
   * @param {string} iconName - Bootstrap Icons 名（bi- プレフィックスなし）
   */
  _setIcon(el, iconName) {
    el.innerHTML = `<i class="bi bi-${iconName}"></i>`;
  }

  _darkenColor(hex, percent) {
    try {
      const num = parseInt(hex.replace('#', ''), 16);
      const r = Math.max(0, (num >> 16) - Math.round(2.55 * percent));
      const g = Math.max(0, ((num >> 8) & 0x00FF) - Math.round(2.55 * percent));
      const b = Math.max(0, (num & 0x0000FF) - Math.round(2.55 * percent));
      return '#' + (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1);
    } catch {
      return null;
    }
  }

  _updateQuickActions(actions) {
    const container = document.getElementById('quick-actions');
    if (!container) return;

    container.innerHTML = '';
    actions.forEach(qa => {
      const btn = document.createElement('button');
      btn.className = 'agent__quick-btn';
      btn.setAttribute('data-action', qa.action || qa.text);

      if (qa.icon) {
        const iconSpan = document.createElement('span');
        iconSpan.className = 'agent__quick-icon';
        iconSpan.innerHTML = `<i class="bi bi-${this.escapeHtml(qa.icon)}"></i>`;
        btn.appendChild(iconSpan);
      }

      const textSpan = document.createElement('span');
      textSpan.textContent = qa.text;
      btn.appendChild(textSpan);

      container.appendChild(btn);
    });

    // actions が空なら hidden を付与 (空 container を見せない)
    if (actions.length > 0) {
      container.classList.remove('agent--hidden');
    } else {
      container.classList.add('agent--hidden');
    }
  }
}

/**
 * §10 R2 の緩和策①: window.__NS_VERSION__ (PHP 側の NECCOSPEAK_VERSION) と
 * このモジュールに焼き込まれた NS_FRAME_VER の突合。不一致は「親ドキュメントは
 * 新しいが、この深い階層の .js モジュールはキャッシュ/CDN 経由で古いまま」という版混在の
 * シグナル。相対 import には `?ver=` を付けられない (INV-T3) ため、これが唯一の
 * 検知手段 — 未注入環境 (standalone テスト等) では比較自体をスキップする。
 * initPersistentWidget() から呼ばれる本体を独立関数として切り出し、単体テストが
 * PersistentWidgetApp のフル init() を走らせずにこのロジックだけ検証できるように
 * している。
 *
 * @param {string|undefined} injectedVersion window.__NS_VERSION__ の値。
 * @param {(...args: any[]) => void} [warn] テスト用に差し替え可能な warn 関数。
 * @returns {boolean} 突合が行われ、かつ不一致だった場合に true。
 */
export function checkFrameVersion(injectedVersion, warn = console.warn) {
  if (typeof injectedVersion === "string" && injectedVersion !== NS_FRAME_VER) {
    warn(
      "[PersistentWidget] Version mismatch: window.__NS_VERSION__=%s but widget-app-persistent.js NS_FRAME_VER=%s — the deep-path .js module bundle may be served from a stale cache/CDN. Purge caching layers and hard-reload.",
      injectedVersion,
      NS_FRAME_VER,
    );
    return true;
  }
  return false;
}

/**
 * 初期化関数（EJS から呼ばれる）
 */
export function initPersistentWidget() {
  checkFrameVersion(typeof window !== "undefined" ? window.__NS_VERSION__ : undefined);

  const app = new PersistentWidgetApp();
  app.init();
  return app;
}
