// Kayser v2 "Glass" — main app shell.
// Chat-first home with proactive Kayser pushes, ⌘ command palette,
// trust gauge, level-up moments, automation training.

// Local-time formatter for the bubble timestamp. The server's `t`
// field is pre-formatted in UTC (the backend doesn't know the user's
// timezone), so we ignore it and re-derive from `ts` (unix-ms) in
// the browser's local time. Matches the prototype's mock format
// ("8:42 AM"). Falls back to the server-provided `t` if `ts` is missing.
function fmtLocalTime(tsMs, fallback) {
  if (!tsMs) return fallback || '';
  try {
    return new Date(tsMs).toLocaleTimeString([], {
      hour: 'numeric', minute: '2-digit',
    });
  } catch (_) {
    return fallback || '';
  }
}

// API envelope ({ type, id, t, ts, thread, payload }) -> UI message
// shape the components in thread.jsx render. Returns null for envelope
// types the chat thread doesn't render (audit, trust, memory, system —
// those drive sidebars/banners, not chat bubbles).
//
// `thread` is carried over so the per-thread filter in
// kxv2FilterMessages can route messages to their correct view (live
// thread ids aren't in KXV2_THREADS and don't have tag arrays, so the
// old tag-substring filter falls back to []).
function toUiMessage(env) {
  const t = fmtLocalTime(env.ts, env.t);
  const thread = env.thread;
  const attachments = env.payload && Array.isArray(env.payload.attachments)
    ? env.payload.attachments : undefined;
  const toolCalls = env.payload && Array.isArray(env.payload.tool_calls)
    ? env.payload.tool_calls : undefined;
  if (env.type === 'kayser-text') {
    return {
      id: env.id, kind: 'kayser-text', t, thread,
      text: env.payload.text, replies: env.payload.replies,
      attachments, tool_calls: toolCalls,
    };
  }
  if (env.type === 'user-text') {
    return { id: env.id, kind: 'user-text', t, thread, text: env.payload.text, attachments };
  }
  if (env.type === 'kayser-card') {
    return {
      id: env.id, kind: 'kayser-card', t, thread,
      card: { type: env.payload.kind, ...env.payload },
    };
  }
  return null;
}

const KXV2_TWEAKS = /*EDITMODE-BEGIN*/{
  "trustPct": 58,
  "watching": 12,
  "handledToday": 7,
  "showLevelUp": false,
  "trustVoice": "calm",
  "wallpaperHue": "purple",
  "firstLaunch": false,
  "showOnboarding": false,
  "showMorningBrief": true,
  "offline": false,
  "pushDemo": false
}/*EDITMODE-END*/;

