<!DOCTYPE html>
<html lang="{{ ui_language }}">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{{ title or app_name ~ " Assistant" }}</title>
    <link rel="icon" type="image/png" href="/static/favicon.png" />
    <link rel="apple-touch-icon" href="/static/favicon.png" />
    <style>
      :root {
        --app-bg-black: #000000;
        --ink: #eaeaea;
        --ink-muted: rgba(234, 234, 234, 0.7);
        --panel-bg: rgba(11, 11, 11, 0.82);
        --panel-border: rgba(255, 255, 255, 0.16);
        --panel-strong: rgba(214, 79, 79, 0.72);
        --ease: cubic-bezier(0.22, 1, 0.36, 1);
        --crossfade-ms: 420ms;
        --fade-out-ms: 520ms;
        --word-fade-ms: 320ms;
      }

      * {
        box-sizing: border-box;
      }

      html,
      body {
        margin: 0;
        width: 100%;
        min-height: 100%;
        background: radial-gradient(
          circle at center,
          var(--app-bg-black) 0%,
          var(--app-bg-black) 60%,
          var(--app-bg-black) 100%
        );
        overflow: hidden;
      }

      body {
        color: var(--ink);
        font-family: "SF Pro Text", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
      }

      .screen-fade {
        position: fixed;
        inset: 0;
        background: var(--app-bg-black);
        opacity: 1;
        z-index: 12;
        pointer-events: auto;
        transition: opacity 620ms var(--ease), visibility 0ms linear 620ms;
      }

      .screen-fade.hidden {
        opacity: 0;
        visibility: hidden;
        pointer-events: none;
      }

      #settings-button {
        position: fixed;
        top: 16px;
        right: 16px;
        z-index: 8;
        border: 1px solid rgba(255, 255, 255, 0.2);
        background: rgba(0, 0, 0, 0.55);
        color: var(--ink);
        border-radius: 999px;
        padding: 8px 14px;
        font-size: 0.77rem;
        letter-spacing: 0.05em;
        text-transform: uppercase;
        cursor: pointer;
        transition: border-color 180ms ease, transform 180ms ease;
      }

      #settings-button:hover {
        border-color: rgba(214, 79, 79, 0.72);
        transform: translateY(-1px);
      }

      #settings-button:disabled,
      #settings-button.is-disabled {
        opacity: 0.42;
        cursor: not-allowed;
        transform: none;
        border-color: rgba(255, 255, 255, 0.12);
        pointer-events: none;
      }

      .scene {
        min-height: 100vh;
        display: grid;
        place-items: center;
        padding: 24px 24px 142px;
      }

      #stage {
        display: grid;
        justify-items: center;
        gap: 16px;
      }

      #avatar-shell {
        position: relative;
        width: clamp(360px, 56vmin, 520px);
        aspect-ratio: 16 / 9;
        display: grid;
        place-items: center;
        isolation: isolate;
        margin: 0;
        padding: 0;
        background: transparent;
        border: none;
        outline: none;
        box-shadow: none;
        transition: opacity var(--fade-out-ms) var(--ease);
      }

      #avatar-shell::before {
        content: "";
        position: absolute;
        inset: 18% 18%;
        background: radial-gradient(circle, rgba(175, 24, 24, 0.22) 0%, rgba(64, 0, 0, 0.06) 48%, rgba(0, 0, 0, 0) 72%);
        filter: blur(18px);
        z-index: 0;
        pointer-events: none;
      }

      .orb-layer {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        object-fit: contain;
        display: block;
        margin: 0;
        padding: 0;
        background: transparent;
        border: none;
        outline: none;
        box-shadow: none;
        image-rendering: auto;
        transform: translateZ(0);
        backface-visibility: hidden;
        will-change: opacity;
        transition: opacity var(--crossfade-ms) var(--ease);
      }

      #orb-initial {
        z-index: 1;
        opacity: 1;
      }

      #orb-final {
        z-index: 2;
        opacity: 0;
      }

      #avatar-shell.mode-final #orb-initial {
        opacity: 0;
      }

      #avatar-shell.mode-final #orb-final {
        opacity: 1;
      }

      #transcript,
      #response {
        margin: 0;
        width: min(78ch, 90vw);
        text-align: center;
        text-wrap: pretty;
        opacity: 0;
        transform: translateY(8px);
        pointer-events: none;
        transition: opacity var(--fade-out-ms) var(--ease), transform 340ms var(--ease);
      }

      #transcript {
        color: var(--ink-muted);
        font-size: clamp(0.86rem, 1.4vw, 1.02rem);
        min-height: 1.6em;
      }

      #response {
        color: var(--ink);
        font-size: clamp(1.02rem, 2.1vw, 1.35rem);
        line-height: 1.6;
        min-height: 2.8em;
      }

      #response p,
      #response blockquote,
      #response pre,
      #response ul,
      #response ol {
        margin: 0.55em 0;
      }

      #response p:first-child,
      #response blockquote:first-child,
      #response pre:first-child,
      #response ul:first-child,
      #response ol:first-child {
        margin-top: 0;
      }

      #response p:last-child,
      #response blockquote:last-child,
      #response pre:last-child,
      #response ul:last-child,
      #response ol:last-child {
        margin-bottom: 0;
      }

      #response h1,
      #response h2,
      #response h3,
      #response h4,
      #response h5,
      #response h6 {
        margin: 0.45em 0;
        line-height: 1.25;
      }

      #response ul,
      #response ol {
        display: inline-block;
        text-align: left;
        padding-left: 1.25em;
        max-width: 100%;
      }

      #response pre {
        text-align: left;
        border: 1px solid rgba(255, 255, 255, 0.16);
        border-radius: 10px;
        padding: 10px;
        overflow-x: auto;
        background: rgba(0, 0, 0, 0.5);
      }

      #response code {
        font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
        font-size: 0.82em;
        border: 1px solid rgba(255, 255, 255, 0.1);
        border-radius: 6px;
        background: rgba(255, 255, 255, 0.06);
        padding: 1px 5px;
      }

      #response pre code {
        border: none;
        border-radius: 0;
        background: transparent;
        padding: 0;
      }

      #response blockquote {
        border-left: 2px solid rgba(255, 255, 255, 0.2);
        padding-left: 10px;
        color: var(--ink-muted);
        text-align: left;
      }

      #response a {
        color: #f4c2c2;
        text-decoration: underline;
      }

      #transcript.visible,
      #response.visible {
        opacity: 1;
        transform: translateY(0);
      }

      .response-word {
        display: inline-block;
        opacity: 0;
        transform: translateY(8px);
        filter: blur(4px);
        transition: opacity var(--word-fade-ms) ease, transform var(--word-fade-ms) ease, filter var(--word-fade-ms) ease;
      }

      .response-word.reveal {
        opacity: 1;
        transform: translateY(0);
        filter: blur(0);
      }

      body.scene-fade #avatar-shell,
      body.scene-fade #transcript,
      body.scene-fade #response {
        opacity: 0 !important;
      }

      #debug-indicator {
        position: fixed;
        left: 10px;
        bottom: 8px;
        z-index: 4;
        font-size: 11px;
        line-height: 1.4;
        color: rgba(234, 234, 234, 0.82);
        font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
        white-space: pre;
        pointer-events: none;
        user-select: none;
      }

      #assistant-controls {
        position: fixed;
        left: 50%;
        bottom: 14px;
        z-index: 9;
        width: min(920px, calc(100vw - 24px));
        transform: translateX(-50%);
        display: grid;
        gap: 8px;
      }

      #runtime-worker {
        display: inline-flex;
        align-items: center;
        justify-self: end;
        gap: 7px;
        padding: 5px 10px;
        border-radius: 999px;
        border: 1px solid rgba(255, 255, 255, 0.2);
        background: rgba(0, 0, 0, 0.5);
        font-size: 0.72rem;
        letter-spacing: 0.04em;
        text-transform: uppercase;
        color: var(--ink-muted);
      }

      #runtime-worker-dot {
        width: 7px;
        height: 7px;
        border-radius: 999px;
        background: #ff8f8f;
        box-shadow: 0 0 0 2px rgba(255, 143, 143, 0.18);
      }

      #runtime-worker.is-active #runtime-worker-dot {
        background: #9be7a8;
        box-shadow: 0 0 0 2px rgba(155, 231, 168, 0.18);
      }

      #mic-warning {
        margin: 0;
        font-size: 0.8rem;
        color: #ffb7b7;
      }

      #mic-retry-button {
        border: 1px solid rgba(255, 255, 255, 0.2);
        background: rgba(0, 0, 0, 0.5);
        color: var(--ink);
        border-radius: 999px;
        font-size: 0.74rem;
        letter-spacing: 0.04em;
        text-transform: uppercase;
        padding: 6px 12px;
        cursor: pointer;
      }

      #assistant-notice {
        min-height: 18px;
        display: flex;
        align-items: center;
        justify-content: flex-end;
        gap: 8px;
      }

      #input-form {
        border: 1px solid var(--panel-border);
        background: var(--panel-bg);
        border-radius: 16px;
        padding: 8px;
        display: grid;
        grid-template-columns: minmax(0, 1fr) auto auto;
        align-items: center;
        gap: 8px;
      }

      #text-input {
        width: 100%;
        min-width: 0;
        border: none;
        outline: none;
        color: var(--ink);
        background: transparent;
        padding: 10px 12px;
        font-size: 0.95rem;
        line-height: 1.35;
        min-height: 42px;
        max-height: 132px;
        resize: none;
        overflow-y: auto;
        font-family: inherit;
      }

      #text-input::placeholder {
        color: rgba(234, 234, 234, 0.56);
      }

      .input-action {
        border: 1px solid rgba(255, 255, 255, 0.2);
        background: rgba(0, 0, 0, 0.5);
        color: var(--ink);
        border-radius: 999px;
        padding: 8px 13px;
        font-size: 0.75rem;
        letter-spacing: 0.04em;
        text-transform: uppercase;
        cursor: pointer;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        line-height: 1;
        transition: border-color 180ms ease, transform 180ms ease;
      }

      .input-action:hover {
        border-color: var(--panel-strong);
        transform: translateY(-1px);
      }

      .input-action:disabled {
        opacity: 0.45;
        cursor: not-allowed;
        transform: none;
      }

      #send-button {
        min-width: 72px;
        min-height: 42px;
      }

      #send-button .send-label {
        display: inline-flex;
        align-items: center;
        justify-content: center;
      }

      #send-button .stop-icon {
        display: none;
        width: 12px;
        height: 12px;
        border-radius: 1px;
        background: currentColor;
        margin: 0;
      }

      #send-button.is-stop {
        width: 42px;
        min-width: 42px;
        height: 42px;
        padding: 0;
        border-radius: 999px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        line-height: 0;
      }

      #send-button.is-stop .send-label {
        display: none;
      }

      #send-button.is-stop .stop-icon {
        display: block;
      }

      .mic-button {
        width: 42px;
        height: 42px;
        padding: 0;
        border-radius: 999px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
      }

      .mic-icon {
        width: 18px;
        height: 18px;
        display: block;
        fill: currentColor;
      }

      .mic-button.is-listening {
        border-color: rgba(214, 79, 79, 0.9);
        background: rgba(80, 10, 10, 0.65);
        box-shadow: 0 0 0 6px rgba(214, 79, 79, 0.18);
        animation: mic-pulse 1.4s ease-in-out infinite;
      }

      @keyframes mic-pulse {
        0%,
        100% {
          box-shadow: 0 0 0 4px rgba(214, 79, 79, 0.14);
        }
        50% {
          box-shadow: 0 0 0 8px rgba(214, 79, 79, 0.28);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        .orb-layer,
        #transcript,
        #response,
        .response-word {
          transition: none;
        }
      }

      @media (max-width: 680px) {
        #assistant-controls {
          width: calc(100vw - 14px);
          bottom: 8px;
          gap: 8px;
        }

        #input-form {
          border-radius: 12px;
          grid-template-columns: 1fr auto auto;
          gap: 6px;
        }
      }
    </style>
  </head>
  <body
    class="state-idle"
    data-debug-ui="{{ '1' if debug_ui else '0' }}"
    data-post-response-hold-seconds="{{ post_response_hold_seconds }}"
  >
    <div id="screen-fade" class="screen-fade"></div>
    <button
      id="settings-button"
      type="button"
      aria-label="{{ t('assistant.settings_aria') }}"
      title="{{ t('assistant.settings_aria') }}"
    >
      {{ t("assistant.settings_button") }}
    </button>
    <main class="scene" aria-live="polite">
      <div id="stage">
        <div id="avatar-shell" class="mode-initial" aria-hidden="true">
          <img id="orb-initial" class="orb-layer" src="/static/initial.gif" alt="" />
          <img id="orb-final" class="orb-layer" src="/static/final.gif" alt="" />
        </div>
        <p id="transcript"></p>
        <div id="response"></div>
      </div>
    </main>
    <section id="assistant-controls" aria-label="assistant controls">
      <div id="runtime-worker" class="is-inactive" aria-live="polite">
        <span id="runtime-worker-dot" aria-hidden="true"></span>
        <span id="runtime-worker-text">{{ t("assistant.runtime_worker_inactive") }}</span>
      </div>
      <form id="input-form" autocomplete="off">
        <textarea id="text-input" rows="1" placeholder="{{ t('assistant.input_placeholder') }}"></textarea>
        <button
          id="send-button"
          class="input-action"
          type="submit"
          aria-label="{{ t('assistant.send_button') }}"
          title="{{ t('assistant.send_button') }}"
        >
          <span class="send-label">{{ t("assistant.send_button") }}</span>
          <span class="stop-icon" aria-hidden="true"></span>
        </button>
        <button
          id="mic-button"
          class="input-action mic-button"
          type="button"
          aria-label="{{ t('assistant.mic_button') }}"
          title="{{ t('assistant.mic_button') }}"
          aria-pressed="false"
        >
          <svg class="mic-icon" viewBox="0 0 24 24" aria-hidden="true">
            <path
              d="M12 15.5a3.8 3.8 0 0 0 3.8-3.8V6.2A3.8 3.8 0 0 0 12 2.4a3.8 3.8 0 0 0-3.8 3.8v5.5a3.8 3.8 0 0 0 3.8 3.8Zm6.1-3.8a1 1 0 1 0-2 0 4.1 4.1 0 0 1-8.2 0 1 1 0 0 0-2 0 6.1 6.1 0 0 0 5.1 6v2.2H8.9a1 1 0 1 0 0 2h6.2a1 1 0 1 0 0-2H13v-2.2a6.1 6.1 0 0 0 5.1-6Z"
            />
          </svg>
        </button>
      </form>
      <div id="assistant-notice" hidden>
        <p id="mic-warning" hidden>{{ t("assistant.mic_unavailable") }}</p>
        <button id="mic-retry-button" type="button" hidden>{{ t("assistant.mic_retry") }}</button>
      </div>
    </section>
    <div id="debug-indicator" hidden></div>

    <script>
      ;(function () {
        var overlay = document.getElementById("screen-fade")
        var body = document.body
        var timerId = null
        var revealed = false

        function reveal(reason) {
          if (revealed) return
          revealed = true
          if (timerId) {
            window.clearTimeout(timerId)
            timerId = null
          }
          if (overlay) {
            overlay.classList.add("hidden")
          }
          if (body) {
            body.classList.add("ready")
          }
          try {
            var payload = {
              phase: "assistant_ui_revealed_preflight",
              detail: String(reason || ""),
              ts_ms: Date.now(),
            }
            fetch("/assistant/ui/startup-phase", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify(payload),
              keepalive: true,
            })
          } catch (error) {
            void error
            // ignore telemetry errors in fallback path
          }
        }

        timerId = window.setTimeout(function () {
          reveal("hard-failsafe")
        }, 4200)
        window.__orionStartupReveal = reveal
      })()
    </script>

    <script>
      (() => {
        const i18n = {{ i18n_strings | tojson }}
        const VALID_BACKEND_STATES = new Set([
          "idle",
          "listening",
          "transcribing",
          "thinking",
          "speaking",
          "cooldown",
          "audio_finished",
          "paused",
        ])
        const WORD_FADE_MS = 320
        const TRANSCRIPT_VISIBLE_MS = 4600
        const FADE_OUT_MS = 520
        const STOP_RESPONSE_HOLD_SECONDS = 3.0
        const STARTUP_FAILSAFE_MS = 2600

        const body = document.body
        const params = new URLSearchParams(window.location.search)
        const screenFade = document.getElementById("screen-fade")
        const settingsButton = document.getElementById("settings-button")
        const avatarShell = document.getElementById("avatar-shell")
        const transcriptEl = document.getElementById("transcript")
        const responseEl = document.getElementById("response")
        const debugEl = document.getElementById("debug-indicator")
        const inputForm = document.getElementById("input-form")
        const textInput = document.getElementById("text-input")
        const sendButton = document.getElementById("send-button")
        const micButton = document.getElementById("mic-button")
        const assistantNotice = document.getElementById("assistant-notice")
        const micRetryButton = document.getElementById("mic-retry-button")
        const micWarning = document.getElementById("mic-warning")
        const runtimeWorker = document.getElementById("runtime-worker")
        const runtimeWorkerText = document.getElementById("runtime-worker-text")

        const tt = (key) => {
          const value = i18n && typeof i18n === "object" ? i18n[key] : undefined
          return typeof value === "string" ? value : key
        }

        const postStartupPhase = (phase, detail = "") => {
          const payload = {
            phase: String(phase || "").trim(),
            detail: String(detail || "").trim(),
            ts_ms: Date.now(),
          }
          if (!payload.phase) return
          try {
            void fetch("/assistant/ui/startup-phase", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify(payload),
              keepalive: true,
            })
          } catch {
            // Never block UI interaction on telemetry errors.
          }
        }

        const isDevUi = body.dataset.debugUi === "1"
        const debugEnabled = isDevUi && params.get("debug") === "1"
        const holdDefault = (() => {
          const value = Number.parseFloat(body.dataset.postResponseHoldSeconds || "3.0")
          return Number.isFinite(value) && value > 0 ? value : 3.0
        })()
        const wordIntervalBase = Number.parseInt(params.get("word_ms") || "230", 10)

        document.documentElement.style.setProperty("--word-fade-ms", `${WORD_FADE_MS}ms`)

        let uiMode = "assistant" // assistant | config
        let uiPhase = "idle" // idle | active | hold | paused
        let assistantState = "idle"
        let currentCycleId = null
        let speakingLock = false
        let wsConnected = false
        let wsSocket = null
        let lastAppliedEventSeq = 0
        let pendingTranscript = ""
        let pendingResponse = ""
        let holdTimer = null
        let fadeTimer = null
        let debugTicker = null
        let holdEndsAtMs = 0
        let transcriptTimer = null
        let typeTimer = null
        let typeToken = 0
        let wsPingTimer = null
        let voiceStatusPollTimer = null
        let lastEventType = "init"
        let lastEventState = ""
        let lastEventCycleId = null
        let lastEventPayload = null
        let lastEventAt = Date.now()
        let submittingPrompt = false
        let pttCommandInFlight = null
        let pttCommandTimer = null
        let stopRequestInFlight = false
        let stopRequestTimer = null
        const interruptedCycleIds = new Set()
        let noticeKind = ""
        let startupRevealDone = false
        let startupFailsafeTimer = null
        let textQueryVisualState = false
        const markdownCache = new Map()
        let voiceStatus = {
          state: "idle",
          backend: "unknown",
          capture_backend: "unknown",
          input_device_name: "none",
          input_device_index: null,
          input_sample_rate: null,
          input_channels: 1,
          input_dtype: "int16",
          microphone_available: false,
          microphone_error: tt("mic_unavailable"),
          input_level_rms: 0,
          input_level_peak: 0,
          input_level_norm: 0,
          worker_active: false,
          worker_last_seen_ts: null,
        }

        const cancelTimer = (timerId) => {
          if (timerId) clearTimeout(timerId)
          return null
        }

        const escapeHtml = (value) =>
          String(value || "")
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#39;")

        const renderMarkdownHtml = async (rawText) => {
          const text = String(rawText || "")
          if (!text.trim()) return ""
          if (markdownCache.has(text)) {
            return markdownCache.get(text)
          }
          const renderPromise = fetch("/assistant/render-markdown", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ text }),
          })
            .then(async (response) => {
              const payload = await response.json().catch(() => ({}))
              if (!response.ok) {
                throw new Error(String(payload?.detail || `HTTP ${response.status}`))
              }
              return String(payload?.html || "")
            })
            .catch(() => `<p>${escapeHtml(text)}</p>`)
          markdownCache.set(text, renderPromise)
          return renderPromise
        }

        const tokenizeResponseWords = (root) => {
          const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
            acceptNode(node) {
              const value = String(node.nodeValue || "")
              if (!value.trim()) return NodeFilter.FILTER_REJECT
              const parent = node.parentElement
              if (parent && parent.closest("pre, code")) return NodeFilter.FILTER_REJECT
              return NodeFilter.FILTER_ACCEPT
            },
          })
          const textNodes = []
          let current = walker.nextNode()
          while (current) {
            textNodes.push(current)
            current = walker.nextNode()
          }

          const words = []
          for (const node of textNodes) {
            if (!node.parentNode) continue
            const fragment = document.createDocumentFragment()
            const tokens = String(node.nodeValue || "").split(/(\s+)/)
            for (const token of tokens) {
              if (!token) continue
              if (/^\s+$/.test(token)) {
                fragment.appendChild(document.createTextNode(token))
                continue
              }
              const span = document.createElement("span")
              span.className = "response-word"
              span.textContent = token
              fragment.appendChild(span)
              words.push(span)
            }
            node.parentNode.replaceChild(fragment, node)
          }
          return words
        }

        const forceRevealStartup = (reason) => {
          if (startupRevealDone) return
          startupRevealDone = true
          startupFailsafeTimer = cancelTimer(startupFailsafeTimer)
          if (typeof window.__orionStartupReveal === "function") {
            window.__orionStartupReveal(reason)
          } else {
            screenFade.classList.add("hidden")
            body.classList.add("ready")
          }
          postStartupPhase("assistant_ui_revealed", reason)
        }

        const revealStartup = () => {
          setTimeout(() => {
            forceRevealStartup("normal")
          }, 620)
        }

        const navigateWithFade = (url) => {
          screenFade.classList.remove("hidden")
          setTimeout(() => {
            window.location.assign(url)
          }, 620)
        }

        const setServerUiMode = async (mode) => {
          const target = mode === "config" ? "config" : "assistant"
          try {
            await fetch("/assistant/ui-mode", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ mode: target }),
            })
          } catch (error) {
            debugLog("ui mode sync failed", { mode: target, error: String(error) })
          }
        }

        const isSettingsLocked = () => {
          if (uiMode !== "assistant") return true
          if (speakingLock) return true
          if (uiPhase === "hold") return true
          if (uiPhase === "active" && currentCycleId) return true
          return assistantState === "thinking" || assistantState === "speaking" || assistantState === "cooldown"
        }

        const syncSettingsButton = () => {
          const locked = isSettingsLocked()
          settingsButton.disabled = locked
          settingsButton.classList.toggle("is-disabled", locked)
          settingsButton.textContent = tt("settings_button")
          settingsButton.setAttribute("aria-label", tt("settings_aria"))
          settingsButton.setAttribute("title", locked ? tt("settings_disabled") : tt("settings_aria"))
        }

        const canStopResponse = () => {
          if (uiMode !== "assistant") return false
          if (stopRequestInFlight) return false
          if (uiPhase !== "active") return false
          return assistantState === "speaking" || assistantState === "thinking"
        }

        const shouldShowStopButton = () => {
          return canStopResponse()
        }

        const isInteractionLocked = () => {
          if (uiMode !== "assistant") return true
          if (submittingPrompt) return true
          if (stopRequestInFlight) return true
          if (uiPhase === "paused" || uiPhase === "hold") return true
          if (uiPhase === "active") return true
          if (body.classList.contains("scene-fade")) return true
          return assistantState === "thinking" || assistantState === "speaking" || assistantState === "transcribing" || assistantState === "cooldown"
        }

        const canSubmitPrompt = () => {
          if (shouldShowStopButton()) return false
          return !isInteractionLocked()
        }

        const syncSendButtonMode = () => {
          const stopMode = shouldShowStopButton()
          sendButton.classList.toggle("is-stop", stopMode)
          if (stopMode) {
            sendButton.setAttribute("aria-label", tt("stop_aria"))
            sendButton.setAttribute("title", tt("stop_aria"))
            return
          }
          sendButton.setAttribute("aria-label", tt("send_button"))
          sendButton.setAttribute("title", tt("send_button"))
        }

        const syncInteractionControls = () => {
          syncSendButtonMode()
          const stopMode = shouldShowStopButton()
          const promptLocked = !canSubmitPrompt()
          textInput.disabled = promptLocked
          textInput.setAttribute("aria-disabled", String(promptLocked))
          const sendDisabled = stopMode ? !canStopResponse() : promptLocked
          sendButton.disabled = sendDisabled
          sendButton.setAttribute("aria-disabled", String(sendDisabled))
        }

        const debugLog = (...parts) => {
          if (!debugEnabled) return
          console.log("[assistant-ui]", new Date().toISOString(), ...parts)
        }

        const setNotice = (text, { retry = false, kind = "generic" } = {}) => {
          const message = String(text || "").trim()
          if (!message) return
          noticeKind = kind
          assistantNotice.hidden = false
          micWarning.hidden = false
          micWarning.textContent = message
          micRetryButton.hidden = !retry
          micRetryButton.title = retry ? message : ""
        }

        const clearNotice = (kind = null) => {
          if (kind && noticeKind !== kind) return
          noticeKind = ""
          assistantNotice.hidden = true
          micWarning.hidden = true
          micWarning.textContent = ""
          micRetryButton.hidden = true
          micRetryButton.title = ""
        }

        const clearPttCommandInFlight = () => {
          pttCommandInFlight = null
          pttCommandTimer = cancelTimer(pttCommandTimer)
        }

        const clearStopRequestInFlight = () => {
          stopRequestInFlight = false
          stopRequestTimer = cancelTimer(stopRequestTimer)
          syncInteractionControls()
          applyVoiceStatusUi()
        }

        const syncRuntimeWorkerUi = () => {
          const workerActive = Boolean(voiceStatus.worker_active)
          runtimeWorker.classList.toggle("is-active", workerActive)
          runtimeWorker.classList.toggle("is-inactive", !workerActive)
          runtimeWorkerText.textContent = workerActive ? tt("runtime_worker_active") : tt("runtime_worker_inactive")
        }

        const applyVoiceStatusUi = () => {
          const state = String(voiceStatus.state || "idle")
          const workerActive = Boolean(voiceStatus.worker_active)
          const hasMic = Boolean(voiceStatus.microphone_available)
          const isListening = workerActive && hasMic && state === "listening"
          const busyVoiceState = new Set(["thinking", "speaking", "transcribing", "cooldown", "paused"])
          const lockedForInteraction = isInteractionLocked()
          const micShouldBeDisabled =
            !workerActive || lockedForInteraction || Boolean(pttCommandInFlight) || !hasMic || (!isListening && busyVoiceState.has(state))
          const micError = String(voiceStatus.microphone_error || "").trim()

          const micDisabledReason = workerActive ? micError || tt("mic_unavailable") : tt("runtime_not_running")
          micButton.disabled = micShouldBeDisabled
          micButton.title = workerActive && hasMic ? (isListening ? tt("state_listening") : tt("mic_button")) : micDisabledReason
          micButton.setAttribute("aria-disabled", String(micShouldBeDisabled))
          micButton.setAttribute("aria-pressed", String(isListening))
          micButton.classList.toggle("is-listening", isListening)

          if (!workerActive) {
            if (!noticeKind || noticeKind === "runtime" || noticeKind === "mic") {
              setNotice(tt("runtime_not_running"), { kind: "runtime" })
            }
          } else if (!hasMic) {
            if (!noticeKind || noticeKind === "runtime" || noticeKind === "mic") {
              setNotice(micDisabledReason, { retry: true, kind: "mic" })
            }
          } else if (noticeKind === "mic" || noticeKind === "runtime") {
            clearNotice("mic")
            clearNotice("runtime")
          }
          syncRuntimeWorkerUi()
          syncInteractionControls()
        }

        const applyVoiceStatus = (payload) => {
          if (!payload || typeof payload !== "object") return
          voiceStatus = {
            ...voiceStatus,
            state: String(payload.state || voiceStatus.state || "idle"),
            backend: String(payload.backend || voiceStatus.backend || "unknown"),
            input_device_name: String(payload.input_device_name || voiceStatus.input_device_name || "none"),
            input_device_index:
              payload.input_device_index === undefined ? voiceStatus.input_device_index : payload.input_device_index,
            input_sample_rate:
              payload.input_sample_rate === undefined ? voiceStatus.input_sample_rate : payload.input_sample_rate,
            input_channels: payload.input_channels === undefined ? voiceStatus.input_channels : payload.input_channels,
            input_dtype: String(payload.input_dtype || voiceStatus.input_dtype || "int16"),
            microphone_available:
              payload.microphone_available === undefined
                ? Boolean(voiceStatus.microphone_available)
                : Boolean(payload.microphone_available),
            microphone_error:
              payload.microphone_error === undefined
                ? voiceStatus.microphone_error
                : payload.microphone_error
                ? String(payload.microphone_error)
                : "",
            capture_backend: String(payload.capture_backend || voiceStatus.capture_backend || voiceStatus.backend || "unknown"),
            input_level_rms: Number(payload.input_level_rms ?? voiceStatus.input_level_rms ?? 0),
            input_level_peak: Number(payload.input_level_peak ?? voiceStatus.input_level_peak ?? 0),
            input_level_norm: Number(payload.input_level_norm ?? voiceStatus.input_level_norm ?? 0),
            worker_active:
              payload.worker_active === undefined ? Boolean(voiceStatus.worker_active) : Boolean(payload.worker_active),
            worker_last_seen_ts:
              payload.worker_last_seen_ts === undefined ? voiceStatus.worker_last_seen_ts : payload.worker_last_seen_ts,
          }
          applyVoiceStatusUi()
        }

        const fetchVoiceStatus = async () => {
          try {
            const response = await fetch("/assistant/ui/voice-status", { cache: "no-store" })
            if (!response.ok) return
            const payload = await response.json()
            applyVoiceStatus(payload)
          } catch (error) {
            debugLog("voice status fetch error", error)
          }
        }

        const normalizeCycleId = (payload) => {
          const raw = payload && typeof payload === "object" ? payload.cycle_id : null
          if (raw === undefined || raw === null) return null
          const clean = String(raw).trim()
          return clean || null
        }

        const holdRemainingSeconds = () => {
          if (uiPhase !== "hold") return 0
          if (!holdEndsAtMs) return 0
          return Math.max(0, (holdEndsAtMs - Date.now()) / 1000)
        }

        const setBodyState = (state) => {
          assistantState = state
          body.classList.remove(
            "state-idle",
            "state-listening",
            "state-transcribing",
            "state-thinking",
            "state-speaking",
            "state-cooldown",
            "state-paused",
          )
          body.classList.add(`state-${state}`)
          syncSettingsButton()
          syncInteractionControls()
          debugLog("t_ui_state_change", {
            ts_ms: Date.now(),
            state,
            uiPhase,
            speaking_lock: speakingLock,
            cycle_id: currentCycleId,
          })
        }

        const setVisualInitial = () => {
          avatarShell.classList.remove("mode-final")
        }

        const setVisualFinal = () => {
          avatarShell.classList.add("mode-final")
        }

        const clearText = () => {
          transcriptEl.textContent = ""
          responseEl.innerHTML = ""
          transcriptEl.classList.remove("visible")
          responseEl.classList.remove("visible")
        }

        const enterIdle = () => {
          uiPhase = "idle"
          currentCycleId = null
          speakingLock = false
          textQueryVisualState = false
          stopRequestInFlight = false
          interruptedCycleIds.clear()
          holdTimer = cancelTimer(holdTimer)
          fadeTimer = cancelTimer(fadeTimer)
          holdEndsAtMs = 0
          transcriptTimer = cancelTimer(transcriptTimer)
          typeTimer = cancelTimer(typeTimer)
          typeToken += 1
          pendingTranscript = ""
          pendingResponse = ""
          clearText()
          body.classList.remove("scene-fade")
          setVisualInitial()
          setBodyState("idle")
          debugLog("ui_initial_visible", { ts_ms: Date.now(), uiPhase, speakingLock })
        }

        const beginCycle = (cycleId) => {
          if (!cycleId) return
          if (currentCycleId !== cycleId) {
            debugLog("cycle_start", { previous_cycle_id: currentCycleId, cycle_id: cycleId })
            typeToken += 1
            typeTimer = cancelTimer(typeTimer)
            transcriptTimer = cancelTimer(transcriptTimer)
            pendingTranscript = ""
            pendingResponse = ""
            clearText()
          }
          currentCycleId = cycleId
          holdTimer = cancelTimer(holdTimer)
          fadeTimer = cancelTimer(fadeTimer)
          holdEndsAtMs = 0
          body.classList.remove("scene-fade")
          uiPhase = "active"
          setVisualFinal()
        }

        const showTranscript = (text) => {
          const clean = String(text || "").trim()
          if (!clean) return
          transcriptTimer = cancelTimer(transcriptTimer)
          transcriptEl.textContent = clean
          transcriptEl.classList.add("visible")
          transcriptTimer = setTimeout(() => {
            transcriptEl.classList.remove("visible")
          }, TRANSCRIPT_VISIBLE_MS)
        }

        const getWordInterval = (wordCount) => {
          if (wordCount > 120) return 190
          if (wordCount > 80) return 220
          return wordIntervalBase
        }

        const startTypewriter = async (text) => {
          const clean = String(text || "").trim()
          if (!clean) return
          pendingResponse = clean
          typeToken += 1
          const token = typeToken
          typeTimer = cancelTimer(typeTimer)
          responseEl.innerHTML = ""
          responseEl.classList.add("visible")

          const rendered = await renderMarkdownHtml(clean)
          if (token !== typeToken) return
          responseEl.innerHTML = rendered

          const words = tokenizeResponseWords(responseEl)
          if (!words.length) return
          const stepMs = getWordInterval(words.length)
          let index = 0

          const emitWord = () => {
            if (token !== typeToken) return
            if (index >= words.length) return
            words[index].classList.add("reveal")
            index += 1
            typeTimer = setTimeout(emitWord, stepMs)
          }

          emitWord()
        }

        const ensureActive = (stateForBody) => {
          if (uiPhase === "hold") {
            holdTimer = cancelTimer(holdTimer)
          }
          fadeTimer = cancelTimer(fadeTimer)
          body.classList.remove("scene-fade")
          uiPhase = "active"
          setVisualFinal()
          setBodyState(stateForBody)

          if (pendingTranscript) {
            showTranscript(pendingTranscript)
          }
        }

        const setResponseInstant = (text) => {
          const clean = String(text || "").trim()
          if (!clean) return
          pendingResponse = clean
          typeToken += 1
          const token = typeToken
          typeTimer = cancelTimer(typeTimer)
          responseEl.innerHTML = ""
          responseEl.classList.add("visible")
          void renderMarkdownHtml(clean)
            .then((rendered) => {
              if (token !== typeToken) return
              responseEl.innerHTML = rendered
            })
            .catch(() => {
              if (token !== typeToken) return
              responseEl.textContent = clean
            })
        }

        const startFadeToIdle = () => {
          if (uiPhase === "idle") return
          holdTimer = cancelTimer(holdTimer)
          fadeTimer = cancelTimer(fadeTimer)
          holdEndsAtMs = 0
          body.classList.add("scene-fade")
          fadeTimer = setTimeout(() => {
            enterIdle()
            updateDebug()
          }, FADE_OUT_MS)
        }

        const startHold = (seconds) => {
          if (uiPhase === "idle") {
            debugLog("ignoring hold event while idle")
            return
          }
          if (uiPhase === "hold") {
            debugLog("ignoring duplicate hold event")
            return
          }

          const holdSeconds = Number.isFinite(seconds) && seconds > 0 ? seconds : holdDefault
          uiPhase = "hold"
          speakingLock = false
          setVisualFinal()
          setBodyState("cooldown")
          holdEndsAtMs = Date.now() + holdSeconds * 1000
          holdTimer = cancelTimer(holdTimer)
          holdTimer = setTimeout(() => {
            startFadeToIdle()
          }, holdSeconds * 1000)
        }

        const startStopHold = () => {
          typeToken += 1
          typeTimer = cancelTimer(typeTimer)
          transcriptTimer = cancelTimer(transcriptTimer)
          pendingTranscript = ""
          pendingResponse = ""
          clearText()
          if (uiPhase === "idle" || uiPhase === "paused") {
            uiPhase = "active"
            setVisualFinal()
          }
          speakingLock = false
          startHold(STOP_RESPONSE_HOLD_SECONDS)
        }

        const requestStopResponse = async () => {
          if (!canStopResponse()) return

          const activeCycle = currentCycleId ? String(currentCycleId) : null
          if (activeCycle) {
            interruptedCycleIds.add(activeCycle)
          }

          stopRequestInFlight = true
          syncInteractionControls()
          applyVoiceStatusUi()
          startStopHold()
          updateDebug()

          if (sendWsCommand("stop_response")) {
            stopRequestTimer = cancelTimer(stopRequestTimer)
            stopRequestTimer = setTimeout(() => {
              clearStopRequestInFlight()
            }, 1800)
            return
          }

          try {
            const response = await fetch("/assistant/ui/stop", { method: "POST" })
            const payload = await response.json().catch(() => ({}))
            const stoppedCycle = payload && payload.cycle_id ? String(payload.cycle_id) : null
            if (stoppedCycle) {
              interruptedCycleIds.add(stoppedCycle)
            }
            if (!response.ok || !payload.ok) {
              if (!payload.duplicate) {
                setNotice(String(payload.detail || tt("voice_connection_unavailable")), { kind: "command" })
              }
            } else if (noticeKind === "command" || noticeKind === "connection") {
              clearNotice()
            }
          } catch (error) {
            setNotice(String((error && error.message) || tt("voice_connection_unavailable")), { kind: "command" })
          } finally {
            clearStopRequestInFlight()
            updateDebug()
          }
        }

        const shouldIgnoreByCycle = (cycleId, payload) => {
          if (cycleId && interruptedCycleIds.has(cycleId)) {
            const eventType = String((payload && payload.type) || "")
            const state = String((payload && payload.state) || "")
            if (!(eventType === "state" && (state === "audio_finished" || state === "cooldown" || state === "idle"))) {
              debugLog("ignored interrupted cycle event", { cycleId, eventType, state })
              return true
            }
          }
          // Root-cause guard: without cycle correlation, stray idle/clear or reconnect events can
          // collapse an active speaking view mid-response.
          if (uiPhase !== "active" && uiPhase !== "hold") return false
          if (!currentCycleId) return false
          if (!cycleId) {
            debugLog("ignored_event_cycle_mismatch", {
              uiPhase,
              speakingLock,
              currentCycleId,
              eventCycleId: cycleId,
              payload,
            })
            return true
          }
          if (cycleId !== currentCycleId) {
            debugLog("ignored_event_cycle_mismatch", {
              uiPhase,
              speakingLock,
              currentCycleId,
              eventCycleId: cycleId,
              payload,
            })
            return true
          }
          return false
        }

        const handleState = (payload) => {
          const stateRaw = String(payload.state || "idle")
          const state = VALID_BACKEND_STATES.has(stateRaw) ? stateRaw : "idle"
          const cycleId = normalizeCycleId(payload)

          if (shouldIgnoreByCycle(cycleId, payload)) {
            return
          }

          if (state === "paused") {
            uiPhase = "paused"
            currentCycleId = null
            speakingLock = false
            clearStopRequestInFlight()
            holdTimer = cancelTimer(holdTimer)
            fadeTimer = cancelTimer(fadeTimer)
            holdEndsAtMs = 0
            setVisualInitial()
            setBodyState("paused")
            return
          }

          if (state === "idle") {
            if (speakingLock) {
              debugLog("ignored state:idle while speaking lock is active")
              return
            }
            clearStopRequestInFlight()
            if (uiPhase === "idle" || uiPhase === "paused") {
              setVisualInitial()
              setBodyState("idle")
            } else {
              debugLog("ignored state:idle during active phase", { uiPhase })
            }
            return
          }

          if (state === "listening" || state === "transcribing") {
            if (speakingLock) {
              debugLog("ignored waiting state while speaking lock is active", { state })
              return
            }
            if (uiPhase === "idle" || uiPhase === "paused") {
              setVisualInitial()
              setBodyState(state)
            } else {
              debugLog("ignored waiting state during active/hold", { state, uiPhase })
            }
            return
          }

          if (state === "thinking") {
            if (cycleId) {
              if (uiPhase === "idle") {
                beginCycle(cycleId)
              } else if (currentCycleId && cycleId !== currentCycleId) {
                debugLog("ignored thinking for foreign cycle", { cycleId, currentCycleId })
                return
              } else if (!currentCycleId) {
                beginCycle(cycleId)
              }
            }
            speakingLock = false
            ensureActive("thinking")
            return
          }

          if (state === "speaking") {
            if (cycleId) {
              if (uiPhase === "idle") {
                beginCycle(cycleId)
              } else if (currentCycleId && cycleId !== currentCycleId) {
                debugLog("ignored speaking for foreign cycle", { cycleId, currentCycleId })
                return
              } else if (!currentCycleId) {
                beginCycle(cycleId)
              }
            }
            speakingLock = true
            ensureActive("speaking")
            return
          }

          if (state === "audio_finished") {
            if (!cycleId || (currentCycleId && cycleId !== currentCycleId)) {
              debugLog("ignored audio_finished for non-active cycle", { cycleId, currentCycleId })
              return
            }
            if (!currentCycleId && cycleId) {
              beginCycle(cycleId)
            }
            speakingLock = false
            startHold(Number(payload.seconds))
            return
          }

          if (state === "cooldown") {
            if (speakingLock) {
              debugLog("ignored cooldown while speaking lock is active")
              return
            }
            if (!cycleId || (currentCycleId && cycleId !== currentCycleId)) {
              debugLog("ignored cooldown for non-active cycle", { cycleId, currentCycleId })
              return
            }
            if (!currentCycleId && cycleId) {
              beginCycle(cycleId)
            }
            startHold(Number(payload.seconds))
          }
        }

        const applyEvent = (payload) => {
          if (!payload || typeof payload !== "object") return
          const eventSeq = Number(payload.event_seq)
          if (Number.isFinite(eventSeq) && eventSeq > 0) {
            if (eventSeq <= lastAppliedEventSeq) {
              debugLog("ignored_out_of_order_event", {
                eventSeq,
                lastAppliedEventSeq,
                payload,
              })
              return
            }
            lastAppliedEventSeq = eventSeq
          }
          lastEventType = String(payload.type || "unknown")
          lastEventState = String(payload.state || "")
          lastEventCycleId = normalizeCycleId(payload)
          lastEventPayload = payload
          lastEventAt = Date.now()
          debugLog("event", payload)

          if (lastEventType === "voice_status") {
            applyVoiceStatus(payload)
            updateDebug()
            return
          }

          if (lastEventType === "command_result") {
            if (payload.command === pttCommandInFlight) {
              clearPttCommandInFlight()
              applyVoiceStatusUi()
            }
            if (payload.command === "stop_response") {
              const stoppedCycle = normalizeCycleId(payload)
              if (stoppedCycle) {
                interruptedCycleIds.add(stoppedCycle)
              }
              if (!payload.ok && !payload.duplicate) {
                setNotice(String(payload.detail || tt("voice_connection_unavailable")), { kind: "command" })
              } else if (noticeKind === "command" || noticeKind === "connection") {
                clearNotice()
              }
              clearStopRequestInFlight()
              updateDebug()
              return
            }
            if (!payload.ok) {
              const detail = String(payload.detail || tt("mic_unavailable"))
              const retryable = /mic|microphone|input unavailable/i.test(detail)
              setNotice(detail, { retry: retryable, kind: "command" })
            } else if (noticeKind === "command" || noticeKind === "connection") {
              clearNotice()
            }
            updateDebug()
            return
          }

          if (lastEventType === "state") {
            handleState(payload)
            updateDebug()
            return
          }

          if (lastEventType === "transcript") {
            const clean = String(payload.text || "").trim()
            if (!clean) return
            const cycleId = normalizeCycleId(payload)
            if (shouldIgnoreByCycle(cycleId, payload)) {
              updateDebug()
              return
            }
            if (cycleId && (uiPhase === "idle" || !currentCycleId)) {
              beginCycle(cycleId)
              setBodyState("thinking")
            }
            pendingTranscript = clean
            if (uiPhase !== "idle") {
              showTranscript(clean)
            }
            updateDebug()
            return
          }

          if (lastEventType === "response") {
            const clean = String(payload.text || "").trim()
            if (!clean) return
            const cycleId = normalizeCycleId(payload)
            if (shouldIgnoreByCycle(cycleId, payload)) {
              updateDebug()
              return
            }
            if (cycleId && (uiPhase === "idle" || !currentCycleId)) {
              beginCycle(cycleId)
              setBodyState("thinking")
            }
            pendingResponse = clean
            ensureActive(assistantState === "speaking" ? "speaking" : "thinking")
            if (pendingTranscript) {
              showTranscript(pendingTranscript)
            }
            void startTypewriter(clean)
            updateDebug()
            return
          }

          if (lastEventType === "speaking_done") {
            if (shouldIgnoreByCycle(normalizeCycleId(payload), payload)) {
              updateDebug()
              return
            }
            speakingLock = false
            startHold(Number(payload.seconds))
            updateDebug()
            return
          }

          if (lastEventType === "clear") {
            if (shouldIgnoreByCycle(normalizeCycleId(payload), payload)) {
              updateDebug()
              return
            }
            if (speakingLock) {
              debugLog("ignored clear while speaking lock is active", { uiPhase })
            } else if (uiPhase === "idle") {
              clearText()
            } else {
              debugLog("ignored clear during active/hold; waiting for audio_finished+hold", { uiPhase })
            }
            updateDebug()
          }
        }

        const applyServerSnapshot = (snapshot) => {
          if (!snapshot || typeof snapshot !== "object") return
          applyVoiceStatus({
            state: snapshot.voice_state,
            backend: snapshot.stt_backend,
            capture_backend: snapshot.capture_backend,
            input_device_name: snapshot.input_device_name,
            input_device_index: snapshot.input_device_index,
            input_sample_rate: snapshot.input_sample_rate,
            input_channels: snapshot.input_channels,
            input_dtype: snapshot.input_dtype,
            microphone_available: snapshot.microphone_available,
            microphone_error: snapshot.microphone_error,
            input_level_rms: snapshot.input_level_rms,
            input_level_peak: snapshot.input_level_peak,
            input_level_norm: snapshot.input_level_norm,
            worker_active: snapshot.worker_active,
            worker_last_seen_ts: snapshot.worker_last_seen_ts,
          })
          const phaseRaw = String(snapshot.uiPhase || "idle")
          const phase = phaseRaw === "active" || phaseRaw === "hold" || phaseRaw === "paused" ? phaseRaw : "idle"
          const cycleId = snapshot.cycle_id ? String(snapshot.cycle_id) : null
          const transcript = String(snapshot.transcript || "").trim()
          const response = String(snapshot.response || "").trim()
          const lock = Boolean(snapshot.is_speaking_lock)
          const holdRemaining = Math.max(0, Number(snapshot.hold_remaining || 0))

          debugLog("snapshot", { phase, cycleId, lock, holdRemaining })

          if (phase === "idle") {
            if (uiPhase === "idle") {
              setBodyState("idle")
              setVisualInitial()
            }
            return
          }

          if (phase === "paused") {
            uiPhase = "paused"
            currentCycleId = null
            speakingLock = false
            clearStopRequestInFlight()
            holdTimer = cancelTimer(holdTimer)
            fadeTimer = cancelTimer(fadeTimer)
            holdEndsAtMs = 0
            setVisualInitial()
            setBodyState("paused")
            updateDebug()
            return
          }

          if (cycleId && (uiPhase === "idle" || !currentCycleId)) {
            beginCycle(cycleId)
          } else if (cycleId && currentCycleId !== cycleId) {
            debugLog("snapshot ignored for foreign cycle while active", { cycleId, currentCycleId, uiPhase })
            return
          }

          if (!currentCycleId && cycleId) {
            currentCycleId = cycleId
          }

          pendingTranscript = transcript
          pendingResponse = response
          speakingLock = lock
          setVisualFinal()
          body.classList.remove("scene-fade")

          if (phase === "hold") {
            uiPhase = "hold"
            setBodyState("cooldown")
            if (transcript) showTranscript(transcript)
            if (response) setResponseInstant(response)
            holdTimer = cancelTimer(holdTimer)
            holdEndsAtMs = Date.now() + holdRemaining * 1000
            holdTimer = setTimeout(() => {
              startFadeToIdle()
            }, holdRemaining * 1000)
            updateDebug()
            return
          }

          uiPhase = "active"
          setBodyState(lock ? "speaking" : "thinking")
          holdEndsAtMs = 0
          holdTimer = cancelTimer(holdTimer)
          if (transcript) showTranscript(transcript)
          if (response) setResponseInstant(response)
          updateDebug()
        }

        const hydrateFromServerState = async () => {
          try {
            const response = await fetch("/assistant/ws/state", { cache: "no-store" })
            if (!response.ok) {
              debugLog("snapshot fetch failed", { status: response.status })
              return
            }
            const snapshot = await response.json()
            applyServerSnapshot(snapshot)
          } catch (error) {
            debugLog("snapshot fetch error", error)
          }
        }

        const updateDebug = () => {
          if (!debugEnabled || !debugEl) return
          const elapsed = Math.max(0, Date.now() - lastEventAt)
          const payloadText = lastEventPayload ? JSON.stringify(lastEventPayload) : "none"
          const holdRemaining = holdRemainingSeconds()
          debugEl.textContent =
            `ws_connected=${wsConnected}\n` +
            `uiPhase=${uiPhase}\n` +
            `cycle_id=${currentCycleId || "none"}\n` +
            `speaking_lock=${speakingLock}\n` +
            `assistantState=${assistantState}\n` +
            `voice_status=${voiceStatus.state}/${voiceStatus.backend}/${voiceStatus.capture_backend} worker=${voiceStatus.worker_active} mic=${voiceStatus.microphone_available} device=${voiceStatus.input_device_name} sr=${voiceStatus.input_sample_rate} lvl=${Number(voiceStatus.input_level_norm || 0).toFixed(2)}\n` +
            `holdDefault=${holdDefault.toFixed(1)}s\n` +
            `hold_remaining=${holdRemaining.toFixed(2)}s\n` +
            `event_seq=${lastAppliedEventSeq}\n` +
            `lastEvent=${lastEventType} state=${lastEventState || "-"} cycle=${lastEventCycleId || "none"} (${elapsed}ms ago)\n` +
            `transcript_len=${transcriptEl.textContent.length}\n` +
            `response_len=${responseEl.textContent.length}\n` +
            `payload=${payloadText}`
        }

        const setPromptSubmitting = (submitting) => {
          submittingPrompt = Boolean(submitting)
          syncInteractionControls()
          applyVoiceStatusUi()
        }

        const syncTextInputHeight = () => {
          textInput.style.height = "0px"
          const targetHeight = Math.max(42, Math.min(textInput.scrollHeight, 132))
          textInput.style.height = `${targetHeight}px`
        }

        const submitTypedPrompt = async (rawMessage) => {
          const message = String(rawMessage || "").trim()
          if (!message || submittingPrompt || !canSubmitPrompt()) return
          textQueryVisualState = uiMode === "assistant" && uiPhase === "idle" && !speakingLock
          if (textQueryVisualState) {
            setVisualFinal()
            setBodyState("thinking")
          }
          setPromptSubmitting(true)
          try {
            const response = await fetch("/assistant/query", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ message }),
            })
            const payload = await response.json().catch(() => ({}))
            if (!response.ok) {
              throw new Error(String(payload.detail || tt("prompt_failed")))
            }
            const reply = String(payload.response || "").trim()
            if (!reply) {
              throw new Error(tt("prompt_failed"))
            }
            textInput.value = ""
            syncTextInputHeight()
            pendingTranscript = message
            showTranscript(message)
            setResponseInstant(reply)
            if (noticeKind === "prompt") {
              clearNotice("prompt")
            }
          } catch (error) {
            setNotice(String((error && error.message) || tt("prompt_failed")), { kind: "prompt" })
          } finally {
            if (textQueryVisualState) {
              textQueryVisualState = false
              if (uiPhase === "idle" && !currentCycleId && !speakingLock) {
                setVisualInitial()
                setBodyState("idle")
              }
            }
            setPromptSubmitting(false)
            updateDebug()
          }
        }

        const sendWsCommand = (command) => {
          if (!wsSocket || wsSocket.readyState !== WebSocket.OPEN) {
            setNotice(tt("voice_connection_unavailable"), { kind: "connection" })
            return false
          }
          wsSocket.send(JSON.stringify({ type: "command", command }))
          return true
        }

        const toggleMicListening = () => {
          if (isInteractionLocked()) return
          if (pttCommandInFlight) return
          if (!voiceStatus.worker_active) {
            setNotice(tt("runtime_not_running"), { kind: "runtime" })
            return
          }
          if (!voiceStatus.microphone_available) {
            setNotice(String(voiceStatus.microphone_error || tt("mic_unavailable")), { retry: true, kind: "mic" })
            return
          }
          const command = voiceStatus.state === "listening" ? "ptt_stop" : "ptt_start"
          if (sendWsCommand(command)) {
            pttCommandInFlight = command
            pttCommandTimer = cancelTimer(pttCommandTimer)
            pttCommandTimer = setTimeout(() => {
              clearPttCommandInFlight()
              applyVoiceStatusUi()
            }, 1500)
            clearNotice("command")
            clearNotice("connection")
            applyVoiceStatusUi()
          }
        }

        const retryMicrophone = async () => {
          try {
            if (!sendWsCommand("mic_retry")) {
              await fetch("/assistant/ui/microphone/retry", { method: "POST" })
            }
            setNotice(`${tt("mic_retry")}...`, { kind: "mic" })
            setTimeout(() => {
              void fetchVoiceStatus()
            }, 750)
          } catch (error) {
            setNotice(String((error && error.message) || tt("mic_unavailable")), { retry: true, kind: "mic" })
          }
        }

        const connect = () => {
          const proto = window.location.protocol === "https:" ? "wss" : "ws"
          const socket = new WebSocket(`${proto}://${window.location.host}/assistant/ws`)
          wsSocket = socket

          socket.addEventListener("open", () => {
            wsConnected = true
            debugLog("ws open")
            postStartupPhase("assistant_ui_ws_connected", `uiPhase=${uiPhase}`)
            Promise.all([hydrateFromServerState(), fetchVoiceStatus()]).finally(updateDebug)
            wsPingTimer = cancelTimer(wsPingTimer)
            voiceStatusPollTimer = cancelTimer(voiceStatusPollTimer)
            wsPingTimer = setInterval(() => {
              if (socket.readyState === WebSocket.OPEN) {
                socket.send("ping")
              }
            }, 12000)
            voiceStatusPollTimer = setInterval(() => {
              void fetchVoiceStatus()
            }, 6000)
            updateDebug()
          })

          socket.addEventListener("message", (event) => {
            try {
              const payload = JSON.parse(event.data)
              debugLog("t_ws_event_received", {
                ts_ms: Date.now(),
                type: String(payload.type || "unknown"),
                state: String(payload.state || ""),
                cycle_id: normalizeCycleId(payload),
                event_seq: Number(payload.event_seq || 0),
              })
              applyEvent(payload)
            } catch (error) {
              debugLog("invalid ws payload", error)
            }
          })

          socket.addEventListener("close", () => {
            wsConnected = false
            wsSocket = null
            postStartupPhase("assistant_ui_ws_disconnected", `uiPhase=${uiPhase}`)
            clearPttCommandInFlight()
            clearStopRequestInFlight()
            debugLog("ws close")
            if (wsPingTimer) {
              clearInterval(wsPingTimer)
              wsPingTimer = null
            }
            if (voiceStatusPollTimer) {
              clearInterval(voiceStatusPollTimer)
              voiceStatusPollTimer = null
            }
            updateDebug()
            setTimeout(connect, 1300)
          })

          socket.addEventListener("error", () => {
            debugLog("ws error")
            wsConnected = false
            wsSocket = null
            clearStopRequestInFlight()
            updateDebug()
            socket.close()
          })
        }

        if (debugEnabled) {
          debugEl.hidden = false
          debugTicker = setInterval(updateDebug, 250)
        }
        inputForm.addEventListener("submit", (event) => {
          event.preventDefault()
          if (shouldShowStopButton()) {
            void requestStopResponse()
            return
          }
          if (!canSubmitPrompt()) return
          void submitTypedPrompt(textInput.value)
        })
        textInput.addEventListener("input", () => {
          syncTextInputHeight()
        })
        textInput.addEventListener("keydown", (event) => {
          if (event.key === "Enter" && !event.shiftKey) {
            event.preventDefault()
            if (shouldShowStopButton()) {
              void requestStopResponse()
              return
            }
            if (!canSubmitPrompt()) return
            void submitTypedPrompt(textInput.value)
          }
        })
        window.addEventListener("keydown", (event) => {
          if (event.key !== "Escape") return
          if (!canStopResponse()) return
          event.preventDefault()
          void requestStopResponse()
        })
        micButton.addEventListener("click", () => {
          toggleMicListening()
        })
        micRetryButton.addEventListener("click", () => {
          void retryMicrophone()
        })
        settingsButton.addEventListener("click", (event) => {
          if (isSettingsLocked()) {
            event.preventDefault()
            event.stopPropagation()
            return
          }
          uiMode = "config"
          syncSettingsButton()
          postStartupPhase("assistant_ui_hide", "to_config")
          void setServerUiMode("config")
          navigateWithFade("/config?from=assistant")
        })
        startupFailsafeTimer = setTimeout(() => {
          forceRevealStartup("failsafe")
        }, STARTUP_FAILSAFE_MS)
        window.addEventListener("error", () => {
          forceRevealStartup("error")
        })
        window.addEventListener("unhandledrejection", () => {
          forceRevealStartup("unhandledrejection")
        })
        postStartupPhase("assistant_ui_dom_ready")
        revealStartup()
        enterIdle()
        syncTextInputHeight()
        applyVoiceStatusUi()
        syncSettingsButton()
        syncInteractionControls()
        void setServerUiMode("assistant")
        void fetchVoiceStatus()
        updateDebug()
        postStartupPhase("assistant_ui_ready", `phase=${uiPhase}`)
        connect()
      })()
    </script>
  </body>
</html>
