/**
 * Mac Island Phase 1 — web widget served at GET /island.
 *
 * Designed to be opened in a tiny always-on-top browser window (Arc small
 * window, Vivaldi PWA, Safari split). Subscribes to /events for live mood +
 * idle pulses, polls /api/island/ping for richer state (current desire,
 * unread flag). See docs/MAC_ISLAND_PLAN.md.
 *
 * No build step, no framework — single string of inline HTML/CSS/JS.
 */
export declare const ISLAND_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>Lisa \u00B7 island</title>\n<link rel=\"manifest\" href=\"/manifest.webmanifest\">\n<style>\n  :root {\n    color-scheme: dark;\n    --bg: rgba(8, 12, 24, 0.92);\n    --bg-strong: rgba(8, 12, 24, 0.96);\n    --fg: #e4e4e6;\n    --fg-dim: #9ba3b8;\n    --fg-faint: #6b7280;\n    --accent: #6ad4ff;\n    --accent-warm: #ffd066;\n    --accent-dream: #b487ff;\n    --accent-claude: #ff8c42;\n    --border: rgba(255, 255, 255, 0.06);\n  }\n  html, body {\n    margin: 0;\n    padding: 0;\n    background: transparent;\n    overflow: hidden;\n    height: 100vh;\n    font-family: -apple-system, BlinkMacSystemFont, \"SF Pro Text\", system-ui, sans-serif;\n    -webkit-font-smoothing: antialiased;\n    color: var(--fg);\n    user-select: none;\n    cursor: default;\n  }\n  body {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    padding: 4px 8px;\n  }\n\n  #pill {\n    display: inline-flex;\n    align-items: center;\n    gap: 8px;\n    background: var(--bg);\n    border: 1px solid var(--border);\n    border-radius: 22px;\n    padding: 5px 14px 5px 5px;\n    backdrop-filter: blur(20px);\n    -webkit-backdrop-filter: blur(20px);\n    box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);\n    cursor: pointer;\n    transition: transform 200ms ease, opacity 200ms ease;\n    max-width: 280px;\n  }\n  #pill:hover { transform: translateY(1px); }\n\n  /* Avatar is an <img> not a background-image \u2014 more reliable in\n     WKWebView and lets us crop into the face via object-position.\n     The 512\u00D7512 source has ~15% transparent padding around the\n     character; we scale up via object-fit + anchor toward the top\n     so the face dominates the small circle.\n     pointer-events: none + draggable=false so the img never steals\n     or hijacks mouse events from the pill (HTML <img> default is\n     draggable, which interferes with our Swift-side click/drag\n     resolution). */\n  #avatar {\n    width: 36px;\n    height: 36px;\n    border-radius: 50%;\n    object-fit: cover;\n    object-position: 50% 22%;\n    background: #15192a;\n    flex-shrink: 0;\n    image-rendering: pixelated;\n    border: 1px solid rgba(255, 255, 255, 0.10);\n    box-shadow: 0 0 0 2px rgba(106, 212, 255, 0.10);\n    pointer-events: none;\n    -webkit-user-drag: none;\n    user-select: none;\n  }\n\n  #label {\n    font-size: 13px;\n    font-weight: 600;\n    letter-spacing: 0.02em;\n    color: var(--fg);\n  }\n\n  #dot {\n    width: 7px;\n    height: 7px;\n    border-radius: 50%;\n    background: transparent;\n    flex-shrink: 0;\n  }\n  #dot.thinking       { background: var(--accent);         animation: pulse 1.2s ease-in-out infinite; }\n  #dot.dreaming       { background: var(--accent-dream);   animation: pulse 2.4s ease-in-out infinite; }\n  #dot.unread         { background: var(--accent-warm); }\n  /* Phase 2: pill dot reflects the strongest signal across all\n     Claude sessions. */\n  #dot.claude-working { background: var(--accent-claude);  animation: pulse 1.8s ease-in-out infinite; }\n  #dot.claude-waiting { background: var(--accent-claude); }  /* solid \u2014 \"needs you\" */\n  #dot.claude-error   { background: #ff5577;               animation: pulse 0.8s ease-in-out infinite; }\n  #dot.offline        { background: var(--fg-faint); }\n\n  @keyframes pulse {\n    0%, 100% { opacity: 0.35; }\n    50%      { opacity: 1; }\n  }\n\n  /* Expanded panel \u2014 appears below the pill on hover/click.\n     The native LisaIsland.app window is a fixed 360\u00D7440 pt; the pill\n     takes the top ~58pt (height + 8pt margin around). The expand\n     panel fills the rest. When content (long \u2605 reflection + many\n     active Claude sessions + their state trails when row-open)\n     exceeds that, the panel scrolls internally rather than letting\n     anything clip out of the window. */\n  #expand {\n    margin-top: 10px;\n    width: 336px;\n    max-height: calc(100vh - 70px);\n    overflow-y: auto;\n    overscroll-behavior: contain;\n    background: var(--bg-strong);\n    border: 1px solid var(--border);\n    border-radius: 18px;\n    padding: 16px 18px;\n    backdrop-filter: blur(20px);\n    -webkit-backdrop-filter: blur(20px);\n    box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5);\n    font-size: 12.5px;\n    line-height: 1.55;\n    box-sizing: border-box;\n    opacity: 0;\n    transform: translateY(-4px);\n    pointer-events: none;\n    transition: opacity 200ms ease, transform 200ms ease;\n  }\n  body.expanded #expand {\n    opacity: 1;\n    transform: none;\n    pointer-events: auto;\n  }\n  /* Subtle scrollbar \u2014 visible only while scrolling/hovering. The\n     default WKWebView scrollbar is too chunky for a 336px panel. */\n  #expand::-webkit-scrollbar { width: 6px; }\n  #expand::-webkit-scrollbar-track { background: transparent; }\n  #expand::-webkit-scrollbar-thumb {\n    background: rgba(255, 255, 255, 0.10);\n    border-radius: 3px;\n  }\n  #expand::-webkit-scrollbar-thumb:hover {\n    background: rgba(255, 255, 255, 0.20);\n  }\n\n  /* Stack section blocks with consistent vertical rhythm. */\n  #expand > div + div { margin-top: 14px; }\n\n  .section-label {\n    color: var(--accent);\n    font-weight: 600;\n    text-transform: uppercase;\n    letter-spacing: 0.10em;\n    font-size: 10.5px;\n    margin-bottom: 8px;\n  }\n  .section-body {\n    color: var(--fg-dim);\n    word-wrap: break-word;\n  }\n\n  #idle-section { display: none; }\n  body.has-unread #idle-section { display: block; }\n  #idle-body {\n    background: rgba(255, 208, 102, 0.07);\n    border-left: 2px solid var(--accent-warm);\n    padding: 8px 12px;\n    border-radius: 6px;\n    color: var(--fg);\n    /* No inner max-height \u2014 the outer #expand panel scrolls if total\n       content overflows the window. One scrollbar, not nested. */\n    white-space: pre-wrap;\n  }\n\n  /* Claude Code section \u2014 appears when there's active Claude Code activity */\n  #claude-section { display: none; }\n  body.has-claude #claude-section { display: block; }\n  #claude-section .section-label { color: var(--accent-claude); }\n  #claude-list {\n    list-style: none;\n    padding: 4px 0;\n    margin: 0;\n    border-left: 2px solid var(--accent-claude);\n    background: rgba(255, 140, 66, 0.06);\n    border-radius: 6px;\n    /* No inner overflow either \u2014 outer #expand scrolls. Avoids the\n       nested-scrollbar UX where the user scrolls inside this card by\n       accident and can't reach the action buttons below. */\n  }\n  #claude-list li {\n    padding: 8px 12px;\n    color: var(--fg);\n    font-size: 11.5px;\n    display: flex;\n    flex-direction: column;\n    gap: 4px;\n    cursor: pointer;\n    transition: background 120ms ease;\n  }\n  #claude-list li:hover { background: rgba(255, 140, 66, 0.10); }\n  #claude-list li + li { border-top: 1px solid rgba(255, 140, 66, 0.10); }\n  /* Row \"head\" \u2014 the pip + project + relative-time line. Always rendered.\n     Stays as a horizontal flex strip even when the row is expanded; the\n     trail + actions render BELOW it because the parent <li> is flex-column. */\n  #claude-list .head {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n  }\n  #claude-list .proj { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n  #claude-list .when { color: var(--fg-dim); flex-shrink: 0; font-variant-numeric: tabular-nums; font-size: 11px; }\n  #claude-list .empty { padding: 8px 12px; color: var(--fg-faint); font-style: italic; }\n  /* O2 \u2014 Tier-2 activity line: what the session is structurally doing. */\n  #claude-list .act {\n    margin: 2px 0 0 18px;\n    font-size: 10.5px;\n    color: var(--fg-dim);\n    font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n\n  /* Phase 2 \u2014 per-session state pip prefix */\n  #claude-list .pip {\n    width: 7px;\n    height: 7px;\n    border-radius: 50%;\n    flex-shrink: 0;\n    background: var(--fg-faint);\n    margin-right: 4px;\n  }\n  #claude-list .pip.working { background: var(--accent-claude); animation: pulse 1.8s ease-in-out infinite; }\n  #claude-list .pip.waiting { background: var(--accent-claude); }\n  #claude-list .pip.error   { background: #ff5577; }\n  #claude-list .pip.unknown { background: var(--fg-faint); }\n\n  /* Phase 3 \u2014 state transition trail, shown below the row head when\n     the parent <li> has the .row-open class. */\n  #claude-list .trail {\n    display: none;\n    margin: 4px 0 0 14px;\n    padding: 6px 0 2px;\n    border-top: 1px dashed rgba(255, 140, 66, 0.18);\n    font-size: 10px;\n    color: var(--fg-faint);\n    line-height: 1.7;\n    word-spacing: 0.05em;\n  }\n  #claude-list li.row-open .trail { display: block; }\n\n  /* Phase 3.5 \u2014 inline action row when the session is expanded */\n  #claude-list .actions {\n    display: none;\n    margin: 6px 0 0 14px;\n    gap: 6px;\n    flex-wrap: wrap;\n  }\n  #claude-list li.row-open .actions { display: flex; }\n  #claude-list .actions button {\n    flex: 0 0 auto;\n    background: rgba(255, 140, 66, 0.10);\n    border: 1px solid rgba(255, 140, 66, 0.22);\n    color: var(--fg);\n    padding: 4px 8px;\n    border-radius: 6px;\n    font-size: 10.5px;\n    cursor: pointer;\n    font-family: inherit;\n  }\n  #claude-list .actions button:hover { background: rgba(255, 140, 66, 0.16); }\n  #claude-list .actions button:disabled { opacity: 0.35; cursor: not-allowed; }\n  #claude-list .trail .tdot {\n    display: inline-block;\n    width: 5px;\n    height: 5px;\n    border-radius: 50%;\n    margin-right: 4px;\n    vertical-align: 0;\n    background: var(--fg-faint);\n  }\n  #claude-list .trail .tdot.working { background: var(--accent-claude); }\n  #claude-list .trail .tdot.waiting { background: var(--accent-claude); opacity: 0.7; }\n  #claude-list .trail .tdot.error   { background: #ff5577; }\n  /* (older stub removed \u2014 .head is a real flex strip now, see above) */\n\n  /* Phase 3 \u2014 notification opt-in chip */\n  #notify-cta {\n    display: none;\n    margin-top: 10px;\n    padding: 8px 12px;\n    border-radius: 10px;\n    background: rgba(255, 140, 66, 0.12);\n    border: 1px solid rgba(255, 140, 66, 0.30);\n    font-size: 11px;\n    color: var(--fg);\n    text-align: center;\n    cursor: pointer;\n    transition: background 120ms ease;\n  }\n  #notify-cta:hover { background: rgba(255, 140, 66, 0.18); }\n  body.notify-default #notify-cta { display: block; }\n\n  #actions {\n    display: flex;\n    gap: 8px;\n  }\n  button {\n    flex: 1;\n    background: rgba(255, 255, 255, 0.05);\n    border: 1px solid var(--border);\n    color: var(--fg);\n    padding: 8px 12px;\n    border-radius: 10px;\n    font-size: 11.5px;\n    cursor: pointer;\n    font-family: inherit;\n    transition: background 150ms ease;\n  }\n  button:hover  { background: rgba(255, 255, 255, 0.10); }\n  button:active { background: rgba(255, 255, 255, 0.15); }\n  button.muted  { opacity: 0.5; }\n\n  /* Offline state \u2014 desaturate + dim */\n  body.offline #avatar { filter: grayscale(1); opacity: 0.5; }\n  body.offline #label  { color: var(--fg-faint); }\n</style>\n</head>\n<body>\n  <div id=\"pill\" role=\"button\" tabindex=\"0\" aria-label=\"Lisa island\">\n    <img id=\"avatar\" alt=\"\" draggable=\"false\" src=\"/assets/lisa/neutral.png\" />\n    <div id=\"label\">Lisa</div>\n    <div id=\"dot\" aria-hidden=\"true\"></div>\n  </div>\n  <div id=\"expand\" role=\"region\" aria-label=\"Lisa status detail\">\n    <div id=\"desire-section\">\n      <div class=\"section-label\">currently wanting</div>\n      <div class=\"section-body\" id=\"desire-body\">\u2014</div>\n    </div>\n    <div id=\"idle-section\">\n      <div class=\"section-label\">\u2605 while you were away</div>\n      <div id=\"idle-body\"></div>\n    </div>\n    <div id=\"claude-section\">\n      <div class=\"section-label\">claude code \u00B7 <span id=\"claude-count\">0</span> active</div>\n      <ul id=\"claude-list\"></ul>\n      <div id=\"notify-cta\" role=\"button\" tabindex=\"0\">\uD83D\uDD14 Notify me when Claude is waiting</div>\n    </div>\n    <div id=\"actions\">\n      <button id=\"btn-open\">Open chat</button>\n      <button id=\"btn-dismiss\" class=\"muted\">Dismiss \u2605</button>\n    </div>\n  </div>\n\n<script>\n(() => {\n  const pill         = document.getElementById('pill');\n  const avatar       = document.getElementById('avatar');\n  const dot          = document.getElementById('dot');\n  const expand       = document.getElementById('expand');\n  const desireBody   = document.getElementById('desire-body');\n  const idleBody     = document.getElementById('idle-body');\n  const claudeList   = document.getElementById('claude-list');\n  const claudeCount  = document.getElementById('claude-count');\n  const notifyCta    = document.getElementById('notify-cta');\n  const btnOpen      = document.getElementById('btn-open');\n  const btnDismiss   = document.getElementById('btn-dismiss');\n  const body         = document.body;\n\n  const state = {\n    mood: 'neutral',\n    online: false,\n    unread: false,\n    idleText: '',\n    desire: null,\n    thinking: false,\n    dreaming: false,\n    claudeSessions: [],  // [{project, sessionId, lastMtime}, \u2026]\n  };\n\n  // 30-min activity window matches the watcher's ACTIVE_WINDOW_MS.\n  const CLAUDE_ACTIVE_WINDOW_MS = 30 * 60 * 1000;\n\n  // Phase 3 \u2014 in-memory state transition history per session.\n  // Map<sessionId, [{state, reason, ts}, \u2026]> capped at MAX_HISTORY.\n  // Pure UI memory; not persisted, not transmitted.\n  const stateHistory = new Map();\n  const MAX_HISTORY = 8;\n  // Per-session opened-state for the inline trail in the expand list.\n  const rowOpen = new Set();\n\n  function recordStateHistory(sessionId, info) {\n    let h = stateHistory.get(sessionId);\n    if (!h) { h = []; stateHistory.set(sessionId, h); }\n    const last = h[h.length - 1];\n    if (last && last.state === info.state && last.reason === info.reason) {\n      // Same state, just refresh timestamp \u2014 collapse repeats.\n      last.ts = info.lastMtime;\n      return false;\n    }\n    h.push({ state: info.state, reason: info.reason, ts: info.lastMtime });\n    while (h.length > MAX_HISTORY) h.shift();\n    return true; // transition happened\n  }\n\n  // \u2500\u2500 Phase 3 \u2014 notifications (native via LisaIsland.app, or web fallback)\n  // Fires \"Claude is waiting in <project>\" alerts when a session\n  // transitions INTO waiting. Throttled per session so a flaky tool\n  // that bounces between waiting/working doesn't spam.\n  //\n  // Phase 3.5: when running inside LisaIsland.app, we delegate to the\n  // native UNUserNotificationCenter via postMessage \u2014 better permission\n  // flow, integrates with macOS Focus / DnD. In a plain browser tab we\n  // fall back to the Notification API.\n  const NOTIFY_THROTTLE_MS = 60_000;\n  const lastNotifyAt = new Map();\n  const hasBridge = !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.island);\n\n  function notifPermission() {\n    if (hasBridge) return 'granted'; // native side asks the user; we just request and trust\n    return ('Notification' in window) ? Notification.permission : 'unsupported';\n  }\n\n  function refreshNotifyCta() {\n    // The \uD83D\uDD14 chip is only needed when running in a browser AND\n    // permission hasn't been answered yet. Native bridge handles its\n    // own permission prompt.\n    if (hasBridge) {\n      body.classList.remove('notify-default');\n      return;\n    }\n    body.classList.toggle('notify-default', notifPermission() === 'default');\n  }\n\n  function requestNotificationPermission() {\n    if (hasBridge) {\n      window.webkit.messageHandlers.island.postMessage({ type: 'ensure_notify_permission' });\n      refreshNotifyCta();\n      return;\n    }\n    if (!('Notification' in window) || Notification.permission !== 'default') {\n      refreshNotifyCta();\n      return;\n    }\n    Notification.requestPermission().then(() => refreshNotifyCta()).catch(() => {});\n  }\n\n  function maybeNotifyWaiting(prevState, info) {\n    if (info.state !== 'waiting') return;\n    if (prevState === 'waiting') return;     // already in this state\n    const last = lastNotifyAt.get(info.sessionId) || 0;\n    if (Date.now() - last < NOTIFY_THROTTLE_MS) return;\n    lastNotifyAt.set(info.sessionId, Date.now());\n    const reasonLabel = info.stateReason === 'permission' ? 'needs permission' : 'is waiting';\n    const title = 'Claude ' + reasonLabel + ' in ' + info.project;\n    const bodyText = info.sessionId.slice(0, 8) + ' \u00B7 click to open Lisa';\n    if (hasBridge) {\n      window.webkit.messageHandlers.island.postMessage({\n        type: 'notify',\n        title: title,\n        body: bodyText,\n        sessionId: info.sessionId,\n      });\n      return;\n    }\n    if (notifPermission() !== 'granted') return;\n    try {\n      const n = new Notification(title, {\n        body: bodyText,\n        tag: 'lisa-claude-' + info.sessionId,\n        icon: '/assets/lisa-mascot.png',\n        silent: false,\n      });\n      n.onclick = () => { window.focus(); window.open('/', '_blank'); n.close(); };\n    } catch (_) { /* unsupported, ignore */ }\n  }\n  function recentSessions() {\n    const cutoff = Date.now() - CLAUDE_ACTIVE_WINDOW_MS;\n    return state.claudeSessions.filter(\n      (s) => new Date(s.lastMtime).getTime() >= cutoff\n    );\n  }\n  /**\n   * Phase 2: aggregate Claude state for the pill dot. Priority is\n   * \"loudest signal wins\": an error anywhere dominates everything;\n   * otherwise a \"waiting\" session beats a \"working\" session (because\n   * \"waiting\" means Claude needs the user \u2014 more attention-worthy\n   * than \"working\" which is passive observing).\n   */\n  function aggregateClaudeState() {\n    const recent = recentSessions();\n    if (recent.length === 0) return null;\n    if (recent.some((s) => s.state === 'error'))   return 'error';\n    if (recent.some((s) => s.state === 'waiting')) return 'waiting';\n    if (recent.some((s) => s.state === 'working')) return 'working';\n    return null;\n  }\n\n  function setAvatar(slug) {\n    if (!slug) return;\n    state.mood = slug;\n    // Use the <img> src attribute \u2014 far more reliable than CSS\n    // background-image in WKWebView, and lets the browser's standard\n    // image cache + retry logic do its thing.\n    avatar.src = '/assets/lisa/' + encodeURIComponent(slug) + '.png';\n  }\n\n  function refreshDot() {\n    dot.className = '';\n    // Priority: LISA's own state (offline / thinking / dreaming / unread)\n    // always wins over the Claude-Code-monitor indicator \u2014 the pill is\n    // primarily about her, the Claude dot is a quieter \"by the way\".\n    if (!state.online)  { dot.classList.add('offline');  return; }\n    if (state.thinking) { dot.classList.add('thinking'); return; }\n    if (state.dreaming) { dot.classList.add('dreaming'); return; }\n    if (state.unread)   { dot.classList.add('unread');   return; }\n    const claude = aggregateClaudeState();\n    if (claude === 'error')   { dot.classList.add('claude-error');   return; }\n    if (claude === 'waiting') { dot.classList.add('claude-waiting'); return; }\n    if (claude === 'working') { dot.classList.add('claude-working'); return; }\n  }\n\n  function refreshPanel() {\n    body.classList.toggle('offline',   !state.online);\n    body.classList.toggle('has-unread', state.unread);\n    body.classList.toggle('has-claude', state.claudeSessions.length > 0);\n    desireBody.textContent = state.desire || '(nothing actively pursued)';\n    idleBody.textContent   = state.idleText || '';\n    btnDismiss.classList.toggle('muted', !state.unread);\n    btnDismiss.disabled = !state.unread;\n    renderClaudeList();\n  }\n\n  function relativeTime(iso) {\n    const ms = Date.now() - new Date(iso).getTime();\n    if (ms < 30_000)             return 'just now';\n    if (ms < 60_000)             return Math.round(ms / 1000) + 's ago';\n    if (ms < 3600_000)           return Math.round(ms / 60_000) + 'm ago';\n    return Math.round(ms / 3600_000) + 'h ago';\n  }\n\n  // O2 \u2014 compact one-line summary of a session's Tier-2 activity. Structural\n  // only (tool names, last command, basename of a touched file). Returns ''\n  // when there's no activity (e.g. visibility=metadata).\n  function basename(p) {\n    if (!p) return '';\n    const parts = String(p).split('/');\n    return parts[parts.length - 1] || p;\n  }\n  function formatActivity(s) {\n    const a = s.activity;\n    if (!a) return '';\n    if (a.pendingPermission) return '\u26A0 wants to run ' + a.pendingPermission;\n    const bits = [];\n    if (a.lastError) bits.push('\u2717 ' + a.lastError);\n    if (a.lastCommandName) bits.push('$ ' + a.lastCommandName);\n    const tool = a.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : '';\n    const file = a.filesTouched && a.filesTouched.length ? basename(a.filesTouched[a.filesTouched.length - 1]) : '';\n    if (tool && file) bits.push(tool + ' ' + file);\n    else if (tool) bits.push(tool);\n    else if (file) bits.push(file);\n    return bits.join(' \u00B7 ');\n  }\n\n  function renderClaudeList() {\n    const recent = recentSessions();\n    claudeCount.textContent = String(recent.length);\n    while (claudeList.firstChild) claudeList.removeChild(claudeList.firstChild);\n    if (recent.length === 0) {\n      const li = document.createElement('li');\n      li.className = 'empty';\n      li.textContent = '(idle)';\n      claudeList.appendChild(li);\n      return;\n    }\n    // Sort: errors first, then waiting, then working, then by mtime.\n    const stateRank = { error: 0, waiting: 1, working: 2, unknown: 3 };\n    const rows = recent.slice().sort((a, b) => {\n      const ra = stateRank[a.state] ?? 9;\n      const rb = stateRank[b.state] ?? 9;\n      if (ra !== rb) return ra - rb;\n      return new Date(b.lastMtime).getTime() - new Date(a.lastMtime).getTime();\n    }).slice(0, 5);\n    for (const s of rows) {\n      const li = document.createElement('li');\n      if (rowOpen.has(s.sessionId)) li.classList.add('row-open');\n      // pip + project + relative-time render as a single horizontal\n      // .head strip. The trail + actions render BELOW the head when\n      // the row is open (li is flex-column).\n      const head = document.createElement('div');\n      head.className = 'head';\n      const pip = document.createElement('span');\n      pip.className = 'pip ' + (s.state || 'unknown');\n      const proj = document.createElement('span');\n      proj.className = 'proj';\n      proj.textContent = s.project;\n      const when = document.createElement('span');\n      when.className = 'when';\n      when.textContent = relativeTime(s.lastMtime);\n      head.appendChild(pip);\n      head.appendChild(proj);\n      head.appendChild(when);\n      li.appendChild(head);\n\n      // O2 (Tier 2) \u2014 one-line structural activity under the row head.\n      // \"what it's doing\" without any conversation content.\n      const actLine = formatActivity(s);\n      if (actLine) {\n        const act = document.createElement('div');\n        act.className = 'act';\n        act.textContent = actLine;\n        li.appendChild(act);\n      }\n\n      // Phase 3 \u2014 collapsible state-transition trail\n      const trail = document.createElement('div');\n      trail.className = 'trail';\n      renderTrail(trail, s);\n      li.appendChild(trail);\n\n      // Phase 3.5 \u2014 action buttons (Open in Finder / Copy resume)\n      const actions = document.createElement('div');\n      actions.className = 'actions';\n      renderActions(actions, s);\n      li.appendChild(actions);\n\n      li.title = s.state + (s.stateReason ? ' (' + s.stateReason + ')' : '')\n               + ' \u00B7 ' + s.sessionId\n               + '\\nclick: expand timeline \u00B7 double-click: copy sessionId';\n      li.addEventListener('click', () => {\n        if (rowOpen.has(s.sessionId)) rowOpen.delete(s.sessionId);\n        else rowOpen.add(s.sessionId);\n        renderClaudeList();\n      });\n      li.addEventListener('dblclick', async (ev) => {\n        ev.stopPropagation();\n        try { await navigator.clipboard.writeText(s.sessionId); } catch (_) {}\n      });\n      claudeList.appendChild(li);\n    }\n  }\n\n  /**\n   * Phase 3.5 \u2014 render the inline action buttons for one Claude session.\n   * Each session has a cwd (from .cwd top-level field in the jsonl)\n   * and a sessionId. We expose two actions:\n   *   - Open in Finder  \u2014 opens the cwd folder\n   *   - Copy resume cmd \u2014 clipboard: cd \"<cwd>\" && claude --resume <sid>\n   */\n  function renderActions(container, s) {\n    const cwd = s.cwd || '';\n    const hasCwd = cwd.startsWith('/');\n\n    const openBtn = document.createElement('button');\n    openBtn.type = 'button';\n    openBtn.textContent = '\uD83D\uDCC1 Open folder';\n    openBtn.disabled = !hasCwd;\n    openBtn.title = hasCwd ? cwd : 'No cwd recorded in this session';\n    openBtn.addEventListener('click', (e) => {\n      e.stopPropagation();\n      if (!hasCwd) return;\n      if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.island) {\n        window.webkit.messageHandlers.island.postMessage({\n          type: 'open_path',\n          path: cwd,\n        });\n      } else {\n        // Browser fallback: copy the path so the user can paste in Finder.\n        navigator.clipboard.writeText(cwd).catch(() => {});\n      }\n    });\n    container.appendChild(openBtn);\n\n    const copyBtn = document.createElement('button');\n    copyBtn.type = 'button';\n    copyBtn.textContent = '\uD83D\uDCCB Resume cmd';\n    copyBtn.disabled = !hasCwd;\n    const cmd = hasCwd\n      ? 'cd ' + JSON.stringify(cwd) + ' && claude --resume ' + s.sessionId\n      : 'claude --resume ' + s.sessionId;\n    copyBtn.title = cmd;\n    copyBtn.addEventListener('click', async (e) => {\n      e.stopPropagation();\n      try {\n        await navigator.clipboard.writeText(cmd);\n        const orig = copyBtn.textContent;\n        copyBtn.textContent = '\u2713 copied';\n        setTimeout(() => { copyBtn.textContent = orig; }, 1200);\n      } catch (_) {}\n    });\n    container.appendChild(copyBtn);\n  }\n\n  function renderTrail(container, s) {\n    const h = stateHistory.get(s.sessionId) || [];\n    if (h.length === 0) {\n      container.textContent = '(no transitions recorded yet)';\n      return;\n    }\n    // Render newest first so the right-most reads as \"right now\".\n    const ordered = h.slice();\n    for (let i = 0; i < ordered.length; i++) {\n      const entry = ordered[i];\n      const tdot = document.createElement('span');\n      tdot.className = 'tdot ' + (entry.state || 'unknown');\n      container.appendChild(tdot);\n      const span = document.createElement('span');\n      span.textContent = entry.state + ' \u00B7 ' + relativeTime(entry.ts);\n      container.appendChild(span);\n      if (i < ordered.length - 1) {\n        const sep = document.createElement('span');\n        sep.textContent = '  \u2192  ';\n        sep.style.color = 'rgba(255,255,255,0.15)';\n        container.appendChild(sep);\n      }\n    }\n  }\n\n  function expandPanel(open) {\n    if (body.classList.contains('expanded') === open) return;\n    body.classList.toggle('expanded', open);\n    // Tell the native container (LisaIsland.app, Phase 2.2+) so it can\n    // resize its NSWindow \u2014 the pill window is sized just for the pill\n    // when collapsed, and grows to host the expand panel on open.\n    // Falls back gracefully when running in a plain browser tab.\n    if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.island) {\n      window.webkit.messageHandlers.island.postMessage({\n        type: open ? 'expand' : 'collapse'\n      });\n    }\n  }\n\n  // \u2500\u2500 Interaction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  //\n  // When running inside LisaIsland.app (Phase 2.1+), the native window\n  // owns drag and click-vs-drag resolution: Swift's IslandWindow\n  // intercepts mouseDown in sendEvent and runs a synchronous\n  // mouseDragged loop. If movement > 4px \u2192 drag (setFrameOrigin each\n  // tick, no IPC roundtrip). If no movement \u2192 Swift synthesizes\n  // pill.click() here so this click handler still fires.\n  //\n  // In a plain browser tab there's no native container \u2014 the click\n  // handler runs normally. Hover-to-expand also works in both modes:\n  // when the native window is in \"passthrough\" state for the\n  // non-pill area, mouseenter still fires on the WKWebView once the\n  // cursor crosses INTO the pill region.\n\n  let hoverTimer = null;\n  pill.addEventListener('mouseenter', () => {\n    clearTimeout(hoverTimer);\n    hoverTimer = setTimeout(() => expandPanel(true), 250);\n  });\n  pill.addEventListener('mouseleave', () => {\n    clearTimeout(hoverTimer);\n    // Don't collapse if the mouse just moved into the expand panel.\n    setTimeout(() => {\n      if (!expand.matches(':hover')) expandPanel(false);\n    }, 200);\n  });\n  expand.addEventListener('mouseleave', () => expandPanel(false));\n\n  pill.addEventListener('click', (e) => {\n    e.preventDefault();\n    expandPanel(!body.classList.contains('expanded'));\n  });\n  pill.addEventListener('dblclick', (e) => {\n    e.preventDefault();\n    openFull();\n  });\n\n  function openFull() {\n    // If a Swift container is present (Phase 2), prefer to delegate.\n    if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.island) {\n      window.webkit.messageHandlers.island.postMessage({ type: 'open_full_gui' });\n    } else {\n      window.open('/', '_blank');\n    }\n  }\n  btnOpen.addEventListener('click', (e) => { e.stopPropagation(); openFull(); });\n  btnDismiss.addEventListener('click', async (e) => {\n    e.stopPropagation();\n    if (!state.unread) return;\n    try { await fetch('/api/island/dismiss-unread', { method: 'POST' }); } catch (_) {}\n    state.unread = false;\n    state.idleText = '';\n    refreshDot();\n    refreshPanel();\n  });\n\n  // \u2500\u2500 Server polling for richer state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  let backoff = 5_000;\n  async function pollPing() {\n    try {\n      const r = await fetch('/api/island/ping', { cache: 'no-store' });\n      if (!r.ok) throw new Error('not ok');\n      const j = await r.json();\n      state.online  = !!j.online;\n      state.unread  = !!j.has_unread_idle_message;\n      state.idleText = j.last_idle_message_text || '';\n      state.desire  = j.current_desire || null;\n      if (j.mood) setAvatar(j.mood);\n      backoff = 5_000;\n    } catch (_) {\n      state.online = false;\n    }\n    refreshDot();\n    refreshPanel();\n  }\n\n  // \u2500\u2500 SSE subscription for instant pulses \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  let es = null;\n  function subscribe() {\n    try { if (es) es.close(); } catch (_) {}\n    es = new EventSource('/events');\n    es.addEventListener('open', () => {\n      state.online = true;\n      refreshDot();\n      refreshPanel();\n    });\n    es.addEventListener('message', (ev) => {\n      let m;\n      try { m = JSON.parse(ev.data); } catch (_) { return; }\n      switch (m.type) {\n        case 'mood':\n          setAvatar(m.slug);\n          break;\n        case 'chat_start':\n          state.thinking = true;\n          refreshDot();\n          break;\n        case 'chat_end':\n          state.thinking = false;\n          refreshDot();\n          break;\n        case 'idle_start':\n          state.dreaming = true;\n          refreshDot();\n          break;\n        case 'idle_done':\n        case 'idle_error':\n          state.dreaming = false;\n          refreshDot();\n          break;\n        case 'idle_message':\n          state.dreaming = false;\n          state.unread = true;\n          state.idleText = (m.text || '').slice(0, 1000);\n          refreshDot();\n          refreshPanel();\n          // One subtle pulse so a watching user notices.\n          document.body.animate(\n            [{ opacity: 0.7 }, { opacity: 1 }, { opacity: 0.85 }],\n            { duration: 600, iterations: 2 },\n          );\n          break;\n        case 'claude_session_update':\n          upsertClaudeSession({\n            project: m.projectLabel,\n            projectEncoded: m.projectEncoded,\n            sessionId: m.sessionId,\n            lastMtime: m.ts,\n            state: m.state || 'unknown',\n            stateReason: m.stateReason || '',\n            cwd: m.cwd || '',\n          });\n          refreshDot();\n          refreshPanel();\n          break;\n      }\n    });\n    es.addEventListener('error', () => {\n      state.online = false;\n      refreshDot();\n      refreshPanel();\n      // Auto-retry: SSE EventSource reconnects on its own, but if the page\n      // server is down entirely we'll keep firing 'error' until it's back.\n    });\n  }\n\n  // \u2500\u2500 Claude Code session helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n  function upsertClaudeSession(s) {\n    const idx = state.claudeSessions.findIndex(\n      (x) => x.sessionId === s.sessionId\n    );\n    const prevState = idx >= 0 ? state.claudeSessions[idx].state : null;\n    if (idx >= 0) state.claudeSessions[idx] = s;\n    else state.claudeSessions.push(s);\n    pruneClaudeSessions();\n\n    // Phase 3 \u2014 record transition + maybe notify on entering \"waiting\".\n    const transitioned = recordStateHistory(s.sessionId, {\n      state: s.state,\n      reason: s.stateReason,\n      lastMtime: s.lastMtime,\n    });\n    if (transitioned) maybeNotifyWaiting(prevState, s);\n  }\n\n  function pruneClaudeSessions() {\n    const cutoff = Date.now() - CLAUDE_ACTIVE_WINDOW_MS;\n    state.claudeSessions = state.claudeSessions.filter(\n      (s) => new Date(s.lastMtime).getTime() >= cutoff\n    );\n  }\n\n  async function fetchClaudeSessions() {\n    try {\n      const r = await fetch('/api/claude/sessions', { cache: 'no-store' });\n      if (!r.ok) return;\n      const j = await r.json();\n      if (Array.isArray(j.sessions)) {\n        state.claudeSessions = j.sessions.map((s) => ({\n          project: s.project,\n          projectEncoded: s.projectEncoded,\n          sessionId: s.sessionId,\n          lastMtime: s.lastMtime,\n          state: s.state || 'unknown',\n          stateReason: s.stateReason || '',\n          cwd: s.cwd || '',\n        }));\n        // Phase 3 \u2014 seed each session's history with its current state\n        // so the trail isn't empty on first open. Doesn't notify (no\n        // transition implied by initial load).\n        for (const s of state.claudeSessions) {\n          recordStateHistory(s.sessionId, {\n            state: s.state,\n            reason: s.stateReason,\n            lastMtime: s.lastMtime,\n          });\n        }\n      }\n    } catch (_) {\n      // server might not yet have the endpoint (older LISA) \u2014 silent\n    }\n  }\n\n  // Phase 3 \u2014 notification permission opt-in. The CTA is only visible\n  // when permission is in the default (un-asked) state.\n  notifyCta.addEventListener('click', (e) => {\n    e.stopPropagation();\n    requestNotificationPermission();\n  });\n  notifyCta.addEventListener('keydown', (e) => {\n    if (e.key === 'Enter' || e.key === ' ') requestNotificationPermission();\n  });\n\n  // \u2500\u2500 Bootstrap \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  setAvatar('neutral');\n  pollPing();\n  fetchClaudeSessions().then(() => { refreshDot(); refreshPanel(); });\n  subscribe();\n  refreshNotifyCta();\n  // Lightweight resync \u2014 covers cases where SSE silently disconnected\n  // (laptop sleep, network blip) and we need to refresh state.\n  setInterval(pollPing, 30_000);\n  setInterval(fetchClaudeSessions, 60_000);\n  // Re-render every 15s so \"Xs ago\" / \"Xm ago\" labels stay fresh and\n  // stale sessions fall off the list without a fresh update event.\n  setInterval(() => {\n    pruneClaudeSessions();\n    refreshDot();\n    refreshPanel();\n  }, 15_000);\n})();\n</script>\n</body>\n</html>\n";
//# sourceMappingURL=island.d.ts.map