function KxV2App() {
  const [tweaks, setTweak] = useTweaks(KXV2_TWEAKS);

  // Active thread state — declared early because the message-fetch
  // useEffect below and the WebSocket handler both depend on it.
  // Previously this lived ~200 lines further down, but Babel-standalone
  // hoists `const` like `var`, so the dep arrays `[activeThread, ...]`
  // evaluated to `[undefined, ...]` on every render → React thought
  // deps never changed → the fetch never re-fired on thread switch
  // → user saw stale messages, which the per-thread filter then
  // stripped out → empty UI on every non-"now" thread.
  const [activeThread, setActiveThread] = React.useState('now');

  // Real chat history: load when the active thread changes. Falls back
  // to the mock seed if window.KX_TOKEN is absent (so the prototype
  // still works standalone from the file:// shell).
  const [messages, setMessages] = React.useState(
    window.KX_TOKEN ? [] : KXV2_SEED
  );
  React.useEffect(() => {
    if (!window.KX_TOKEN) {
      setMessages(tweaks.firstLaunch ? KXV2_ONBOARDING_SEED : KXV2_SEED);
      return;
    }
    // Drafts have no server-side row to fetch; just present an empty
    // canvas. The first send materializes the thread.
    if (activeThread === '__draft__') {
      setMessages([]);
      return;
    }
    let alive = true;
    window.kxApi(`/thread/${encodeURIComponent(activeThread)}?limit=50`)
      .then((data) => {
        if (!alive) return;
        const ui = (data.messages || []).map(toUiMessage).filter(Boolean);
        setMessages(ui);
      })
      .catch(() => { if (alive) setMessages([]); });
    return () => { alive = false; };
  }, [activeThread, tweaks.firstLaunch]);

  // Live thread id ref — captured fresh inside the WS onmessage
  // closure so typing-dots / system events end up tagged with the
  // thread the user is currently viewing.
  const activeThreadRef = React.useRef(activeThread);
  React.useEffect(() => { activeThreadRef.current = activeThread; }, [activeThread]);

  // WebSocket — single persistent connection per session. Server pushes
  // user-text echo + kayser-text replies + system events. The dedupe
  // by id (last line of onmessage) means optimistic local renders for
  // the same id don't get doubled when the server echo arrives.
  React.useEffect(() => {
    if (!window.KX_TOKEN) return;     // mock mode — no socket
    let closed = false;
    let retryMs = 1000;
    let ws = null;
    const connect = () => {
      ws = new WebSocket(window.KX_WS_BASE + '?token=' + encodeURIComponent(window.KX_TOKEN));
      ws.onopen = () => { retryMs = 1000; };
      ws.onmessage = (e) => {
        let env;
        try { env = JSON.parse(e.data); } catch (_) { return; }
        if (!env || !env.type) return;

        // ── system events: typing indicator, error fallback ─────────
        if (env.type === 'system' && env.payload) {
          const ev = env.payload.event;
          if (ev === 'agent_busy') {
            // Drop a typing-dots placeholder, tagged with the thread
            // the user is currently in so it shows up in the right
            // chat view (and gets filtered out of others).
            const tid = activeThreadRef.current;
            setMessages((m) => m.some((x) => x.id === 'kx_typing')
              ? m
              : [...m, { id: 'kx_typing', kind: 'kayser-typing', thread: tid }]);
            return;
          }
          if (ev === 'agent_unreachable') {
            setMessages((m) => m.filter((x) => x.id !== 'kx_typing'));
            setUndo({ label: "I'm having trouble reaching my brain. Try again in a minute." });
            return;
          }
          // Other system events (connected etc.) — no-op.
          return;
        }

        // ── thread_meta: title / folder move / archive notifications ──
        // Same envelope handles every per-thread sidebar mutation:
        //   payload.title      → rename (agent auto-titler OR user PATCH)
        //   payload.folder_id  → move (PATCH /threads/{slug})
        //   payload.archived   → delete (DELETE /threads/{slug})
        // Multiple fields in one envelope are allowed (PATCH with both).
        if (env.type === 'thread_meta' && env.payload && env.thread) {
          const { title, folder_id, archived } = env.payload;
          if (archived) {
            setRealThreads((prev) => prev.filter((t) => t.slug !== env.thread));
            if (env.thread === activeThreadRef.current) {
              setActiveThread('now');
            }
            return;
          }
          if (title || folder_id !== undefined) {
            setRealThreads((prev) => prev.map((t) => {
              if (t.slug !== env.thread) return t;
              const next = { ...t };
              if (title) next.name = title;
              if (folder_id !== undefined) {
                next.folder_id = folder_id == null ? null : String(folder_id);
              }
              return next;
            }));
          }
          return;
        }

        // ── tool_progress: a Hermes MCP tool is running ─────────────
        if (env.type === 'tool_progress' && env.payload) {
          // Surface Hermes's preview text alongside the tool name so
          // the live pill shows e.g. "Looking through past
          // conversations · foundation" instead of just the bare
          // tool label. Preview gets passed as-is to kxHumanizeTool
          // which strips it apart for display.
          // Phase governs pill style: started (default), failed (red),
          // thinking (italic — reasoning preview when
          // ATLAS_SURFACE_REASONING is on).
          const phase = env.payload.phase || 'started';
          let label = env.payload.tool || env.payload.name || 'running tool';
          const preview = env.payload.preview;
          if (preview && typeof preview === 'string') {
            const trimmed = preview.trim();
            if (trimmed) {
              // Use a separator the humanizer can split on without
              // mangling MCP names that contain underscores.
              label = `${label}${trimmed.slice(0, 80)}`;
            }
          }
          setMessages((m) => {
            // Attach to the streaming kayser-text if one exists,
            // otherwise to the typing-dots placeholder, otherwise
            // create a typing placeholder.
            const idx = m.findIndex(
              (x) => (x.kind === 'kayser-text' && x.streaming) || x.id === 'kx_typing'
            );
            if (idx !== -1) {
              const copy = m.slice();
              copy[idx] = { ...copy[idx], tool_progress: label, tool_phase: phase };
              return copy;
            }
            return [...m, {
              id: 'kx_typing', kind: 'kayser-typing',
              tool_progress: label, tool_phase: phase,
            }];
          });
          return;
        }

        // ── kayser-text-chunk: token-by-token streaming ─────────────
        if (env.type === 'kayser-text-chunk' && env.payload) {
          const delta = env.payload.delta || '';
          if (!delta) return;
          const streamId = env.id;   // stable per-reply id (stream_<rand>)
          setMessages((m) => {
            const idx = m.findIndex((x) => x.id === streamId);
            if (idx !== -1) {
              const copy = m.slice();
              copy[idx] = { ...copy[idx], text: (copy[idx].text || '') + delta };
              return copy;
            }
            // First chunk: replace any typing placeholder with a real
            // streaming bubble. Carry over any tool_progress label
            // that was already attached to the typing dots.
            const typingIdx = m.findIndex((x) => x.id === 'kx_typing');
            const carryTool = typingIdx !== -1 ? m[typingIdx].tool_progress : undefined;
            const t = fmtLocalTime(env.ts || Date.now());
            const newBubble = {
              id: streamId, kind: 'kayser-text', t, text: delta,
              thread: env.thread,
              streaming: true,
              ...(carryTool ? { tool_progress: carryTool } : {}),
            };
            if (typingIdx !== -1) {
              const copy = m.slice();
              copy[typingIdx] = newBubble;
              return copy;
            }
            return [...m, newBubble];
          });
          return;
        }

        // ── kayser-text (final): persist + finalize the streaming bubble ─
        if (env.type === 'kayser-text' || env.type === 'user-text' || env.type === 'kayser-card') {
          const ui = toUiMessage(env);
          if (!ui) return;

          // ── draft → real slug swap ───────────────────────────────
          // The first user-text echo for a brand-new conversation
          // carries `payload.draft_id === "__draft__"`. We swap
          // activeThread to the real slug the server picked, and
          // optimistically prepend the new thread to the sidebar so
          // it lands at the top immediately (refreshThreads reconciles
          // in the background to pull in the auto-title once Hermes
          // generates it).
          if (env.type === 'user-text'
              && env.payload && env.payload.draft_id === '__draft__'
              && env.thread) {
            if (activeThreadRef.current === '__draft__') {
              setActiveThread(env.thread);
            }
            setRealThreads((prev) => {
              if (prev.some((t) => t.slug === env.thread)) return prev;
              return [
                {
                  slug: env.thread, name: 'Untitled', kind: 'topic',
                  summary: '', pinned: false, unread: 0, folder_id: null,
                },
                ...prev,
              ];
            });
            setTimeout(() => refreshThreads(), 300);
          }
          setMessages((m) => {
            // Always remove any lingering typing placeholder when a
            // real Kayser message lands.
            let next = env.type === 'kayser-text'
              ? m.filter((x) => x.id !== 'kx_typing')
              : m;

            // If the server is finalizing a streaming reply, replace
            // the streaming bubble in place. The final envelope's
            // payload.streaming_id matches the stream_<rand> id we
            // were accumulating chunks into.
            const streamingId = env.payload && env.payload.streaming_id;
            if (env.type === 'kayser-text' && streamingId) {
              const idx = next.findIndex((x) => x.id === streamingId);
              if (idx !== -1) {
                const copy = next.slice();
                copy[idx] = { ...ui, streaming: false };
                return copy;
              }
            }

            // Optimistic-echo replacement for user messages (existing
            // dedupe pattern via client_id).
            const clientId = env.payload && env.payload.client_id;
            if (env.type === 'user-text' && clientId) {
              const idx = next.findIndex((x) => x.id === clientId);
              if (idx !== -1) {
                // If this echo is materializing a draft, retag the
                // optimistic bubble's thread field to the canonical
                // slug so kxv2FilterMessages keeps showing it after
                // we swap activeThread below.
                const copy = next.slice();
                copy[idx] = ui;
                return copy;
              }
            }

            // Already have this canonical id → no-op
            if (next.some((x) => x.id === ui.id)) return next;
            return [...next, ui];
          });
          return;
        }

        if (env.type === 'trust' && env.payload) {
          setTweak('trustPct', env.payload.overall_pct);
        }

        // Phase 5.0 — approval tray.
        // NOTE (2026-05-30): Hermes 0.15.2 doesn't emit approval events
        // on session_chat_stream, so this envelope is dormant today.
        // Path A (atlas-api-side tool gate, when channel tools ship)
        // will be the one inserting into approval_queue + emitting this
        // envelope. The handler logic below is correct as-is — only
        // the producer side changes. See runbooks/approvals-gap.md.
        // When the envelope arrives: queue it, open the sheet, let the
        // operator pick Send / Hold / Don't ask again / Skip.
        if (env.type === 'approval-pending' && env.payload) {
          setApprovalQueue((q) => {
            const exists = q.some((a) => a.approval_id === env.payload.approval_id);
            return exists ? q : [env.payload, ...q];
          });
          setApprovalOpen(true);
          return;
        }
        if (env.type === 'approval-resolved' && env.payload) {
          setApprovalQueue((q) => q.filter((a) => a.approval_id !== env.payload.approval_id));
          // If the resolved one was the visible one, close the sheet.
          // The next pending (if any) will pop on its own envelope.
          setApprovalOpen(false);
          return;
        }
        // `audit`, `memory` envelopes handled in later wiring steps
      };
      ws.onclose = () => {
        if (closed) return;
        setTimeout(connect, retryMs);
        retryMs = Math.min(retryMs * 2, 16000);
      };
      ws.onerror = () => {
        try { ws.close(); } catch (_) {}
      };
      window.__kxWs = ws;
    };
    connect();
    return () => {
      closed = true;
      try { ws && ws.close(); } catch (_) {}
      window.__kxWs = null;
    };
  }, []);
  // Onboarding overlay (5-screen welcome flow)
  const [showOnb, setShowOnb] = React.useState(false);
  React.useEffect(() => { setShowOnb(!!tweaks.showOnboarding); }, [tweaks.showOnboarding]);
  const [auditOpen, setAuditOpen] = React.useState(false);
  const [push, setPush] = React.useState(null);
  const [billing, setBilling] = React.useState(false);
  const [pauseConfirm, setPauseConfirm] = React.useState(false);
  const [paused, setPaused] = React.useState(false);
  const [serviceDetail, setServiceDetail] = React.useState(null); // string id
  const [palette, setPalette]   = React.useState(false);
  const [trust, setTrust]       = React.useState(false);
  const [status, setStatus]     = React.useState(false);
  const [teach, setTeach]       = React.useState(false);
  const [memory, setMemory]     = React.useState(false);
  const [settings, setSettings] = React.useState(false);
  const [menu, setMenu]         = React.useState(false);
  const [quickMenu, setQuickMenu] = React.useState(false);
  const [settingsPage, setSettingsPage] = React.useState(false);
  const [threadsSheet, setThreadsSheet] = React.useState(false);
  const menuAnchorRef = React.useRef(null);
  // (activeThread state moved to the top of the component — see comment above
  // the message-fetch useEffect for why.)
  const [approvalOpen, setApprovalOpen] = React.useState(false);
  // Phase 5.0 — live approval queue. Newest pending approval at index 0,
  // populated by the `approval-pending` WS envelope + a one-shot
  // backfill on mount (catches anything that fired while the WS was
  // disconnected). Cleared per-row on `approval-resolved`.
  const [approvalQueue, setApprovalQueue] = React.useState([]);
  const [wrongOpen, setWrongOpen]       = React.useState(false);

  // Backfill the approval tray from /api/v1/approvals/pending once.
  // Subsequent updates arrive over the WS; this just covers the
  // reconnect-after-disconnect case + initial page load.
  React.useEffect(() => {
    if (!window.KX_TOKEN || !window.kxApi) return;
    let alive = true;
    (async () => {
      try {
        const list = await window.kxApi('/approvals/pending');
        if (alive && Array.isArray(list) && list.length) {
          setApprovalQueue(list);
          setApprovalOpen(true);
        }
      } catch (_) { /* kxApi handles 401 redirect; otherwise best-effort */ }
    })();
    return () => { alive = false; };
  }, []);

  const [undo, setUndo]         = React.useState(null); // { label }
  const [levelUpDismissed, setLevelUpDismissed] = React.useState(false);

  // ── Live trust state ───────────────────────────────────────────
  // The top bar reads from realTrust when the user is signed in;
  // falls back to the tweaks defaults so the standalone prototype
  // still renders sane values. /trust is server-side cached for 60s
  // so polling from multiple tabs is cheap.
  const [realTrust, setRealTrust] = React.useState(null);
  React.useEffect(() => {
    if (!window.KX_TOKEN) return;
    let alive = true;
    const fetchTrust = async () => {
      try {
        const t = await window.kxApi('/trust');
        if (alive && t && typeof t === 'object') setRealTrust(t);
      } catch (_) { /* best-effort polling */ }
    };
    fetchTrust();
    // 60s matches the server-side cache TTL; visible-tab heuristic
    // lets dormant tabs sleep without dropping freshness when focused.
    const id = setInterval(() => {
      if (document.visibilityState === 'visible') fetchTrust();
    }, 60000);
    const onVis = () => { if (document.visibilityState === 'visible') fetchTrust(); };
    document.addEventListener('visibilitychange', onVis);
    return () => {
      alive = false;
      clearInterval(id);
      document.removeEventListener('visibilitychange', onVis);
    };
  }, []);

  // Derive the display values. realTrust wins when present; otherwise
  // fall through to the tweaks defaults for the file:// prototype mode.
  //
  // Trust v1 ("count chat tool calls") was misleading — see
  // docs/trust-v2-plan.md. Until the real task-completion substrate
  // lands, overall_pct / level / handled_today come back as null from
  // /trust and we render a "Setting up" copy instead of a fake number.
  // `watching` stays live (active threads count).
  const trustReady     = realTrust ? realTrust.overall_pct != null : !window.KX_TOKEN;
  const trustPct       = realTrust ? realTrust.overall_pct : tweaks.trustPct;
  const trustLevel     = realTrust ? realTrust.level
                                   : (trustPct != null
                                       ? (trustPct < 25 ? 1 : trustPct < 50 ? 2 : trustPct < 75 ? 3 : 4)
                                       : null);
  const watchingCount  = realTrust ? realTrust.watching      : tweaks.watching;
  const handledCount   = realTrust ? realTrust.handled_today : tweaks.handledToday;
  const tier = trustLevel != null ? KXV2_TRUST_TIERS.find(t => t.lvl === trustLevel) : null;

  // Wallpaper hue swap (kept tight: purple, indigo, slate)
  const wallpaper = {
    purple: KX2_WALLPAPER,
    indigo: `
      radial-gradient(ellipse 80% 60% at 20% 0%,  #1e3a8a 0%, transparent 60%),
      radial-gradient(ellipse 70% 50% at 100% 30%, #3730a3 0%, transparent 55%),
      radial-gradient(ellipse 90% 60% at 50% 100%, #0f172a 0%, transparent 60%),
      linear-gradient(180deg, #0f172a 0%, #020617 100%)
    `,
    slate: `
      radial-gradient(ellipse 80% 60% at 20% 0%,  #1e293b 0%, transparent 60%),
      radial-gradient(ellipse 70% 50% at 100% 30%, #334155 0%, transparent 55%),
      linear-gradient(180deg, #0b0f17 0%, #050709 100%)
    `,
  }[tweaks.wallpaperHue] || KX2_WALLPAPER;

  const handleSend = (text, opts) => {
    const attachmentIds = (opts && Array.isArray(opts.attachment_ids))
      ? opts.attachment_ids : [];
    if (!window.KX_TOKEN) {
      // Mock mode — preserve the canned demo behaviour for the
      // standalone prototype (no server).
      const id = Date.now();
      setMessages((m) => [...m, { id, kind: 'user-text', t: 'now', text }]);
      setTimeout(() => {
        setMessages((m) => [...m, {
          id: id + 1, kind: 'kayser-text', t: 'now',
          text: "On it. I'll come back when I have something.",
        }]);
      }, 700);
      return;
    }
    // Live mode — optimistic echo. We send the tempId as `client_id`
    // so the server's WS echo of the same user-text can replace the
    // optimistic bubble in place rather than appending a duplicate.
    // Attachment ids ride alongside; the bubble carries placeholder
    // attachment chips until the server-echo arrives with the real
    // filename/size/type metadata.
    const tempId = 'tmp_' + Date.now();
    setMessages((m) => [...m, {
      id: tempId, kind: 'user-text',
      t: fmtLocalTime(Date.now()),
      thread: activeThread,
      text,
      attachments: attachmentIds.length
        ? attachmentIds.map((id) => ({ id, filename: 'Uploading…', content_type: '', size_bytes: 0 }))
        : undefined,
    }]);
    window.kxApi('/messages', {
      method: 'POST',
      body: JSON.stringify({
        thread: activeThread,
        text,
        client_id: tempId,
        attachment_ids: attachmentIds,
      }),
    }).catch(() => {
      setUndo({ label: "Couldn't send that. Try again in a minute." });
    });
    // The agent reply arrives on the WebSocket — no local timeout.
  };

  const handlePick = (cmd) => {
    setPalette(false);
    if (cmd.hint === 'trust' || cmd.hint === 'auto') {
      setTimeout(() => setTrust(true), 80);
      return;
    }
    if (cmd.hint === 'teach') {
      setTimeout(() => setTeach(true), 80);
      return;
    }
    if (cmd.hint === 'memory' || cmd.hint === 'forget') {
      setTimeout(() => setMemory(true), 80);
      return;
    }
    // Skill picks send the skill-aware prompt string the palette
    // attached to the command rather than the human label.
    if (cmd.hint && cmd.hint.startsWith('skill:') && cmd.sendText) {
      handleSend(cmd.sendText);
      return;
    }
    handleSend(cmd.label);
  };

  const handleCardTap = (card) => {
    if (card.type === 'approval') setApprovalOpen(true);
    if (card.type === 'auto-handled') setWrongOpen(true);
  };

  const handleCardAction = (card, action) => {
    if (card.type === 'approval' && action === 'Send') {
      // Real send flow: replace card with auto-handled receipt + show undo bar
      setMessages(ms => [
        ...ms.filter(m => m.card !== card),
        { id: Date.now(), kind: 'kayser-card', t: 'now', card: {
          type: 'auto-handled',
          title: 'Sent reminder · Garcia · #1031',
          subtitle: 'Delivered via SMS',
          meta: 'You said send. I sent it. Undo if you want.',
        }},
      ]);
      setUndo({ label: 'Sent reminder to Lina Garcia' });
    } else if (card.type === 'approval' && action === 'Edit') {
      setApprovalOpen(true);
    } else if (card.type === 'approval' && action === 'Skip') {
      // Remove the card and post a brief Kayser ack
      setMessages(ms => [
        ...ms.filter(m => m.card !== card),
        { id: Date.now(), kind: 'kayser-text', t: 'now',
          text: "Skipped. I'll check again in 24 hours." },
      ]);
    } else if (card.type === 'insight') {
      // Insight CTA: post a Kayser follow-up plan
      const id = Date.now();
      setMessages(ms => [
        ...ms,
        { id, kind: 'user-text', t: 'now', text: action },
        { id: id + 1, kind: 'kayser-text', t: 'now',
          text: "Drafting now. I'll group the 3 overdue invoices, send Garcia the soft nudge first (already approved a tone you like), then a payment-plan offer to Henderson. The third — Reyes — gets nothing yet; she always pays around day 10. Want me to queue it?",
          replies: ["Yes, queue it", "Skip Reyes anyway", "Show me the drafts"] },
      ]);
    }
  };

  const handleReply = (text) => {
    handleSend(text);
  };

  const handleAttach = () => {
    setMessages(ms => [...ms, {
      id: Date.now(), kind: 'kayser-text', t: 'now',
      text: 'Attach a photo, voice memo, or file — I\'ll figure out where it goes. (Demo: not wired in this prototype.)',
    }]);
  };

  const handleSentFromSheet = () => {
    setApprovalOpen(false);
    setUndo({ label: 'Sent reminder to Lina Garcia' });
    // Replace the approval card with auto-handled receipt
    setMessages(ms => ms.map(m =>
      m.card?.type === 'approval' && m.card?.title?.includes('Garcia')
        ? { ...m, card: {
            type: 'auto-handled',
            title: 'Sent reminder · Garcia · #1031',
            subtitle: 'Delivered via SMS',
            meta: 'You said send. I sent it. Undo if you want.',
          }}
        : m
    ));
  };

  const handleHoldFromSheet = ({ minutes }) => {
    setApprovalOpen(false);
    const fmt = minutes < 60 ? `${minutes} min`
              : minutes < 480 ? `${minutes/60} hr`
              : minutes < 1440 ? 'tonight' : 'tomorrow';
    setUndo({ label: `Holding Garcia reminder for ${fmt}. You can cancel anytime.` });
    setMessages(ms => ms.map(m =>
      m.card?.type === 'approval' && m.card?.title?.includes('Garcia')
        ? { ...m, card: {
            type: 'auto-handled',
            title: `Holding reminder · Garcia · ${fmt}`,
            subtitle: `Will send in ${fmt} unless you cancel`,
            meta: 'You can review, edit, or cancel in chat anytime in that window.',
          }}
        : m
    ));
  };

  const handleWrongConfirm = () => {
    setWrongOpen(false);
    setUndo({ label: "Undid it. I'll be more careful next time." });
  };

  // Demo push trigger
  React.useEffect(() => {
    if (!tweaks.pushDemo) { setPush(null); return; }
    setPush({ title: 'Kayser', body: 'Garcia just opened the reminder you sent. She replied: "I\'ll have it Friday morning."' });
  }, [tweaks.pushDemo]);

  const showLevelUp = tweaks.showLevelUp && !levelUpDismissed && tier;
  // When wrapped in DesktopShell at ≥1024, the outer shell renders its own top bar.
  // We hide ours + the iOS-status-bar padding to avoid double headers.
  const [isDesktop, setIsDesktop] = React.useState(() => !!window.__kxDesktop);
  React.useEffect(() => {
    const check = () => setIsDesktop(!!window.__kxDesktop);
    check();
    const id = setInterval(check, 250);
    return () => clearInterval(id);
  }, []);

  // Expose openers for desktop shell
  React.useEffect(() => {
    window.__kxOpenPalette = () => setPalette(true);
    window.__kxOpenMenu = () => setQuickMenu(q => !q);
    window.__kxOpenSettings = () => setSettingsPage(true);
    return () => {
      window.__kxOpenPalette = null; window.__kxOpenMenu = null; window.__kxOpenSettings = null;
    };
  }, []);

  // ── Real threads — replace the mock KXV2_THREADS for the live PWA ──
  //
  // The threads rail (desktop) and threads sheet (mobile) read from
  // window.__kxThreads when present, falling back to KXV2_THREADS only
  // for the standalone/file:// prototype. Refreshed on mount + after
  // every `done` envelope (handled by the WS branch above for the
  // title-generation case).
  const [realThreads, setRealThreads] = React.useState([]);
  const [realFolders, setRealFolders] = React.useState([]);
  const refreshThreads = React.useCallback(async () => {
    if (!window.KX_TOKEN) return;
    try {
      const list = await window.kxApi('/threads');
      if (!Array.isArray(list)) return;
      // Adapt server shape → component shape (the components want
      // `summary` + `pinned` etc. — fill sensible defaults).
      const mapped = list.map((t) => ({
        slug: t.slug,
        name: t.name || 'Untitled',
        kind: t.kind || 'topic',
        summary: t.summary || '',
        pinned: !!t.pinned,
        unread: t.unread || 0,
        folder_id: t.folder_id || null,
      }));
      setRealThreads(mapped);
    } catch (_) { /* best-effort */ }
  }, []);
  const refreshFolders = React.useCallback(async () => {
    if (!window.KX_TOKEN) return;
    try {
      const list = await window.kxApi('/folders');
      if (!Array.isArray(list)) return;
      setRealFolders(list.map((f) => ({
        id: f.id, name: f.name, position: f.position || 0,
      })));
    } catch (_) { /* best-effort */ }
  }, []);
  React.useEffect(() => { refreshThreads(); refreshFolders(); }, [refreshThreads, refreshFolders]);

  // Synthesise a draft row into the sidebar list while the user is
  // composing a brand-new conversation. The draft has no server row
  // until they hit send; this is purely so the rail and sheet show
  // "New conversation" at the top instead of looking like the user is
  // still on the previously-selected thread.
  const displayedThreads = React.useMemo(() => {
    if (activeThread !== '__draft__') return realThreads;
    if (realThreads.some((t) => t.slug === '__draft__')) return realThreads;
    return [
      {
        slug: '__draft__', name: 'New conversation', kind: 'topic',
        summary: 'Type to start…', pinned: false, unread: 0, folder_id: null,
      },
      ...realThreads,
    ];
  }, [realThreads, activeThread]);

  React.useEffect(() => {
    window.__kxThreads = displayedThreads;
    return () => { window.__kxThreads = null; };
  }, [displayedThreads]);
  React.useEffect(() => {
    window.__kxFolders = realFolders;
    return () => { window.__kxFolders = null; };
  }, [realFolders]);

  // ── Real identity (/me) ────────────────────────────────────────────
  //
  // Drives the QuickMenu profile header + Settings "Profile" row hint.
  // Without this the menu shows hardcoded mock copy ("Wes Kayser /
  // Kayser Foundation Repair") regardless of who is signed in. Fetched
  // once on mount because the only things that can change it
  // mid-session are tenant adds/removes (rare, the operator can
  // re-sign-in to refresh) and Google profile picture (cosmetic).
  // Empty object (not null) so child components can destructure safely.
  const [realMe, setRealMe] = React.useState({});
  React.useEffect(() => {
    if (!window.KX_TOKEN) return;
    let alive = true;
    (async () => {
      try {
        const me = await window.kxApi('/me');
        if (alive && me && typeof me === 'object') setRealMe(me);
      } catch (_) { /* kxApi handles 401; leave empty otherwise */ }
    })();
    return () => { alive = false; };
  }, []);

  // Refresh threads after each agent reply so titles + last-message
  // ordering reflect the latest exchange.
  React.useEffect(() => {
    const lastK = messages.length ? messages[messages.length - 1] : null;
    if (lastK && lastK.kind === 'kayser-text' && !lastK.streaming) {
      refreshThreads();
    }
  }, [messages, refreshThreads]);

  // ── New thread handler — pure client-side draft ──────────────────
  //
  // Switches to the sentinel `__draft__` slug and clears the chat
  // area. No API call. The first message sent against this slug
  // materializes the real thread server-side (POST /messages handles
  // the sentinel + echoes back the real slug; we swap activeThread in
  // the WS handler when payload.draft_id arrives).
  //
  // No-op if the user is already sitting on an empty draft — clicking
  // "+ New" repeatedly used to pile up empty rows; now it's just inert.
  const KX_DRAFT_SLUG = '__draft__';
  const handleNewThread = React.useCallback(() => {
    if (!window.KX_TOKEN) {
      setActiveThread('now');
      return;
    }
    if (activeThread === KX_DRAFT_SLUG && messages.length === 0) return;
    setActiveThread(KX_DRAFT_SLUG);
    setMessages([]);
  }, [activeThread, messages.length]);

  React.useEffect(() => {
    window.__kxNewThread = handleNewThread;
    return () => { window.__kxNewThread = null; };
  }, [handleNewThread]);

  // ── Folder + thread mutation handlers ────────────────────────────
  // All six are best-effort optimistic: update local state immediately
  // for snappy UI, then reconcile with the server. On failure we show
  // the undo toast and refresh from server to bounce back to truth.

  const handleNewFolder = React.useCallback(async () => {
    if (!window.KX_TOKEN) return;
    const name = window.prompt('New folder name:', 'Untitled folder');
    if (!name || !name.trim()) return;
    try {
      const f = await window.kxApi('/folders', {
        method: 'POST',
        body: JSON.stringify({ name: name.trim() }),
      });
      if (f && f.id) {
        setRealFolders((prev) => [...prev, { id: f.id, name: f.name, position: f.position || 0 }]);
      }
    } catch (_) {
      setUndo({ label: "Couldn't create that folder. Try again in a sec." });
    }
  }, []);

  const handleRenameFolder = React.useCallback(async (folder, name) => {
    if (!window.KX_TOKEN || !folder || !name) return;
    setRealFolders((prev) => prev.map((f) =>
      String(f.id) === String(folder.id) ? { ...f, name } : f
    ));
    try {
      await window.kxApi(`/folders/${encodeURIComponent(folder.id)}`, {
        method: 'PATCH',
        body: JSON.stringify({ name }),
      });
    } catch (_) {
      setUndo({ label: "Couldn't rename that folder." });
      refreshFolders();
    }
  }, [refreshFolders]);

  const handleDeleteFolder = React.useCallback(async (folder) => {
    if (!window.KX_TOKEN || !folder) return;
    if (!window.confirm(`Delete "${folder.name}"? The conversations inside stay; they just won't be grouped anymore.`)) return;
    // Optimistic: drop the folder + un-bucket its threads.
    setRealFolders((prev) => prev.filter((f) => String(f.id) !== String(folder.id)));
    setRealThreads((prev) => prev.map((t) =>
      String(t.folder_id) === String(folder.id) ? { ...t, folder_id: null } : t
    ));
    try {
      await window.kxApi(`/folders/${encodeURIComponent(folder.id)}`, { method: 'DELETE' });
    } catch (_) {
      setUndo({ label: "Couldn't delete that folder." });
      refreshFolders();
      refreshThreads();
    }
  }, [refreshFolders, refreshThreads]);

  const handleRenameThread = React.useCallback(async (thread) => {
    if (!window.KX_TOKEN || !thread) return;
    const name = window.prompt('Rename conversation:', thread.name || '');
    if (name == null) return;
    const trimmed = name.trim();
    if (!trimmed || trimmed === thread.name) return;
    setRealThreads((prev) => prev.map((t) =>
      t.slug === thread.slug ? { ...t, name: trimmed } : t
    ));
    try {
      await window.kxApi(`/threads/${encodeURIComponent(thread.slug)}`, {
        method: 'PATCH',
        body: JSON.stringify({ title: trimmed }),
      });
    } catch (_) {
      setUndo({ label: "Couldn't rename that conversation." });
      refreshThreads();
    }
  }, [refreshThreads]);

  const handleMoveThread = React.useCallback(async (thread, folderId) => {
    if (!window.KX_TOKEN || !thread) return;
    const targetId = folderId == null ? null : String(folderId);
    setRealThreads((prev) => prev.map((t) =>
      t.slug === thread.slug ? { ...t, folder_id: targetId } : t
    ));
    try {
      await window.kxApi(`/threads/${encodeURIComponent(thread.slug)}`, {
        method: 'PATCH',
        body: JSON.stringify({ folder_id: targetId }),
      });
    } catch (_) {
      setUndo({ label: "Couldn't move that conversation." });
      refreshThreads();
    }
  }, [refreshThreads]);

  const handleDeleteThread = React.useCallback(async (thread) => {
    if (!window.KX_TOKEN || !thread) return;
    if (!window.confirm(`Delete "${thread.name}"? Messages stay in your history but the conversation hides from the sidebar.`)) return;
    setRealThreads((prev) => prev.filter((t) => t.slug !== thread.slug));
    // If you just deleted the open thread, fall back to "now".
    if (thread.slug === activeThread) setActiveThread('now');
    try {
      await window.kxApi(`/threads/${encodeURIComponent(thread.slug)}`, { method: 'DELETE' });
    } catch (_) {
      setUndo({ label: "Couldn't delete that conversation." });
      refreshThreads();
    }
  }, [activeThread, refreshThreads]);

  // Expose folder + thread mutation handlers for DesktopShell.
  React.useEffect(() => {
    window.__kxNewFolder = handleNewFolder;
    window.__kxRenameFolder = handleRenameFolder;
    window.__kxDeleteFolder = handleDeleteFolder;
    window.__kxRenameThread = handleRenameThread;
    window.__kxMoveThread = handleMoveThread;
    window.__kxDeleteThread = handleDeleteThread;
    return () => {
      window.__kxNewFolder = null; window.__kxRenameFolder = null;
      window.__kxDeleteFolder = null; window.__kxRenameThread = null;
      window.__kxMoveThread = null; window.__kxDeleteThread = null;
    };
  }, [handleNewFolder, handleRenameFolder, handleDeleteFolder,
      handleRenameThread, handleMoveThread, handleDeleteThread]);

  // Expose thread setter to desktop shell rail
  React.useEffect(() => {
    window.__kxSetThread = (slug) => setActiveThread(slug);
    window.__kxGetThread = () => activeThread;
    return () => { window.__kxSetThread = null; window.__kxGetThread = null; };
  }, [activeThread]);

  return (
    <div style={{
      width: '100%', height: '100%', position: 'relative',
      display: 'flex', flexDirection: 'column',
      background: wallpaper,
      fontFamily: KX2.fontUI, color: KX2.ink, overflow: 'hidden',
      paddingTop: isDesktop ? 0 : 54,
    }}>
      <div style={{
        position: 'absolute', inset: 0, pointerEvents: 'none', opacity: 0.5,
        backgroundImage: 'radial-gradient(circle at 30% 20%, rgba(255,255,255,.05) 0%, transparent 40%)',
      }} />

      <KxV2TopBar
        watching={watchingCount} handledToday={handledCount}
        trustPct={trustPct} trustLevel={trustLevel}
        onOpenStatus={() => setStatus(true)}
        onOpenTrust={() => setTrust(true)}
        onOpenSettings={() => setQuickMenu(q => !q)}
        onOpenThreads={() => setThreadsSheet(true)}
        __hidden={isDesktop}
      />

      <KxV2ScopedHeader
        thread={KXV2_THREADS.find(t => t.slug === activeThread)}
        onBack={() => setActiveThread('now')}
      />

      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
        {showLevelUp && (
          <div style={{ paddingTop: 8 }}>
            <KxV2LevelUp tier={tier} onDismiss={() => setLevelUpDismissed(true)}
              onSeeMore={() => { setLevelUpDismissed(true); setTrust(true); }} />
          </div>
        )}
        <KxV2Thread
          header={tweaks.showMorningBrief && activeThread === 'now' && !window.KX_TOKEN
            ? <KxV2MorningBrief brief={KXV2_MORNING_BRIEF} />
            : null}
          messages={kxv2FilterMessages(messages, activeThread)} onCardTap={handleCardTap} onCardAction={handleCardAction} onReply={handleReply} />
      </div>

      <KxV2Composer
        placeholder={activeThread === 'now'
          ? "Ask Kayser, or tap Commands"
          : `Ask about ${KXV2_THREADS.find(t => t.slug === activeThread)?.name || 'this'}…`}
        threadSlug={activeThread}
        onCmd={() => setPalette(true)}
        onSend={handleSend}
        onVoice={(text) => handleSend(text)}
      />

      <KxV2Palette open={palette} onClose={() => setPalette(false)} onPick={handlePick} />
      <KxV2TrustSheet open={trust} onClose={() => setTrust(false)} pct={trustPct} level={trustLevel}
        onTeach={() => { setTrust(false); setTimeout(() => setTeach(true), 200); }} />
      <KxV2StatusSheet open={status} onClose={() => setStatus(false)} watching={watchingCount} handledToday={handledCount}
        onPick={(ask) => { setStatus(false); setTimeout(() => handleSend(ask), 200); }} />
      <KxV2TeachSheet open={teach} onClose={() => setTeach(false)} />
      <KxV2MemorySheet open={memory} onClose={() => setMemory(false)} />
      <KxV2ApprovalSheet open={approvalOpen} onClose={() => setApprovalOpen(false)}
        detail={approvalQueue[0]}
        onSent={async () => {
          // "Send" maps to upstream `once` (Hermes approves THIS execution).
          // Live path: POST /api/v1/approvals/{id}/resolve. Falls through
          // to the legacy mock-card flow when there's no real approval
          // in the queue (the prototype demo path).
          const pending = approvalQueue[0];
          if (pending) {
            try {
              await window.kxApi(`/approvals/${pending.approval_id}/resolve`, {
                method: 'POST',
                body: JSON.stringify({ choice: 'send' }),
              });
              // approval-resolved WS envelope will close the sheet
              // and drop the row; nothing else to do here.
            } catch (_) { setApprovalOpen(false); }
          } else if (handleSentFromSheet) {
            handleSentFromSheet();
          }
        }}
        onHold={(payload) => {
          // Hold is operator-side parking for v1 — no upstream call yet.
          // KxV2HoldForReview captures { minutes, draft } so a future
          // /api/v1/approvals/{id}/hold endpoint can schedule it.
          if (handleHoldFromSheet) handleHoldFromSheet(payload);
          setApprovalOpen(false);
        }}
        onWrong={() => { setApprovalOpen(false); setTimeout(() => setWrongOpen(true), 200); }} />
      <KxV2WrongSheet open={wrongOpen} onClose={() => setWrongOpen(false)}
        onConfirm={async (reason) => {
          // "Don't ask me again" → upstream `always`. If no live
          // approval queued, fall through to the legacy mock-handler
          // (which adjusts trust copy etc.).
          const pending = approvalQueue[0];
          if (pending) {
            try {
              await window.kxApi(`/approvals/${pending.approval_id}/resolve`, {
                method: 'POST',
                body: JSON.stringify({ choice: 'always', reason }),
              });
            } catch (_) { /* WS resolved envelope is the source of truth */ }
            setWrongOpen(false);
          } else if (handleWrongConfirm) {
            handleWrongConfirm(reason);
          }
        }} />
      <KxV2SettingsSheet open={settings} onClose={() => setSettings(false)}
        onOpenMemory={() => setMemory(true)}
        onOpenTrust={() => setTrust(true)}
        onOpenTeach={() => setTeach(true)} />
      <KxV2MenuSheet open={menu} onClose={() => setMenu(false)}
        me={realMe}
        onOpenMemory={() => setMemory(true)}
        onOpenTrust={() => setTrust(true)}
        onOpenTeach={() => setTeach(true)}
        onOpenConversations={() => setThreadsSheet(true)} />
      <KxV2QuickMenu open={quickMenu} onClose={() => setQuickMenu(false)}
        anchorRef={menuAnchorRef}
        me={realMe}
        onOpenSettings={() => { setQuickMenu(false); setSettingsPage(true); }}
        onOpenBilling={() => setQuickMenu(false)}
        onOpenHelp={() => setQuickMenu(false)}
        onSignOut={() => { setQuickMenu(false); window.kxLogout?.(); }} />
      <KxV2SettingsPage open={settingsPage} onClose={() => setSettingsPage(false)}
        me={realMe}
        onOpen={(id) => {
          if (id === 'trust')         { setSettingsPage(false); setTimeout(() => setTrust(true), 100); }
          if (id === 'memory')        { setSettingsPage(false); setTimeout(() => setMemory(true), 100); }
          if (id === 'automations')   { setSettingsPage(false); setTimeout(() => setTeach(true), 100); }
          if (id === 'conversations') { setSettingsPage(false); setTimeout(() => setThreadsSheet(true), 100); }
          if (id === 'billing')       { setSettingsPage(false); setTimeout(() => setBilling(true), 100); }
          if (id === 'pause')         { setSettingsPage(false); setTimeout(() => setPauseConfirm(true), 100); }
          if (['gmail','twilio','quickbooks','calendar','stripe'].includes(id)) {
            setSettingsPage(false); setTimeout(() => setServiceDetail(id), 100);
          }
        }} />
      <KxV2BillingSheet open={billing} onClose={() => setBilling(false)} />
      <KxV2PauseSheet open={pauseConfirm} onClose={() => setPauseConfirm(false)}
        paused={paused}
        onTogglePause={(v) => { setPaused(v); setPauseConfirm(false); }} />
      <KxV2ServiceSheet open={!!serviceDetail} service={serviceDetail}
        onClose={() => setServiceDetail(null)} />
      <KxV2ThreadsSheet open={threadsSheet} onClose={() => setThreadsSheet(false)}
        activeSlug={activeThread}
        threads={displayedThreads}
        folders={realFolders}
        onNew={handleNewThread}
        onPick={(slug) => setActiveThread(slug)}
        onNewFolder={handleNewFolder}
        onRenameFolder={handleRenameFolder}
        onDeleteFolder={handleDeleteFolder}
        onRenameThread={handleRenameThread}
        onMoveThread={handleMoveThread}
        onDeleteThread={handleDeleteThread} />
      <KxV2UndoBar visible={!!undo} label={undo?.label}
        onUndo={() => setUndo(null)} onDismiss={() => setUndo(null)} />

      <KxV2AuditSheet open={auditOpen} onClose={() => setAuditOpen(false)} />
      <KxV2OfflineBanner visible={tweaks.offline} />
      <KxV2PushBanner note={push} onDismiss={() => { setPush(null); setTweak('pushDemo', false); }} onTap={() => { setPush(null); setTweak('pushDemo', false); }} />

      {showOnb && (
        <KxV2Onboarding
          onSkip={() => { setShowOnb(false); setTweak('showOnboarding', false); }}
          onComplete={(data) => {
            setShowOnb(false);
            setTweak('showOnboarding', false);
            // If user tapped "Show me the draft" on the first-find step, pop the approval sheet
            if (data?.openApproval) setTimeout(() => setApprovalOpen(true), 250);
          }}
        />
      )}

      {/* Dev-only tweaks panel. Hidden in prod (window.KX_DEV false);
          append ?dev=1 to the URL to opt in for the current tab. The
          panel previously drove top-bar values (trustPct/watching/
          handledToday); those now come from real /trust data. */}
      {window.KX_DEV && (
      <TweaksPanel title="Tweaks">
        <TweakSection label="Kayser's growth" />
        <TweakSlider label="Trust %" value={tweaks.trustPct} min={0} max={100} unit="%"
          onChange={(v) => setTweak('trustPct', v)} />
        <TweakToggle label="Show level-up moment" value={tweaks.showLevelUp}
          onChange={(v) => { setTweak('showLevelUp', v); setLevelUpDismissed(false); }} />
        <TweakSection label="Status counts" />
        <TweakNumber label="Watching" value={tweaks.watching} min={0} max={99}
          onChange={(v) => setTweak('watching', v)} />
        <TweakNumber label="Handled today" value={tweaks.handledToday} min={0} max={99}
          onChange={(v) => setTweak('handledToday', v)} />
        <TweakSection label="Aesthetic" />
        <TweakRadio label="Wallpaper" value={tweaks.wallpaperHue}
          options={['purple', 'indigo', 'slate']}
          onChange={(v) => setTweak('wallpaperHue', v)} />
        <TweakSection label="Demo" />
        <TweakToggle label="Show morning brief" value={tweaks.showMorningBrief}
          onChange={(v) => setTweak('showMorningBrief', v)} />
        <TweakToggle label="Offline banner" value={tweaks.offline}
          onChange={(v) => setTweak('offline', v)} />
        <TweakButton label="Fire push notification" onClick={() => setTweak('pushDemo', true)} />
        <TweakButton label="Open audit log" onClick={() => setAuditOpen(true)} />
        <TweakToggle label="First-launch mode (welcome bubbles)" value={tweaks.firstLaunch}
          onChange={(v) => setTweak('firstLaunch', v)} />
        <TweakToggle label="Show full onboarding flow" value={tweaks.showOnboarding}
          onChange={(v) => { setTweak('showOnboarding', v); setShowOnb(v); }} />
        <TweakButton label="Reset conversation" onClick={() => {
          setMessages(tweaks.firstLaunch ? KXV2_ONBOARDING_SEED : KXV2_SEED);
          setUndo(null); setLevelUpDismissed(false);
        }} />
      </TweaksPanel>
      )}
    </div>
  );
}

Object.assign(window, { KxV2App });
