/* =========================================================
   CONNIE - main app (voice-wired against the connie-app web API)

   Replaces the original Claude-Design simulation (runScenario) with the
   real loop: Web Speech (de-DE) -> POST /web/turn -> POST /web/tts -> mp3.
   Same state machine (ready | listening | thinking | speaking | error),
   same Orb/Panels/Waveform. Echo-guard: the mic never listens while
   Connie's audio is playing (SR-VM-4).
   ========================================================= */
const { useState: aState, useEffect: aEffect, useRef: aRef } = React;

// ---------- Backend config (from gitignored config.js) ----------
// nullish-coalesce, not ||, so an explicit empty string CONNIE_API_BASE="" (same-
// origin, used by the WG-served Caddy build at connie.vpn.conventic.com) is
// preserved instead of silently falling back to the local-tunnel default.
const API_BASE = (window.CONNIE_API_BASE ?? "http://127.0.0.1:8081").replace(/\/+$/, "");
const WEB_TOKEN = window.CONNIE_WEB_TOKEN || "";
const TURN_TIMEOUT_MS = 60000;

// ---------- Layout/settings persistence (dashboard-v3) ----------
const LAYOUT = window.ConnieLayout;  // from layout.jsx (window-global, no build)

// Card registry: stable id -> column + German title (for the hidden-list). The
// render function lives in App (cards take different live props). The central
// Orb/core is intentionally NOT a card here - it can never be hidden (#8).
//
// S3: a 4-column "2 | Orb | 2" widescreen layout. The natural distribution of
// the 9 cards (F1): left | left2 | Orb | right | right2. The column names
// "left"/"right" are unchanged from the old 2-column model so a stored layout
// keeps working; "left2"/"right2" are the two new columns. Per-column order in
// COLUMN_CARD_IDS is the natural fallback order (drag overrides it per card).
const COLUMN_CARD_IDS = {
  left:   ["schedule", "weather"],
  left2:  ["stocks", "pipeline", "billomat"],
  right:  ["approvals", "workflow", "memory", "reel"],
  right2: ["working_memory", "activity", "transcript"],
};
const CARD_TITLES = {
  schedule:       "Heute",
  weather:        "Wetter",
  stocks:         "Aktien",
  pipeline:       "Pipeline · HubSpot",
  billomat:       "Billomat · Offen",
  approvals:      "Freigaben",
  memory:         "Gedächtnis · Graph",
  workflow:       "Workflows",
  reel:           "Reel · Video",
  working_memory: "Arbeitsgedächtnis · Live",
  activity:       "Aktivität · Live",
  transcript:     "Transkript",
};
// Short German label per column - shown in the column header and, rotated, on a
// collapsed column's strip. Neutral ordinals because cards are freely draggable
// between columns, so a semantic label ("Heute") would lie once a card moves.
const COLUMN_LABELS = {
  left:   "Spalte 1",
  left2:  "Spalte 2",
  right:  "Spalte 3",
  right2: "Spalte 4",
};

// Natural column for every card (cardId -> column name). This is the fallback
// the layout engine uses when a card has no saved column override. New cards
// added here automatically appear in their natural column (robust vs old state).
const NATURAL_COLS = (() => {
  const m = {};
  for (const [col, ids] of Object.entries(COLUMN_CARD_IDS)) {
    ids.forEach((id) => { m[id] = col; });
  }
  return m;
})();

// ---------- Quick-action prompts (sent verbatim to Connie) ----------
const QUICK_PROMPTS = {
  briefing:     "Gib mir bitte mein Tagesbriefing.",
  draft_mail:   "Ich möchte eine E-Mail entwerfen. Frag mich bitte zuerst, an wen sie gehen soll und worum es geht.",
  find_deal:    "Such mir Deals. Frag mich bitte, zu welchem Kunden oder Thema.",
  prep_meeting: "Bereite mein nächstes anstehendes Meeting vor.",
};

// ---------- Spoken morning briefing (sent verbatim to Connie) ----------
// A relaxed, warm spoken briefing for the start of the day. It is read aloud,
// so the prompt forbids Markdown / lists / numbers / links and asks for whole
// spoken sentences with naturally spoken times and dates. Connie duzt Bernd
// (no Sie-form). Tools named explicitly so she pulls the right data:
// calendar_agenda (all calendars + birthdays via title scan) and tasks_open
// against the "Connie Follow-ups" list. Nothing is invented when empty.
const MORNING_BRIEFING_PROMPT = [
  "Sprich mir ein kurzes, entspanntes Morgen-Briefing - so, als würdest du mir beim ersten Kaffee erzählen, was heute ansteht.",
  "Beginne mit einer kurzen, warmen Begrüßung und dem heutigen Datum.",
  "Schau dir dann meine heutigen Termine über ALLE meine Kalender an - benutze dafür das Tool calendar_agenda.",
  "Nenne danach meine offenen To-dos aus meiner Google-Tasks-Liste \"Connie Follow-ups\" (Tool tasks_open).",
  "Erwähne anstehende Geburtstage der nächsten Tage, falls welche dabei sind - die findest du im Feld birthdays von calendar_agenda.",
  "Schließe locker und freundlich ab.",
  "Ganz wichtig: Das wird laut vorgelesen. Sprich also in ganzen Sätzen als Fließtext, KEINE Aufzählungen, keine Listen, keine Nummerierung, keine Links, kein Markdown.",
  "Sag Uhrzeiten und Daten natürlich gesprochen, also \"um zehn Uhr\" statt \"10:00\" und \"am Mittwoch, dem vierten Juni\" statt einem Datum mit Bindestrichen.",
  "Halte es kurz, ungefähr dreißig bis sechzig Sekunden. Duze mich (kein Sie), nett und natürlich, ohne Floskeln.",
  "Wenn nichts ansteht - keine Termine, keine To-dos, keine Geburtstage - dann sag das kurz und positiv und erfinde nichts.",
].join(" ");

// True between 05:00 and 11:00 local time (the morning window in which the
// "Guten Morgen" button is shown). The "g" shortcut and pills work anytime.
function isMorningWindow() {
  const h = new Date().getHours();
  return h >= 5 && h < 11;
}

// ---------- Map a tool-call name to a friendly German activity label ----------
// Two label flavors:
//   - toolLabel(name)        - completed-action ("HubSpot abgefragt")  -> Activity log
//   - toolLabelActive(name)  - present-progressive ("Frage HubSpot ab") -> live status
//                              under the orb while the tool is running
function toolLabel(name) {
  const n = (name || "").replace(/^mcp__conventic__/, "");
  const rules = [
    [/^gmail_(send|draft|update_draft|delete_draft|reply|forward)/, "Mail vorbereitet"],
    [/^gmail_(search|latest|get|read|list|extract_attachment|download)/, "Postfach durchsucht"],
    [/^gmail/, "Gmail bearbeitet"],
    [/^calendar_(create|update|delete|move)/, "Termin eingetragen"],
    [/^calendar/, "Kalender gelesen"],
    [/^tasks_(create|update|delete|complete)/, "Aufgabe gespeichert"],
    [/^tasks/, "Aufgaben gelesen"],
    [/^hubspot_.*_(create|update|delete|associate|add_note)/, "HubSpot aktualisiert"],
    [/^hubspot/, "HubSpot abgefragt"],
    [/^kg_remember/, "Im Gedächtnis abgelegt"],
    [/^kg_(search|query|neighbors|diff|health|recent)/, "Gedächtnis durchsucht"],
    [/^kg_/, "Gedächtnis verwendet"],
    [/^drive/, "Drive durchsucht"],
    [/^web_search/, "Im Web gesucht"],
    [/^web_fetch/, "Webseite gelesen"],
    [/^web_/, "Web abgerufen"],
    [/^llm_/, "Modell befragt"],
    [/^telegram_send_voice/, "Sprachnachricht gesendet"],
    [/^telegram_send/, "Telegram-Nachricht gesendet"],
    [/^telegram/, "Telegram"],
    [/^workflow_/, "Workflow ausgeführt"],
    [/^market_/, "Börsenkurs geholt"],
    [/^location_/, "Standort gelesen"],
    [/^weather/, "Wetter abgefragt"],
  ];
  for (const [re, label] of rules) if (re.test(n)) return label;
  return n.replace(/_/g, " ");
}
function toolLabelActive(name) {
  const n = (name || "").replace(/^mcp__conventic__/, "");
  const rules = [
    [/^gmail_(send|draft|update_draft|reply|forward)/, "Schreibe die Mail"],
    [/^gmail_delete_draft/, "Lösche den Entwurf"],
    [/^gmail_(search|latest|get|read|list)/, "Frage den Mail-Server ab"],
    [/^gmail_(extract_attachment|download)/, "Lese den Mail-Anhang"],
    [/^gmail/, "Bearbeite Gmail"],
    [/^calendar_(create|update|move)/, "Trage den Termin ein"],
    [/^calendar_delete/, "Lösche den Termin"],
    [/^calendar/, "Schaue in den Kalender"],
    [/^tasks_(create|update|complete)/, "Speichere die Aufgabe"],
    [/^tasks_delete/, "Lösche die Aufgabe"],
    [/^tasks/, "Schaue in die Aufgaben"],
    [/^hubspot_.*_(create|update|associate|add_note)/, "Schreibe nach HubSpot"],
    [/^hubspot_.*_delete/, "Lösche in HubSpot"],
    [/^hubspot/, "Frage HubSpot ab"],
    [/^kg_remember/, "Lege es im Gedächtnis ab"],
    [/^kg_(search|query|neighbors|recent)/, "Durchsuche das Gedächtnis"],
    [/^kg_(health|diff)/, "Prüfe das Gedächtnis"],
    [/^kg_/, "Greife auf das Gedächtnis zu"],
    [/^drive/, "Durchsuche Drive"],
    [/^web_search/, "Suche im Web"],
    [/^web_fetch/, "Lese die Webseite"],
    [/^web_/, "Rufe Web-Daten ab"],
    [/^llm_/, "Frage das Modell"],
    [/^telegram_send_voice/, "Schicke Sprachnachricht"],
    [/^telegram_send/, "Schicke die Nachricht"],
    [/^telegram/, "Bearbeite Telegram"],
    [/^workflow_/, "Starte den Workflow"],
    [/^market_/, "Hole den Börsenkurs"],
    [/^location_/, "Lese den Standort"],
    [/^weather/, "Hole das Wetter"],
  ];
  for (const [re, label] of rules) if (re.test(n)) return label;
  return "Arbeite an " + n.replace(/_/g, " ");
}

// ---------- Helpers ----------
function nowTime() {
  return new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
function errorMessage(status) {
  if (status === "no-token")  return "Kein Web-Token gesetzt. Bitte config.js befüllen (Token vom Server).";
  if (status === 401)         return "Authentifizierung fehlgeschlagen. Token in config.js prüfen.";
  if (status === 503)         return "Web-Kanal am Server nicht aktiv (kein Token konfiguriert).";
  if (status === "network")   return "Connie ist gerade still (Netz oder langer Tool-Call). Bitte nochmal probieren.";
  return "Es gab ein Problem bei der Anfrage. Bitte noch einmal versuchen.";
}

// ---------- Clean Connie's reply for the speech synthesizer ----------
// Strips Markdown / list markers / numbering that the TTS would otherwise read
// out as "Stern", "Raute", "eins Punkt" etc., and removes URLs so the synth
// never spells out "h-t-t-p-s-colon-slash-slash ...". Conservative: only
// symbols, leading list/paragraph numbers and link tokens are touched - every
// real WORD is kept. The on-screen transcript keeps the original text
// (including the links); only the TTS payload is cleaned.
function cleanForSpeech(text) {
  if (!text) return "";
  let t = String(text);
  // Markdown links [label](url) -> label (do this before any URL stripping so
  // the human-readable label survives and only the bare url would be removed).
  t = t.replace(/\[([^\]]+)\]\((?:[^)]*)\)/g, "$1");
  // URLs -> a short speakable placeholder, so the synthesizer doesn't read out
  // the protocol/host/path character by character. Three shapes, widest first:
  //   1. http(s)://... and ftp://...  (scheme-qualified, run to whitespace)
  //   2. www.<domain>...              (no scheme but an unmistakable host)
  //   3. bare <domain.tld>/<path>     (host WITH a path, so a normal
  //      end-of-sentence "...im Graphen." is NOT mistaken for a domain)
  // E-mail addresses are intentionally left untouched (the URL is the focus;
  // a name@domain reads acceptably and a greedy rule would eat normal words).
  t = t.replace(/\b(?:https?|ftp):\/\/[^\s]+/gi, " (Link) ");
  t = t.replace(/\bwww\.[^\s]+/gi, " (Link) ");
  t = t.replace(
    /\b[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.[a-z]{2,}\/[^\s]*/gi,
    " (Link) ",
  );
  // Process line by line so leading markers/numbers anchor to ^.
  const lines = t.split(/\r?\n/).map((line) => {
    let l = line;
    // Leading heading hashes: "## Titel" -> "Titel".
    l = l.replace(/^\s{0,3}#{1,6}\s+/, "");
    // Leading blockquote markers.
    l = l.replace(/^\s{0,3}>\s?/, "");
    // Leading bullet markers: -, *, +, • , · , – followed by a space.
    l = l.replace(/^\s*[-*+•·–]\s+/, "");
    // Leading list/paragraph numbers: "1. ", "2) ", "10 - ".
    l = l.replace(/^\s*\d{1,3}[.)]\s+/, "");
    l = l.replace(/^\s*\d{1,3}\s+[-–]\s+/, "");
    return l;
  });
  t = lines.join("\n");
  // Inline emphasis / code markers (keep the inner words).
  t = t.replace(/\*\*([^*]+)\*\*/g, "$1");   // **bold**
  t = t.replace(/\*([^*]+)\*/g, "$1");        // *italic*
  // Underscore emphasis only at word boundaries, so snake_case identifiers
  // like open_deals / kg_remember keep their inner underscores.
  t = t.replace(/(^|[^\w])__([^_]+)__(?=[^\w]|$)/g, "$1$2");  // __bold__
  t = t.replace(/(^|[^\w])_([^_]+)_(?=[^\w]|$)/g, "$1$2");    // _italic_
  t = t.replace(/`{1,3}([^`]+)`{1,3}/g, "$1"); // `code` / ```code```
  // Any stray leftover Markdown symbols that survived. Underscores are left
  // intact on purpose so identifiers like kg_remember stay readable.
  t = t.replace(/[`*#]+/g, "");
  // Collapse whitespace into a speakable flow.
  t = t.replace(/[ \t]+/g, " ");
  t = t.replace(/\s*\n\s*/g, " ");
  t = t.replace(/\s{2,}/g, " ");
  return t.trim();
}

// ---------- Map the /web/context payload to the card props ----------
// Backend already shaped this; here we only do display polish (time fallback
// for all-day events, relative "last sync" label).
function ctxAppointments(today) {
  if (!Array.isArray(today)) return [];
  const tones = ["teal", "lime", "neutral"];
  return today.map((a, i) => ({
    time: a.all_day ? "ganztags" : (a.time || "--:--"),
    title: a.title || "(kein Titel)",
    sub: a.all_day ? "ganztaegig" : "",
    tone: tones[i % tones.length],
  }));
}
function relSync(iso) {
  if (!iso) return "offline";
  const then = new Date(iso).getTime();
  if (!then || Number.isNaN(then)) return "offline";
  const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
  if (secs < 60) return "gerade eben";
  const mins = Math.round(secs / 60);
  if (mins < 60) return `vor ${mins} Min`;
  const hrs = Math.round(mins / 60);
  return `vor ${hrs} Std`;
}

// ---------- Waveform bars (under speech) ----------
function Waveform({ active }) {
  const ref = aRef(null);
  aEffect(() => {
    if (!ref.current) return;
    const bars = Array.from(ref.current.querySelectorAll(".bar"));
    let raf = 0, t = 0;
    function tick() {
      t += 1;
      bars.forEach((b, i) => {
        const v = active
          ? 4 + Math.abs(Math.sin(t * 0.08 + i * 0.4) * Math.sin(t * 0.025 + i * 0.18)) * 22
          : 3;
        b.style.height = `${v}px`;
      });
      raf = requestAnimationFrame(tick);
    }
    const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (!reduced) raf = requestAnimationFrame(tick);
    else bars.forEach((b) => (b.style.height = "3px"));
    return () => cancelAnimationFrame(raf);
  }, [active]);
  return (
    <div className="wave" data-active={active ? "true" : "false"} ref={ref}>
      {Array.from({ length: 36 }).map((_, i) => <span className="bar" key={i} />)}
    </div>
  );
}

// ---------- Clock ----------
function Clock() {
  const [t, setT] = aState(new Date());
  aEffect(() => {
    const id = setInterval(() => setT(new Date()), 30000);
    return () => clearInterval(id);
  }, []);
  const date = t.toLocaleDateString("de-DE", { day: "2-digit", month: "short" });
  const time = t.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
  return (
    <span className="clock"><span className="date">{date}</span><span>{time}</span></span>
  );
}

const STATUS_LABELS = {
  ready:     "Bereit",
  listening: "Ich höre zu",
  thinking:  "Denke nach",
  speaking:  "Spreche",
  error:     "Hinweis",
};

// ---------- Binaural ambient ----------
// Two oscillators panned hard L/R produce a binaural beat (200 + 207 Hz ->
// 7 Hz alpha-ish beat). Lives idle until the user clicks the toggle (browser
// autoplay policy requires a user gesture). When Connie is speaking the gain
// ducks to 0 to avoid masking; restores on release.
const BINAURAL_KEY = "connie.binaural.enabled";
const BINAURAL_FREQ_L = 200;
const BINAURAL_FREQ_R = 207;
const BINAURAL_TARGET_GAIN = 0.035;

class BinauralPlayer {
  constructor() {
    this.ctx = null;
    this.gain = null;
    this.oscL = null;
    this.oscR = null;
    this._ducked = false;
  }
  start() {
    if (this.ctx) return;
    const C = window.AudioContext || window.webkitAudioContext;
    if (!C) return;
    this.ctx = new C();
    const merger = this.ctx.createChannelMerger(2);
    this.oscL = this.ctx.createOscillator();
    this.oscL.frequency.value = BINAURAL_FREQ_L;
    this.oscL.type = "sine";
    this.oscL.connect(merger, 0, 0);
    this.oscR = this.ctx.createOscillator();
    this.oscR.frequency.value = BINAURAL_FREQ_R;
    this.oscR.type = "sine";
    this.oscR.connect(merger, 0, 1);
    this.gain = this.ctx.createGain();
    this.gain.gain.value = 0;
    merger.connect(this.gain).connect(this.ctx.destination);
    this.oscL.start();
    this.oscR.start();
    this.gain.gain.linearRampToValueAtTime(BINAURAL_TARGET_GAIN, this.ctx.currentTime + 1.5);
  }
  stop() {
    if (!this.ctx) return;
    const ctx = this.ctx;
    const g = this.gain;
    const oL = this.oscL;
    const oR = this.oscR;
    this.ctx = null; this.gain = null; this.oscL = null; this.oscR = null; this._ducked = false;
    try { g.gain.cancelScheduledValues(ctx.currentTime); } catch (_) {}
    try { g.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.4); } catch (_) {}
    setTimeout(() => {
      try { oL.stop(); } catch (_) {}
      try { oR.stop(); } catch (_) {}
      try { ctx.close(); } catch (_) {}
    }, 500);
  }
  duck(yes) {
    if (!this.ctx || !this.gain) return;
    if (yes === this._ducked) return;
    this._ducked = yes;
    const target = yes ? 0 : BINAURAL_TARGET_GAIN;
    try {
      this.gain.gain.cancelScheduledValues(this.ctx.currentTime);
      this.gain.gain.linearRampToValueAtTime(target, this.ctx.currentTime + 0.25);
    } catch (_) {}
  }
  isRunning() { return !!this.ctx; }
}

// ---------- App ----------
function App() {
  const [state, setState] = aState("ready"); // ready | listening | thinking | speaking | error
  // Auth bootstrap (WB-Auth Phase 1 dual-accept, 2026-06-04). The frontend
  // calls /web/whoami on mount to learn who is logged in and via what
  // method (cookie or bearer). 401 means neither identity source resolved
  // -> redirect to /login. The whoami response also overrides
  // window.CONNIE_USER_NAME so a cookie-only deploy (no config.js) shows
  // the right name without a static config file.
  const [whoami, setWhoami] = aState(null); // null = pending, false = 401, {user,...} = ok
  aEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        // We omit the Authorization header when the static bearer is empty;
        // the HttpOnly cookie travels automatically. When the bearer IS
        // set (legacy config.js path), we still send it for the dual-accept
        // window so existing deploys keep working.
        const headers = WEB_TOKEN ? { Authorization: `Bearer ${WEB_TOKEN}` } : {};
        const resp = await fetch(`${API_BASE}/web/whoami`, { headers, credentials: "include" });
        if (cancelled) return;
        if (resp.status === 401) {
          // Send the operator to the login page UNLESS they are already there
          // (defence vs accidental redirect-loops if /login itself fails).
          if (!window.location.pathname.startsWith("/login")) {
            window.location.assign("/login");
            return;
          }
          setWhoami(false);
          return;
        }
        if (!resp.ok) { setWhoami(false); return; }
        const data = await resp.json();
        setWhoami(data || false);
      } catch (_) {
        if (!cancelled) setWhoami(false);
      }
    })();
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Per-user greeting: prefer the whoami response (cookie-driven, dynamic);
  // fall back to config.js' static CONNIE_USER_NAME for the legacy bearer
  // path; fall back to a name-less Anrede so a missing value never miscalls
  // anyone.
  const USER_NAME = ((whoami && whoami.user) || window.CONNIE_USER_NAME || "").trim();
  const ANREDE = USER_NAME
    ? USER_NAME.charAt(0).toUpperCase() + USER_NAME.slice(1)
    : "dir";
  const canLogout = !!(whoami && whoami.can_logout);

  async function doLogout() {
    if (!canLogout) return;
    try {
      await fetch(`${API_BASE}/web/auth/logout`, { method: "POST", credentials: "include" });
    } catch (_) { /* server-side cleanup is the security-relevant part; ignore network */ }
    window.location.assign("/login");
  }

  // Local chat-history persistence (2026-06-02). Restores the visible
  // transcript across reloads / tab-closes / hard-resets so Bernd does not
  // lose the last conversation. Stored per-user (so Bernd and Sabine on the
  // same machine don't leak into each other). Server-side, connie-app keeps
  // a parallel 5+5 turn tail that re-seeds the SDK client after a container
  // restart - so even a 3am restart preserves the freshest context.
  const HISTORY_KEY = `connie.chat.history.${(USER_NAME || "default").toLowerCase()}`;
  const HISTORY_MAX = 200;
  function loadHistory() {
    try {
      const raw = localStorage.getItem(HISTORY_KEY);
      if (!raw) return null;
      const doc = JSON.parse(raw);
      if (!doc || !Array.isArray(doc.messages) || doc.messages.length === 0) return null;
      return doc.messages.slice(-HISTORY_MAX);
    } catch (_) { return null; }
  }
  function persistHistory(msgs) {
    try {
      const tail = (msgs || []).slice(-HISTORY_MAX);
      localStorage.setItem(HISTORY_KEY, JSON.stringify({
        v: 1, messages: tail, updated_at: new Date().toISOString(),
      }));
    } catch (_) { /* quota exceeded / disabled */ }
  }

  // Initial greeting. In the single-URL cookie mode WEB_TOKEN is empty by
  // design; whoami fills the name shortly after mount. We greet generically
  // until whoami arrives and an effect below refreshes the line with the
  // resolved alias.
  const [spoken, setSpoken] = aState(
    "Guten Tag. Drücken Sie auf das Mikrofon und sprechen Sie mit mir."
  );
  // Refresh greeting once whoami resolves so the dashboard shows the actual
  // logged-in user.
  aEffect(() => {
    if (whoami && whoami.user) {
      const name = whoami.user.charAt(0).toUpperCase() + whoami.user.slice(1);
      setSpoken(`Guten Tag, ${name}. Drücken Sie auf das Mikrofon und sprechen Sie mit mir.`);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [whoami]);
  const [showCaret, setShowCaret] = aState(false);
  // Live tool-status under the orb ("Frage den Mail-Server ab"). Cleared when
  // the tool ends or the turn finishes; surfaces what Connie is doing during
  // the inevitable Sonnet-tool-call wait so the orb is not silent for 5-15 s.
  const [currentTool, setCurrentTool] = aState(null);
  // Server-reported count of turns the agent still remembers (set by the
  // /web/turn end-event). If localStorage shows more than the server knows
  // we surface a drift-banner so Bernd knows Connie has forgotten the start.
  const [serverTurnCount, setServerTurnCount] = aState(null);
  const [restoredFromStorage, setRestoredFromStorage] = aState(false);
  const [driftBannerDismissed, setDriftBannerDismissed] = aState(false);
  const [messages, setMessages] = aState(() => {
    const restored = loadHistory();
    if (restored) return restored;
    return [{ who: "connie", text: `Guten Tag, ${ANREDE}. Womit kann ich helfen?` }];
  });
  aEffect(() => { persistHistory(messages); }, [messages]);
  aEffect(() => {
    // One-shot: mark "this session was loaded from storage" so the drift
    // banner can decide whether the server tail is shorter than expected.
    if (loadHistory() !== null) setRestoredFromStorage(true);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  function clearChatHistory() {
    try { localStorage.removeItem(HISTORY_KEY); } catch (_) {}
    setMessages([{ who: "connie", text: `Guten Tag, ${ANREDE}. Womit kann ich helfen?` }]);
    setRestoredFromStorage(false);
    setDriftBannerDismissed(false);
  }

  // Drift banner: UI was restored, server now reports a small tail (=>
  // container restarted), and the UI knows more assistant turns than the
  // server's tail. Auto-hides once the user dismissed it or asked enough
  // turns that the server tail catches up.
  const uiAssistantCount = messages.filter(m => m && m.who === "connie").length;
  const showDriftBanner = (
    restoredFromStorage
    && !driftBannerDismissed
    && serverTurnCount !== null
    && serverTurnCount < uiAssistantCount
    && uiAssistantCount > 1
  );
  const [activity, setActivity] = aState([
    { id: "init-1", text: "Frontend bereit", sub: `Backend ${API_BASE}`, status: "done", time: nowTime() },
  ]);
  const [input, setInput] = aState("");

  // Live context cards (left column). null = not loaded yet -> cards show "lade ...".
  const [today, setToday] = aState(null);
  const [pipeline, setPipeline] = aState(null);
  const [memory, setMemory] = aState(null);

  // Interactive graph (Gedaechtnis card). graphFocus is the opaque
  // "<label>:<key>:<value>" string; graph holds the projected subgraph.
  const [graphFocus, setGraphFocus] = aState(null);
  const [graph, setGraph] = aState(null);

  // Working-memory card (F4). null = not loaded yet -> card shows "lade ...".
  // Backend returns peer-MASKED items; we never see full PII here.
  const [recent, setRecent] = aState(null);

  // Workflow card (B4b). null = not loaded yet -> card shows "lade ...". The
  // backend `available` flag toggles the unreachable placeholder; runs are
  // polled tightly (~10s) so awaiting-approval items surface quickly.
  const [workflows, setWorkflows] = aState(null);
  const [runs, setRuns] = aState(null);
  const [workflowsAvail, setWorkflowsAvail] = aState(null);  // null until first fetch
  const [runsAvail, setRunsAvail] = aState(null);

  // ----- dashboard-v3: layout + settings -----
  const [layout, setLayout] = aState(() =>
    LAYOUT ? LAYOUT.loadLayout() : {
      order: [], columns: {}, hidden: [], sizes: {},
      textSize: "M", voiceId: null, language: null,
    }
  );
  const [settingsOpen, setSettingsOpen] = aState(false);
  const [helpOpen, setHelpOpen] = aState(false);  // topbar menu -> shortcuts/help overlay
  // Binaural ambient: toggle persists, the player only runs after a click
  // (browser autoplay policy). Starts muted-by-default on first ever load.
  const [binauralOn, setBinauralOn] = aState(() => {
    try { return localStorage.getItem(BINAURAL_KEY) === "1"; } catch (_) { return false; }
  });
  const binauralRef = aRef(null);

  // ----- Custom quick-action buttons (2026-06-02) -----
  // Per-user persistent extra pills next to the hard-coded ones. Stored in
  // localStorage so they survive reloads without a backend round-trip; if we
  // ever go cross-device we can sync this same shape to /web/quick-prompts.
  const CUSTOM_QUICKS_KEY = "connie.custom_quicks_v1";
  const [customQuicks, setCustomQuicks] = aState(() => {
    try {
      const raw = window.localStorage.getItem(CUSTOM_QUICKS_KEY);
      const parsed = raw ? JSON.parse(raw) : [];
      return Array.isArray(parsed) ? parsed : [];
    } catch (_) { return []; }
  });
  aEffect(() => {
    try { window.localStorage.setItem(CUSTOM_QUICKS_KEY, JSON.stringify(customQuicks)); }
    catch (_) {}
  }, [customQuicks]);
  // Modal state: null = closed, {} = new entry, {id, label, ...} = edit existing
  const [customQuickModal, setCustomQuickModal] = aState(null);
  function openNewCustomQuick() { setCustomQuickModal({}); }
  function openEditCustomQuick(q) { setCustomQuickModal({ ...q }); }
  function closeCustomQuickModal() { setCustomQuickModal(null); }
  function saveCustomQuick(entry) {
    if (!entry || !entry.label || !entry.prompt) return;
    const normalized = {
      label: String(entry.label).slice(0, 32),
      prompt: String(entry.prompt).slice(0, 4000),
      icon: entry.icon || "sparkles",
    };
    if (entry.id) {
      setCustomQuicks((arr) => arr.map((q) => q.id === entry.id ? { ...q, ...normalized } : q));
    } else {
      setCustomQuicks((arr) => [...arr, {
        id: `cq-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
        ...normalized,
      }]);
    }
    closeCustomQuickModal();
  }
  function removeCustomQuick(id) {
    if (!id) return;
    if (!window.confirm("Diese Schnellaktion entfernen?")) return;
    setCustomQuicks((arr) => arr.filter((q) => q.id !== id));
  }
  // Morning-window flag (drives the "Guten Morgen" button visibility). Re-checked
  // on a slow interval so the button appears/vanishes around the 05:00/11:00 edges
  // without a reload.
  const [morningWindow, setMorningWindow] = aState(isMorningWindow());
  const [voices, setVoices] = aState(null);        // null = not loaded yet
  const [voicesLoading, setVoicesLoading] = aState(false);
  const [previewVoiceId, setPreviewVoiceId] = aState(null); // voice currently pre-listening
  // Calendar-visibility admin (per-user-visibility UI). null = not loaded yet.
  const [agenda, setAgenda] = aState(null);
  const [agendaLoading, setAgendaLoading] = aState(false);
  const [agendaAvailable, setAgendaAvailable] = aState(true);

  // Billomat tile (2026-06-02). null = not loaded yet, tile shows "lade ...".
  // `available: false` after any backend failure -> tile shows "kein Zugriff".
  const [billomatTile, setBillomatTile] = aState(null);
  // Approvals card (WB-2, 2026-06-03). Per-caller-filtered list of pending +
  // recent decisions. null = loading; {available:false} after backend issue.
  const [approvalsData, setApprovalsData] = aState(null);
  // Period filter for the Billomat tile (Alle / Jahr / Monat). Persisted per
  // user so a reload keeps the choice; refetches on change.
  const BILLOMAT_PERIOD_KEY = `connie.billomat_period.${(USER_NAME || "default").toLowerCase()}`;
  const [billomatPeriod, setBillomatPeriod] = aState(() => {
    try {
      const stored = localStorage.getItem(BILLOMAT_PERIOD_KEY);
      return (stored === "month" || stored === "year" || stored === "all") ? stored : "all";
    } catch (_) { return "all"; }
  });
  aEffect(() => {
    try { localStorage.setItem(BILLOMAT_PERIOD_KEY, billomatPeriod); } catch (_) {}
  }, [billomatPeriod]);

  // Aktien-Portfolio (per-user). `portfolioTile` = the computed tile for the
  // card (GET /web/portfolio: {available, per_stock, totals, disclaimer}). The
  // settings editor seeds its {wkn, qty} rows from the tile's per_stock list.
  // null = not loaded yet.
  const [portfolioTile, setPortfolioTile] = aState(null);
  const [portfolioLoading, setPortfolioLoading] = aState(false);

  // Standort (per-user). `location` = the saved location ({label, lat, lon, ...})
  // or {source:"none"} when nothing is saved. The settings section seeds its
  // input from `location.label`. null = not loaded yet. (The WeatherCard fetches
  // /web/location itself; this state drives only the settings editor.)
  const [location, setLocation] = aState(null);
  const [locationLoading, setLocationLoading] = aState(false);

  // Wissen (Knowledge-Input, S4). `knowledge` = the shared fact list from
  // GET /web/knowledge ({ok, facts:[{id, title, topic, body, created_by,
  // created_at}]}). null = not loaded yet. The settings "Wissen" section is
  // the sole consumer; the facts are GLOBAL (Connie uses them for all users).
  const [knowledge, setKnowledge] = aState(null);
  const [knowledgeLoading, setKnowledgeLoading] = aState(false);

  // ----- E-S6 voice-mode toggle + realtime session state -----
  // ``voiceMode`` is the user-chosen provider for the NEXT session. We
  // never hot-switch the running session; that would orphan an in-flight
  // WebRTC peer connection mid-turn. Persisted in localStorage so the
  // preference survives reloads (no server round-trip).
  const VOICE_MODE_KEY = "connie.voice_mode";
  // Default = realtime (Bernd 2026-06-01): cascade is the fallback, not
  // the start state. Version marker forces a one-time reset to the new
  // default when the code default changes - otherwise users with an old
  // "cascade" cached in localStorage (set either manually or by an earlier
  // failover) never see the new default. After the reset, the user's
  // Settings-drawer choice persists normally.
  const VOICE_DEFAULT_VERSION_KEY = "connie.voice_default_version";
  const CURRENT_DEFAULT_VERSION = "realtime-2026-06-01-noflip";
  const [voiceMode, setVoiceMode] = aState(() => {
    try {
      const lastVer = window.localStorage.getItem(VOICE_DEFAULT_VERSION_KEY);
      if (lastVer !== CURRENT_DEFAULT_VERSION) {
        window.localStorage.setItem(VOICE_MODE_KEY, "realtime");
        window.localStorage.setItem(VOICE_DEFAULT_VERSION_KEY, CURRENT_DEFAULT_VERSION);
        return "realtime";
      }
      const v = window.localStorage.getItem(VOICE_MODE_KEY);
      return (v === "realtime" || v === "cascade") ? v : "realtime";
    } catch (_) { return "realtime"; }
  });
  aEffect(() => {
    try { window.localStorage.setItem(VOICE_MODE_KEY, voiceMode); } catch (_) {}
  }, [voiceMode]);

  // Active realtime session controller (from voice-realtime.js). null when
  // no realtime session is open. The UI mode-indicator (red/green mic
  // button, SR-RTN-12) keys on this + provider_id to pick the colour.
  const [realtimeSession, setRealtimeSession] = aState(null);
  const realtimeSessionRef = aRef(null);
  aEffect(() => { realtimeSessionRef.current = realtimeSession; }, [realtimeSession]);

  // Toast banner for auto-failover hints + multi-browser-kick + session-
  // ended notices. ``{text, level}`` or null; auto-clears after 6s. We do
  // NOT use alert() so the user gesture chain isn't broken.
  const [toast, setToast] = aState(null);
  function showToast(text, level) {
    setToast({ text, level: level || "info" });
    setTimeout(() => setToast(null), 6_000);
  }

  // ----- dashboard-v3.1: drag-and-drop state -----
  // `drag` is null when idle; during a drag it holds the source card-id, the
  // current pointer position and the computed drop target {col, beforeId}. The
  // pointer move/up handlers live in an effect keyed on drag != null.
  const [drag, setDrag] = aState(null);
  const dragRef = aRef(null);  // mirror of `drag` for the window listeners

  // Persist + apply layout whenever it changes (single localStorage object).
  aEffect(() => { if (LAYOUT) LAYOUT.saveLayout(layout); }, [layout]);

  // Apply the chosen text size to the speech line via --speech-size (#10/#7).
  aEffect(() => {
    const css = (LAYOUT && LAYOUT.TEXT_SIZE_CSS[layout.textSize])
      || "clamp(20px, 2.0vw, 25px)";
    document.documentElement.style.setProperty("--speech-size", css);
  }, [layout.textSize]);

  // Layout mutators (each persists via the effect above).
  function updateLayout(patch) { setLayout((l) => ({ ...l, ...patch })); }
  function hideCard(id) {
    setLayout((l) => l.hidden.includes(id) ? l : { ...l, hidden: [...l.hidden, id] });
  }
  function unhideCard(id) {
    setLayout((l) => ({ ...l, hidden: l.hidden.filter((x) => x !== id) }));
  }
  // Set a card's discrete size step (S/M/L) - visibly changes the tile height.
  function setCardSize(id, step) {
    setLayout((l) => ({ ...l, sizes: LAYOUT.setCardSize(l, id, step) }));
  }
  // Collapse/expand a whole column (S3). Folds the column to a thin strip.
  function toggleColumn(col) {
    setLayout((l) => ({ ...l, collapsed: LAYOUT.toggleCollapsed(l, col) }));
  }

  // ----- dashboard-v3.1: hand-rolled pointer drag-and-drop ------------------
  // The drag handle (in CardShell) calls onDragHandleDown; from there the App
  // owns everything: pointer capture, hit-testing the card under the cursor to
  // derive a drop target {col, beforeId}, a visible drop-indicator line, and
  // committing the new {order, columns} to the layout on pointerup. No library
  // (SR-VM-10), pointer events so touch + mouse both work.
  const DRAG_THRESHOLD_PX = 4;  // ignore micro-jitter before treating it as a drag

  // Map a clientX/clientY to a drop target. We look at every rendered card
  // wrapper (data-card-id/data-col) and the two column containers (data-colzone)
  // to decide which column the cursor is over and before which card to insert.
  function computeDropTarget(clientX, clientY, draggedId) {
    const shells = Array.from(document.querySelectorAll(".card-shell[data-card-id]"));
    // Which column is the cursor horizontally over? Prefer the column zone whose
    // horizontal band contains clientX; fall back to the nearest by center-x.
    const zones = Array.from(document.querySelectorAll("[data-colzone]"));
    let col = null;
    let bestDx = Infinity;
    for (const z of zones) {
      const r = z.getBoundingClientRect();
      if (clientX >= r.left && clientX <= r.right) { col = z.getAttribute("data-colzone"); break; }
      const cx = (r.left + r.right) / 2;
      const dx = Math.abs(clientX - cx);
      if (dx < bestDx) { bestDx = dx; col = z.getAttribute("data-colzone"); }
    }
    if (!col) col = "left";

    // Among the cards in that column (excluding the dragged one), find the first
    // whose vertical midpoint is below the cursor -> insert before it; else null
    // (append at the end of the column).
    const inCol = shells
      .filter((s) => s.getAttribute("data-col") === col)
      .filter((s) => s.getAttribute("data-card-id") !== draggedId);
    let beforeId = null;
    for (const s of inCol) {
      const r = s.getBoundingClientRect();
      const mid = r.top + r.height / 2;
      if (clientY < mid) { beforeId = s.getAttribute("data-card-id"); break; }
    }
    return { col, beforeId };
  }

  function onDragHandleDown(cardId, e) {
    // Left button / primary pointer only; don't start on a modifier-click.
    if (e.button != null && e.button !== 0) return;
    e.preventDefault();
    const startX = e.clientX;
    const startY = e.clientY;
    const initial = {
      id: cardId,
      x: startX, y: startY,
      target: { col: NATURAL_COLS[cardId] || "left", beforeId: null },
      active: false,            // becomes true once past the threshold
      pointerId: e.pointerId,
    };
    dragRef.current = initial;
    setDrag(initial);
    // Prevent text selection while dragging.
    document.body.classList.add("is-dragging");
  }

  // Global pointer listeners live only while a drag is in progress.
  aEffect(() => {
    if (!drag) return;

    function onMove(ev) {
      const cur = dragRef.current;
      if (!cur) return;
      const dx = ev.clientX - cur.x;
      const dy = ev.clientY - cur.y;
      const active = cur.active || Math.hypot(dx, dy) > DRAG_THRESHOLD_PX;
      const target = active
        ? computeDropTarget(ev.clientX, ev.clientY, cur.id)
        : cur.target;
      const next = { ...cur, x: ev.clientX, y: ev.clientY, active, target };
      dragRef.current = next;
      setDrag(next);
    }
    function onUp() {
      const cur = dragRef.current;
      dragRef.current = null;
      document.body.classList.remove("is-dragging");
      setDrag(null);
      if (!cur || !cur.active) return;  // a plain click on the handle -> no-op
      const { id, target } = cur;
      setLayout((l) => {
        const next = { ...l, ...LAYOUT.computeDrop(NATURAL_COLS, l, id, target.col, target.beforeId) };
        // F4: dropping into a collapsed column auto-expands it so the card shows.
        if (LAYOUT.isCollapsed(l, target.col)) next.collapsed = LAYOUT.toggleCollapsed(l, target.col);
        return next;
      });
    }
    function onCancel() {
      dragRef.current = null;
      document.body.classList.remove("is-dragging");
      setDrag(null);
    }

    window.addEventListener("pointermove", onMove, { passive: true });
    window.addEventListener("pointerup", onUp);
    window.addEventListener("pointercancel", onCancel);
    return () => {
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("pointercancel", onCancel);
    };
  }, [drag != null]);

  const busyRef = aRef(false);         // a turn is in flight
  const audioRef = aRef(null);         // current <audio> playback
  const audioPlayingRef = aRef(false); // echo-guard: true while Connie speaks
  // 2026-06-02 barge-in fix: hold the AbortController of the in-flight
  // /web/turn SSE so a mic-click during Connie speaking can both stop the
  // audio AND abort the backend stream (otherwise tokens kept arriving and
  // speak() restarted the audio after stopAudio returned).
  const turnAbortRef = aRef(null);
  const previewAudioRef = aRef(null);  // settings voice-preview <audio> (separate)
  const previewReqRef = aRef(0);       // monotonic token: guards async TTS-fallback races
  const recogRef = aRef(null);         // active SpeechRecognition
  const typeTimerRef = aRef([]);
  const speechRef = aRef(null);        // the scrollable speech line (#10)

  function clearTypeTimers() {
    typeTimerRef.current.forEach(clearTimeout);
    typeTimerRef.current = [];
  }
  aEffect(() => () => { clearTypeTimers(); stopAudio(); stopPreview(); stopListening(); }, []);

  // Stop any voice preview when the settings drawer closes (no stray audio).
  aEffect(() => { if (!settingsOpen) stopPreview(); }, [settingsOpen]);

  // ----- Binaural ambient: lifecycle + auto-duck on Connie's voice -----
  aEffect(() => {
    if (binauralOn) {
      if (!binauralRef.current) binauralRef.current = new BinauralPlayer();
      binauralRef.current.start();
    } else if (binauralRef.current) {
      binauralRef.current.stop();
      binauralRef.current = null;
    }
    return () => { /* component teardown handled by the unmount effect */ };
  }, [binauralOn]);

  // Duck the ambient when Connie speaks (TTS or live), restore on release.
  // Polling at 200ms is enough; the gain ramp absorbs any jitter.
  aEffect(() => {
    if (!binauralOn) return;
    const t = setInterval(() => {
      const p = binauralRef.current;
      if (!p || !p.isRunning()) return;
      const speaking = audioPlayingRef.current || state === "speaking";
      p.duck(speaking);
    }, 200);
    return () => clearInterval(t);
  }, [binauralOn, state]);

  // Tear down the ambient on full app unmount (avoid leaked AudioContext).
  aEffect(() => () => {
    if (binauralRef.current) {
      try { binauralRef.current.stop(); } catch (_) {}
      binauralRef.current = null;
    }
  }, []);

  function toggleBinaural() {
    setBinauralOn(prev => {
      const next = !prev;
      try { localStorage.setItem(BINAURAL_KEY, next ? "1" : "0"); } catch (_) {}
      return next;
    });
  }

  // ----- Keyboard push-to-talk (Space) -----------------------------------
  // Hold Space to talk: keydown starts listening, keyup stops (the recognizer's
  // onend then sends the captured text - same path as releasing the mic button).
  // We deliberately do NOT swallow Space while the user is typing in a field
  // (input / textarea / contenteditable) so normal text entry and scrolling
  // keep working; auto-repeat keydowns are ignored. The shortcut funnels through
  // the very same startListening/stopListening the mic button uses, so the echo-
  // guard (no listening while Connie speaks / a turn runs) still applies.
  const sttPttActiveRef = aRef(false);  // true between our Space-down and -up
  aEffect(() => {
    function isTypingTarget(el) {
      if (!el) return false;
      const tag = (el.tagName || "").toLowerCase();
      return tag === "input" || tag === "textarea" || el.isContentEditable === true;
    }
    function onKeyDown(e) {
      if (e.code !== "Space" && e.key !== " ") return;
      if (e.repeat) return;                       // ignore auto-repeat
      if (isTypingTarget(e.target)) return;       // don't hijack text entry
      if (settingsOpen) return;                   // settings drawer owns focus
      e.preventDefault();                         // we take the key -> no scroll
      // Toggle-stop while already listening (e.g. after a button-start), else
      // begin a push-to-talk capture.
      if (state === "listening") { stopListening(); return; }
      if (busyRef.current || audioPlayingRef.current) return;  // echo-guard
      sttPttActiveRef.current = true;
      startListening();
    }
    function onKeyUp(e) {
      if (e.code !== "Space" && e.key !== " ") return;
      if (!sttPttActiveRef.current) return;       // not our press -> ignore
      sttPttActiveRef.current = false;
      e.preventDefault();
      stopListening();                            // onend sends the captured text
    }
    window.addEventListener("keydown", onKeyDown);
    window.addEventListener("keyup", onKeyUp);
    return () => {
      window.removeEventListener("keydown", onKeyDown);
      window.removeEventListener("keyup", onKeyUp);
    };
  }, [state, settingsOpen]);

  // ----- Morning-window tick (button show/hide around 05:00 / 11:00) ----------
  aEffect(() => {
    const id = setInterval(() => setMorningWindow(isMorningWindow()), 60 * 1000);
    return () => clearInterval(id);
  }, []);

  // ----- Keyboard shortcut "g" -> spoken morning briefing --------------------
  // A single key press of "g" (Guten Morgen) fires the morning briefing. This is
  // its OWN effect with its own cleanup so it never interferes with the Space
  // push-to-talk effect above. The key press doubles as the user gesture the
  // browser needs to allow audio autoplay. We do not hijack "g" while typing in
  // an input/textarea/contenteditable, while the settings drawer is open, or
  // while a turn is already in flight (busyRef). Auto-repeat is ignored.
  aEffect(() => {
    function isTypingTarget(el) {
      if (!el) return false;
      const tag = (el.tagName || "").toLowerCase();
      return tag === "input" || tag === "textarea" || el.isContentEditable === true;
    }
    function onKeyDown(e) {
      if (e.key !== "g" && e.key !== "G") return;
      if (e.repeat) return;                       // ignore auto-repeat
      if (e.metaKey || e.ctrlKey || e.altKey) return;  // leave browser combos alone
      if (isTypingTarget(e.target)) return;       // don't hijack text entry
      if (settingsOpen) return;                   // settings drawer owns focus
      if (busyRef.current) return;                // a turn is already running
      e.preventDefault();
      handleTurn(MORNING_BRIEFING_PROMPT);        // the press is the autoplay gesture
    }
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [settingsOpen]);

  // ----- Live context cards (GET /web/context) -----
  async function loadContext() {
    try {
      const resp = await fetch(`${API_BASE}/web/context`, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) return;  // keep last data / placeholders; cards self-degrade
      const data = await resp.json();
      // Backend degrades single cards to null + an `errors` map; never throws.
      setToday(Array.isArray(data.today) ? data.today : []);
      setPipeline(data.pipeline || null);
      setMemory(data.memory || null);
    } catch (_) {
      // network/tunnel down: leave the cards on their last value / placeholder.
    }
  }
  aEffect(() => {
    loadContext();                                  // on mount
    const id = setInterval(loadContext, 5 * 60 * 1000); // every ~5 min
    return () => clearInterval(id);
  }, []);

  // ----- Aktien-Portfolio tile (GET /web/portfolio) -----
  // Polled ~5 min (prices come from the 6h cron cache, so a tighter poll buys
  // nothing). The settings editor reuses this same tile (per_stock -> rows).
  aEffect(() => {
    loadPortfolio();                                // on mount
    const id = setInterval(loadPortfolio, 5 * 60 * 1000); // every ~5 min
    return () => clearInterval(id);
  }, []);

  // ----- Billomat tile (GET /web/billomat-summary) -----
  // Polled ~5 min. Backend has its own 60s per-(caller, period) cache, so this
  // is mostly a safety net for tab-focus refresh. ``period`` is the active
  // filter pill; on a switch we set the tile to null so the UI shows "lade..."
  // until the new payload arrives.
  async function loadBillomat(period) {
    const p = period === "month" || period === "year" ? period : "all";
    try {
      const resp = await fetch(`${API_BASE}/web/billomat-summary?period=${p}`, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) { setBillomatTile({ available: false }); return; }
      const data = await resp.json();
      setBillomatTile(data && typeof data === "object" ? data : { available: false });
    } catch (_) {
      setBillomatTile({ available: false });
    }
  }
  aEffect(() => {
    loadBillomat(billomatPeriod);
    const id = setInterval(() => loadBillomat(billomatPeriod), 5 * 60 * 1000);
    return () => clearInterval(id);
  }, [billomatPeriod]);
  function onBillomatPeriodChange(next) {
    if (next === billomatPeriod) return;
    setBillomatTile(null);          // show "lade ..." while the new view fetches
    setBillomatPeriod(next);        // effect above re-fires the GET
  }

  // ----- Approvals card (WB-2, GET /web/approvals) -----
  // Polled tightly because the systemd-path-watcher fires the workflow-runner
  // tick within ~1s of an approve, so the UI must update fast enough to feel
  // live. 10s default plus a kick-fetch right after a decide.
  async function loadApprovals() {
    try {
      const resp = await fetch(`${API_BASE}/web/approvals`, {
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) { setApprovalsData({ available: false }); return; }
      const data = await resp.json();
      setApprovalsData(data && typeof data === "object" ? data : { available: false });
    } catch (_) {
      setApprovalsData({ available: false });
    }
  }
  aEffect(() => {
    loadApprovals();
    const id = setInterval(loadApprovals, 10 * 1000);
    return () => clearInterval(id);
  }, []);
  async function onApprovalDecide(queueId, decision) {
    try {
      const resp = await fetch(`${API_BASE}/web/approvals/${encodeURIComponent(queueId)}/decide`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${WEB_TOKEN}`,
        },
        body: JSON.stringify({ decision }),
      });
      // Refresh regardless of outcome so the row disappears from "Zur Freigabe"
      // (the sidecar's decide is idempotent + atomic; a parallel race still
      // converges on the final state).
      loadApprovals();
      if (!resp.ok) {
        const errText = await resp.text().catch(() => "");
        setToast({ level: "warn", text: `Decide fehlgeschlagen: ${resp.status} ${errText.slice(0, 80)}` });
      }
    } catch (_) {
      setToast({ level: "warn", text: "Decide-Aufruf fehlgeschlagen (Netz?)" });
    }
  }

  // ----- Interactive graph (GET /web/graph) -----
  // Default focus: the top open deal from the pipeline card, keyed by name
  // (matches the backend's Deal:name:<value> allowlist). Set once we have it.
  aEffect(() => {
    if (graphFocus) return;                         // user/already focused
    const top = pipeline && Array.isArray(pipeline.top_deals) && pipeline.top_deals[0];
    if (top && top.name) setGraphFocus(`Deal:name:${top.name}`);
  }, [pipeline, graphFocus]);

  async function loadGraph(focus) {
    if (!WEB_TOKEN || !focus) return;
    try {
      const url = `${API_BASE}/web/graph?focus=${encodeURIComponent(focus)}&depth=1`;
      const resp = await fetch(url, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) return;                          // keep last graph; self-degrades
      const data = await resp.json();
      setGraph(data && Array.isArray(data.nodes) ? data : { nodes: [], edges: [] });
    } catch (_) {
      // network/tunnel down: keep the last graph / placeholder.
    }
  }
  aEffect(() => { if (graphFocus) loadGraph(graphFocus); }, [graphFocus]);

  // Click on a neighbour node -> re-centre the graph on it.
  function onFocusNode(focus) { if (focus) setGraphFocus(focus); }

  // ----- Working memory card (GET /web/recent) -----
  async function loadRecent() {
    try {
      const resp = await fetch(`${API_BASE}/web/recent`, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) return;  // keep last data / placeholder; self-degrades
      const data = await resp.json();
      setRecent(Array.isArray(data.items) ? data.items : []);
    } catch (_) {
      // network/tunnel down: leave the card on its last value / placeholder.
    }
  }
  aEffect(() => {
    loadRecent();                                   // on mount
    const id = setInterval(loadRecent, 60 * 1000);  // every ~60s
    return () => clearInterval(id);
  }, []);

  // ----- Workflow card (GET /web/workflows + GET /web/runs) -----
  // Two independent fetches; each self-degrades to available:false on error so a
  // single broken endpoint can't white-screen the card. The catalog rarely
  // changes (slower poll); the runs drive the approval queue (tight ~10s poll).
  async function loadWorkflows() {
    try {
      const resp = await fetch(`${API_BASE}/web/workflows`, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) { setWorkflowsAvail(false); return; }
      const data = await resp.json();
      setWorkflows(Array.isArray(data.workflows) ? data.workflows : []);
      setWorkflowsAvail(data.available !== false);
    } catch (_) {
      setWorkflowsAvail(false);   // tunnel down: keep last list, mark unreachable
    }
  }
  async function loadRuns() {
    try {
      const resp = await fetch(`${API_BASE}/web/runs`, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) { setRunsAvail(false); return; }
      const data = await resp.json();
      setRuns(Array.isArray(data.runs) ? data.runs : []);
      setRunsAvail(data.available !== false);
    } catch (_) {
      setRunsAvail(false);
    }
  }
  aEffect(() => {
    loadWorkflows();                                       // on mount
    const id = setInterval(loadWorkflows, 60 * 1000);     // catalog: every ~60s
    return () => clearInterval(id);
  }, []);
  aEffect(() => {
    loadRuns();                                            // on mount
    const id = setInterval(loadRuns, 10 * 1000);          // runs: every ~10s
    return () => clearInterval(id);
  }, []);

  // Start a workflow. Returns {ok, request_id} on success or {ok:false, detail}
  // so the inline form can show the backend's error verbatim. Refreshes runs on
  // success so a newly created run appears without waiting for the poll.
  async function startWorkflow(name, inputs) {
    if (!WEB_TOKEN || !name) return { ok: false, detail: "Kein Web-Token gesetzt." };
    try {
      const resp = await fetch(`${API_BASE}/web/workflows/${encodeURIComponent(name)}/start`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${WEB_TOKEN}` },
        body: JSON.stringify({ inputs: inputs || {} }),
      });
      const data = await resp.json().catch(() => ({}));
      if (!resp.ok) {
        return { ok: false, detail: data.detail || `Fehler (HTTP ${resp.status}).` };
      }
      pushActivity({ text: "Workflow gestartet", sub: name });
      loadRuns();
      return { ok: true, request_id: data.request_id };
    } catch (_) {
      return { ok: false, detail: "Connie ist gerade nicht erreichbar." };
    }
  }

  // Approve / reject a run awaiting approval. Only ever sends "approve"/"reject".
  // Refreshes runs immediately so the queue reflects the decision at once.
  async function decideRun(runId, decision) {
    if (!WEB_TOKEN || !runId) return { ok: false };
    if (decision !== "approve" && decision !== "reject") return { ok: false };
    try {
      const resp = await fetch(`${API_BASE}/web/runs/${encodeURIComponent(runId)}/decision`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${WEB_TOKEN}` },
        body: JSON.stringify({ decision }),
      });
      const data = await resp.json().catch(() => ({}));
      pushActivity({
        text: decision === "approve" ? "Freigabe erteilt" : "Abgelehnt",
        sub: data.status || "",
      });
      loadRuns();
      return { ok: resp.ok, status: data.status };
    } catch (_) {
      loadRuns();
      return { ok: false };
    }
  }

  // Load a run's masked preview (approval.preview) for the collapsible row. The
  // backend already redacts it; we just surface the string (or null).
  async function loadRunPreview(runId) {
    if (!WEB_TOKEN || !runId) return null;
    try {
      const resp = await fetch(`${API_BASE}/web/runs/${encodeURIComponent(runId)}`, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) return null;
      const data = await resp.json();
      const run = data && data.run;
      const preview = run && run.approval && run.approval.preview;
      return typeof preview === "string" ? preview : (preview == null ? null : String(preview));
    } catch (_) {
      return null;
    }
  }

  // ----- Voices for the settings dropdown (GET /web/voices) -----
  // Lazy: only fetched the first time the settings panel opens (the dropdown is
  // the sole consumer). Cached on the backend; we keep the list in state.
  async function loadVoices() {
    setVoicesLoading(true);
    try {
      const resp = await fetch(`${API_BASE}/web/voices`, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (resp.ok) {
        const data = await resp.json();
        setVoices(Array.isArray(data.voices) ? data.voices : []);
      } else {
        setVoices([]);                              // self-degrades to "Standard"
      }
    } catch (_) {
      setVoices([]);                                // tunnel down: empty dropdown
    } finally {
      setVoicesLoading(false);
    }
  }
  aEffect(() => {
    if (settingsOpen && voices === null) loadVoices();
  }, [settingsOpen]);

  // ----- Calendar visibility (GET/POST /web/agenda-config) -----
  // Lazy: fetched the first time the settings panel opens (the section is the
  // sole consumer). Self-degrades to agendaAvailable=false on any error so the
  // section shows a hint instead of white-screening.
  async function loadAgendaConfig() {
    setAgendaLoading(true);
    try {
      const resp = await fetch(`${API_BASE}/web/agenda-config`, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) { setAgendaAvailable(false); setAgenda([]); return; }
      const data = await resp.json();
      setAgenda(Array.isArray(data.calendars) ? data.calendars : []);
      setAgendaAvailable(data.available !== false);
    } catch (_) {
      setAgendaAvailable(false);
      setAgenda([]);
    } finally {
      setAgendaLoading(false);
    }
  }

  // Save the edited visibility list. The UI sends only id/label/visibility per
  // calendar; connie-app fills `owner` server-side from the web-token owner, so
  // a client can never set a foreign owner. Returns {ok} or {ok:false, detail}.
  async function saveAgendaConfig(list) {
    try {
      const resp = await fetch(`${API_BASE}/web/agenda-config`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${WEB_TOKEN}` },
        body: JSON.stringify({ calendars: Array.isArray(list) ? list : [] }),
      });
      const data = await resp.json().catch(() => ({}));
      if (!resp.ok) {
        return { ok: false, detail: data.detail || `Fehler (HTTP ${resp.status}).` };
      }
      // Refresh so the section reflects the persisted state (incl. owner).
      loadAgendaConfig();
      return { ok: true, count: data.count };
    } catch (_) {
      return { ok: false, detail: "Connie ist gerade nicht erreichbar." };
    }
  }

  // ----- Aktien-Portfolio (GET/POST /web/portfolio) -----
  // The card + the settings editor share one tile fetch (the tile's per_stock
  // carries {wkn, qty}, which seeds the editor rows). Polled ~5 min for the card;
  // the settings section also lazy-loads it once on open. Self-degrades to an
  // unavailable tile on any error so neither the card nor the section breaks.
  async function loadPortfolio() {
    setPortfolioLoading(true);
    try {
      const resp = await fetch(`${API_BASE}/web/portfolio`, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) { setPortfolioTile({ available: false, per_stock: [] }); return; }
      const data = await resp.json();
      setPortfolioTile(data && typeof data === "object" ? data : { available: false, per_stock: [] });
    } catch (_) {
      setPortfolioTile({ available: false, per_stock: [] });
    } finally {
      setPortfolioLoading(false);
    }
  }

  // Save the edited holdings set. The UI sends only {wkn, qty} per position;
  // connie-app fills `owner` server-side from the web-token owner, so a client
  // can never write into a foreign portfolio. Returns {ok} or {ok:false, detail}.
  async function savePortfolio(holdings) {
    try {
      const resp = await fetch(`${API_BASE}/web/portfolio`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${WEB_TOKEN}` },
        body: JSON.stringify({ holdings: Array.isArray(holdings) ? holdings : [] }),
      });
      const data = await resp.json().catch(() => ({}));
      if (!resp.ok) {
        // FastAPI 422 returns `detail` as an ARRAY of {loc,msg,type}; never let a
        // non-string reach the UI (rendering an array/object as a React child
        // crashes the whole tree -> blank screen). Always coerce to a string.
        let detail = data && data.detail;
        if (Array.isArray(detail)) {
          detail = detail.map((e) => (e && typeof e.msg === "string") ? e.msg : "ungültige Eingabe").join("; ");
        } else if (detail && typeof detail !== "string") {
          detail = "ungültige Eingabe";
        }
        return { ok: false, detail: detail || `Fehler (HTTP ${resp.status}).` };
      }
      // Refresh so the card + editor reflect the persisted state.
      loadPortfolio();
      return { ok: true, count: data.count };
    } catch (_) {
      return { ok: false, detail: "Connie ist gerade nicht erreichbar." };
    }
  }

  // ----- Standort (GET/POST /web/location) -----
  // Lazy: fetched the first time the settings panel opens (the section is the
  // sole consumer here; the WeatherCard fetches /web/location itself on mount).
  // Self-degrades to {source:"none"} on any error so the section still seeds an
  // empty input instead of hanging.
  async function loadLocation() {
    setLocationLoading(true);
    try {
      const resp = await fetch(`${API_BASE}/web/location`, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) { setLocation({ source: "none" }); return; }
      const data = await resp.json();
      setLocation(data && typeof data === "object" ? data : { source: "none" });
    } catch (_) {
      setLocation({ source: "none" });
    } finally {
      setLocationLoading(false);
    }
  }

  // Save the location label. The UI sends only {label}; connie-app fills `owner`
  // server-side from the web-token owner, so a client can never write a foreign
  // location. An empty label clears it (delete path). Returns {ok, geocoded} or
  // {ok:false, detail}.
  async function saveLocation(label) {
    try {
      const resp = await fetch(`${API_BASE}/web/location`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${WEB_TOKEN}` },
        body: JSON.stringify({ label: typeof label === "string" ? label : "" }),
      });
      const data = await resp.json().catch(() => ({}));
      if (!resp.ok) {
        return { ok: false, detail: data.detail || `Fehler (HTTP ${resp.status}).` };
      }
      // Refresh so the section reflects the persisted state.
      loadLocation();
      return { ok: true, geocoded: data.geocoded };
    } catch (_) {
      return { ok: false, detail: "Connie ist gerade nicht erreichbar." };
    }
  }

  // ----- Wissen (GET/POST/DELETE /web/knowledge) -----
  // Lazy: fetched the first time the settings panel opens. The facts are shared
  // (Connie uses them for all users); only the write path is owner-gated. Self-
  // degrades to {ok:false, facts:[]} on any error so the section still renders.
  async function loadKnowledge() {
    setKnowledgeLoading(true);
    try {
      const resp = await fetch(`${API_BASE}/web/knowledge`, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) { setKnowledge({ ok: false, facts: [] }); return; }
      const data = await resp.json();
      setKnowledge(data && typeof data === "object" && Array.isArray(data.facts)
        ? data : { ok: false, facts: [] });
    } catch (_) {
      setKnowledge({ ok: false, facts: [] });
    } finally {
      setKnowledgeLoading(false);
    }
  }

  // Save one fact. The UI sends only {body, title?, topic?, company_domain?};
  // connie-app fills `created_by` server-side from the web-token owner, so a
  // client can never forge a foreign attribution. Returns {ok} or
  // {ok:false, detail}.
  async function saveKnowledge(fact) {
    try {
      const resp = await fetch(`${API_BASE}/web/knowledge`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${WEB_TOKEN}` },
        body: JSON.stringify(fact && typeof fact === "object" ? fact : {}),
      });
      const data = await resp.json().catch(() => ({}));
      if (!resp.ok) {
        // FastAPI 422 returns `detail` as an ARRAY of {loc,msg,type}; never let a
        // non-string reach the UI (rendering an array/object as a React child
        // crashes the whole tree -> blank screen). Always coerce to a string.
        let detail = data && data.detail;
        if (Array.isArray(detail)) {
          detail = detail.map((e) => (e && typeof e.msg === "string") ? e.msg : "ungültige Eingabe").join("; ");
        } else if (detail && typeof detail !== "string") {
          detail = "ungültige Eingabe";
        }
        return { ok: false, detail: detail || `Fehler (HTTP ${resp.status}).` };
      }
      // Refresh so the list reflects the new fact.
      loadKnowledge();
      return { ok: true, id: data.id };
    } catch (_) {
      return { ok: false, detail: "Connie ist gerade nicht erreichbar." };
    }
  }

  // Delete one fact by id (DSGVO Art. 17). Returns {ok} or {ok:false, detail}.
  async function deleteKnowledge(id) {
    const fid = (id || "").trim();
    if (!fid) return { ok: false, detail: "Keine ID." };
    try {
      const resp = await fetch(`${API_BASE}/web/knowledge?id=${encodeURIComponent(fid)}`, {
        method: "DELETE",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      const data = await resp.json().catch(() => ({}));
      if (!resp.ok) {
        let detail = data && data.detail;
        if (Array.isArray(detail)) {
          detail = detail.map((e) => (e && typeof e.msg === "string") ? e.msg : "Fehler").join("; ");
        } else if (detail && typeof detail !== "string") {
          detail = "Fehler";
        }
        return { ok: false, detail: detail || `Fehler (HTTP ${resp.status}).` };
      }
      loadKnowledge();
      return { ok: true };
    } catch (_) {
      return { ok: false, detail: "Connie ist gerade nicht erreichbar." };
    }
  }

  // Delete a whole uploaded file (all chunks of one source_id). Returns {ok}
  // or {ok:false, detail}. Same DELETE endpoint, source_id query param.
  async function deleteKnowledgeSource(sourceId) {
    const sid = (sourceId || "").trim();
    if (!sid) return { ok: false, detail: "Keine ID." };
    try {
      const resp = await fetch(`${API_BASE}/web/knowledge?source_id=${encodeURIComponent(sid)}`, {
        method: "DELETE",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      const data = await resp.json().catch(() => ({}));
      if (!resp.ok) {
        let detail = data && data.detail;
        if (Array.isArray(detail)) {
          detail = detail.map((e) => (e && typeof e.msg === "string") ? e.msg : "Fehler").join("; ");
        } else if (detail && typeof detail !== "string") {
          detail = "Fehler";
        }
        return { ok: false, detail: detail || `Fehler (HTTP ${resp.status}).` };
      }
      loadKnowledge();
      return { ok: true };
    } catch (_) {
      return { ok: false, detail: "Connie ist gerade nicht erreichbar." };
    }
  }

  // Upload a file -> N :KnowledgeFact (File-Upload extension). Sends multipart
  // FormData (do NOT set Content-Type: the browser sets the boundary). The
  // server forces created_by + validates + extracts + chunks. Returns
  // {ok, source_id, chunk_count, pii_warning} or {ok:false, detail}. Defensive:
  // always coerce a FastAPI 422 detail-array to a string (d51db38-Fix, else a
  // non-string React child blanks the screen).
  async function uploadKnowledge(file, meta) {
    if (!file) return { ok: false, detail: "Keine Datei gewählt." };
    try {
      const fd = new FormData();
      fd.append("file", file);
      const m = meta && typeof meta === "object" ? meta : {};
      if (m.title) fd.append("title", m.title);
      if (m.topic) fd.append("topic", m.topic);
      if (m.company_domain) fd.append("company_domain", m.company_domain);
      const resp = await fetch(`${API_BASE}/web/knowledge/upload`, {
        method: "POST",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },  // NO Content-Type
        body: fd,
      });
      const data = await resp.json().catch(() => ({}));
      if (!resp.ok) {
        let detail = data && data.detail;
        if (Array.isArray(detail)) {
          detail = detail.map((e) => (e && typeof e.msg === "string") ? e.msg : "ungültige Eingabe").join("; ");
        } else if (detail && typeof detail !== "string") {
          detail = "ungültige Eingabe";
        }
        return { ok: false, detail: detail || `Fehler (HTTP ${resp.status}).` };
      }
      loadKnowledge();
      return {
        ok: true,
        source_id: data.source_id,
        chunk_count: data.chunk_count,
        pii_warning: Array.isArray(data.pii_warning) ? data.pii_warning : [],
      };
    } catch (_) {
      return { ok: false, detail: "Connie ist gerade nicht erreichbar." };
    }
  }

  // ----- WKN-resolve (GET /web/portfolio-resolve) -----
  // The settings "Aufloesen" button resolves a 6-char WKN to a chart-verified
  // exchange-place candidate list so the user can pick the right Boersenplatz
  // (which fills the yahoo_symbol field). Owner-less; the egress is the sidecar's
  // SSRF-hardened resolver. Returns {ok, results:[...]} or {ok:false} on any
  // error / bad WKN so the drawer can show "keine Treffer" instead of breaking.
  async function resolveWkn(wkn) {
    const w = (wkn || "").trim().toUpperCase();
    if (!/^[A-Z0-9]{6}$/.test(w)) return { ok: false, results: [] };
    try {
      const resp = await fetch(`${API_BASE}/web/portfolio-resolve?wkn=${encodeURIComponent(w)}`, {
        method: "GET",
        headers: { Authorization: `Bearer ${WEB_TOKEN}` },
      });
      if (!resp.ok) return { ok: false, results: [] };
      const data = await resp.json();
      if (!data || typeof data !== "object" || !Array.isArray(data.results)) {
        return { ok: false, results: [] };
      }
      return data;
    } catch (_) {
      return { ok: false, results: [] };
    }
  }

  // ----- Speech line auto-scroll (#10) -----
  // The central spoken text is now scrollable (max-height + overflow). On every
  // update (SSE token stream, final reply, STT interim) keep it pinned to the
  // bottom so the newest words are always visible.
  aEffect(() => {
    if (speechRef.current) speechRef.current.scrollTop = speechRef.current.scrollHeight;
  }, [spoken]);

  // ----- Activity feed -----
  function pushActivity(item) {
    setActivity((arr) => [
      { id: "a-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6),
        time: nowTime(), status: "done", ...item },
      ...arr.slice(0, 20),
    ]);
  }
  function activityFromToolCalls(toolCalls) {
    if (!toolCalls || !toolCalls.length) {
      pushActivity({ text: "Direkt beantwortet", sub: "ohne weitere Werkzeuge" });
      return;
    }
    const seen = [];
    toolCalls.forEach((tc) => {
      const label = toolLabel(tc);
      if (seen[seen.length - 1] !== label) seen.push(label);
    });
    seen.slice(0, 8).forEach((label, i) => {
      setActivity((arr) => [
        { id: "tc-" + Date.now() + "-" + i, text: label, sub: "Werkzeug", status: "done", time: nowTime() },
        ...arr.slice(0, 20),
      ]);
    });
  }

  // ----- Speech display -----
  // Note: the artificial word-by-word typeOut has been replaced with native
  // server-sent token streaming. setSpoken is updated functionally from the
  // SSE token reader below so React state updates land in arrival order
  // (SR-WTS-E). The legacy clearTypeTimers + typeTimerRef are kept as no-op
  // cleanup hooks so the older effect/cleanup wiring still compiles.

  // ----- Audio (TTS playback) with echo-guard -----
  function stopAudio() {
    if (audioRef.current) {
      try { audioRef.current.pause(); } catch (_) {}
      if (audioRef.current.src) URL.revokeObjectURL(audioRef.current.src);
      audioRef.current = null;
    }
    audioPlayingRef.current = false;
  }
  async function speak(text) {
    // Speak the cleaned variant (no Markdown read aloud); transcript stays raw.
    const spokenText = cleanForSpeech(text);
    if (!spokenText || !spokenText.trim()) return;
    const body = { text: spokenText };
    if (layout.voiceId) body.voice_id = layout.voiceId;

    // Streaming path: pipe mp3 chunks into MediaSource so audio starts
    // within ~500 ms instead of waiting for the full batch (~1-5 s).
    const canStream =
      typeof window !== "undefined" &&
      window.MediaSource &&
      MediaSource.isTypeSupported("audio/mpeg");
    if (canStream) {
      try {
        const ok = await speakStreaming(body);
        if (ok) return;
        // fall through to batch fallback on any streaming failure
      } catch (_) { /* fall through */ }
    }

    // Batch fallback (legacy path; used if streaming unsupported or upstream stream failed).
    let resp;
    try {
      resp = await fetch(`${API_BASE}/web/tts`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${WEB_TOKEN}` },
        body: JSON.stringify(body),
      });
    } catch (_) {
      return; // network: text is already shown, skip audio silently
    }
    if (resp.status === 429) {
      pushActivity({ text: "Sprachausgabe-Limit erreicht", sub: "Antwort als Text" });
      return;
    }
    if (!resp.ok) return; // tts failed: keep text only
    const blob = await resp.blob();
    const url = URL.createObjectURL(blob);
    await new Promise((resolve) => {
      const audio = new Audio(url);
      audioRef.current = audio;
      audioPlayingRef.current = true;             // echo-guard ON
      audio.onended = () => { audioPlayingRef.current = false; URL.revokeObjectURL(url); resolve(); };
      audio.onerror = () => { audioPlayingRef.current = false; URL.revokeObjectURL(url); resolve(); };
      audio.play().catch(() => { audioPlayingRef.current = false; resolve(); });
    });
  }

  async function speakStreaming(body) {
    const resp = await fetch(`${API_BASE}/web/tts/stream`, {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `Bearer ${WEB_TOKEN}` },
      body: JSON.stringify(body),
    });
    if (resp.status === 429) {
      pushActivity({ text: "Sprachausgabe-Limit erreicht", sub: "Antwort als Text" });
      return true; // handled - no fallback
    }
    if (!resp.ok || !resp.body) return false;

    const mediaSource = new MediaSource();
    const audio = new Audio();
    const objectUrl = URL.createObjectURL(mediaSource);
    audio.src = objectUrl;
    audioRef.current = audio;
    audioPlayingRef.current = true;

    return await new Promise((resolve) => {
      let settled = false;
      const cleanup = () => {
        audioPlayingRef.current = false;
        try { URL.revokeObjectURL(objectUrl); } catch (_) {}
        if (!settled) { settled = true; resolve(true); }
      };
      audio.onended = cleanup;
      audio.onerror = cleanup;

      mediaSource.addEventListener("sourceopen", async () => {
        let sb;
        try {
          sb = mediaSource.addSourceBuffer("audio/mpeg");
        } catch (_) {
          cleanup();
          return;
        }
        const queue = [];
        let streamDone = false;
        const flush = () => {
          if (sb.updating) return;
          if (queue.length > 0) {
            try { sb.appendBuffer(queue.shift()); } catch (_) { cleanup(); }
            return;
          }
          if (streamDone) {
            try { mediaSource.endOfStream(); } catch (_) {}
          }
        };
        sb.addEventListener("updateend", flush);

        const reader = resp.body.getReader();
        try {
          while (true) {
            const { value, done } = await reader.read();
            if (done) break;
            if (value && value.byteLength > 0) {
              queue.push(value);
              flush();
            }
          }
        } catch (_) { /* upstream cut */ }
        streamDone = true;
        flush();
      });

      audio.play().catch(() => cleanup());
    });
  }

  // ----- Voice preview (settings drawer) -------------------------------------
  // A self-contained pre-listen that is INDEPENDENT of the main speech loop: it
  // uses its own <audio> element and never touches audioPlayingRef, so the STT
  // echo-guard is unaffected (the preview only lives inside the settings drawer
  // and Connie isn't speaking there). Only one preview plays at a time. Prefers
  // the free public ElevenLabs preview_url (no TTS spend); falls back to a short
  // sentence via POST /web/tts with that voice_id when no preview_url exists.
  const PREVIEW_FALLBACK_TEXT = "Guten Tag, ich bin Connie.";

  function stopPreview() {
    // Invalidate any in-flight TTS-fallback request (its result must be dropped).
    previewReqRef.current += 1;
    if (previewAudioRef.current) {
      try { previewAudioRef.current.pause(); } catch (_) {}
      const src = previewAudioRef.current.src;
      // Only blob: URLs (the TTS-fallback path) were created by us; revoke those.
      if (src && src.startsWith("blob:")) URL.revokeObjectURL(src);
      previewAudioRef.current = null;
    }
    setPreviewVoiceId(null);
  }

  function _attachPreviewAudio(audio) {
    previewAudioRef.current = audio;
    audio.onended = () => { if (previewAudioRef.current === audio) stopPreview(); };
    audio.onerror = () => { if (previewAudioRef.current === audio) stopPreview(); };
    audio.play().catch(() => { if (previewAudioRef.current === audio) stopPreview(); });
  }

  async function onPreviewVoice(voice) {
    if (!voice || !voice.voice_id) return;
    // Toggle: clicking the playing voice again stops it.
    if (previewVoiceId === voice.voice_id) { stopPreview(); return; }
    // Stop any running preview before starting a new one (bumps the req token).
    stopPreview();
    const myReq = previewReqRef.current;   // this preview's request token
    setPreviewVoiceId(voice.voice_id);

    // Path 1: free public sample mp3 (no TTS call).
    if (voice.preview_url) {
      _attachPreviewAudio(new Audio(voice.preview_url));
      return;
    }

    // Path 2: no preview_url -> a short TTS sample with this voice_id.
    let resp;
    try {
      resp = await fetch(`${API_BASE}/web/tts`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${WEB_TOKEN}` },
        body: JSON.stringify({ text: PREVIEW_FALLBACK_TEXT, voice_id: voice.voice_id }),
      });
    } catch (_) {
      if (myReq === previewReqRef.current) stopPreview();  // network: abort
      return;
    }
    // The user may have toggled/closed/switched while the request was in flight:
    // a newer request (or a stop) bumped the token -> drop this stale result.
    if (myReq !== previewReqRef.current) return;
    if (!resp.ok) { stopPreview(); return; }  // cap (429) or error: no preview
    const blob = await resp.blob();
    if (myReq !== previewReqRef.current) return;  // re-check after the body read
    _attachPreviewAudio(new Audio(URL.createObjectURL(blob)));
  }

  // ----- One full turn (SSE streaming, SR-WTS) -----
  // The /web/turn endpoint now returns ``text/event-stream`` with typed
  // events: start, token, tool_start, tool_end, heartbeat, error, reset,
  // failover, done. Bearer auth stays in the Authorization header (NEVER
  // in the URL); we read the body via ``getReader()`` + a TextDecoder in
  // streaming mode so multi-byte UTF-8 (Umlaute) never splits between
  // chunks. Two hard caps protect the browser:
  //   FRAME_CAP_BYTES = 65536 - max bytes between two ``\n\n`` (a server that
  //     never flushes would otherwise grow our buffer without bound).
  //   SPOKEN_CAP_BYTES = 204800 - max accumulated token text (~200 KB).
  // Idle-timeout: 20s without any frame -> abort (heartbeats reset it).
  // Frames whose ``data:`` is not valid JSON are dropped silently.
  // Token text is rendered via React's escaped string interpolation
  // (``{spoken}``) - NEVER dangerouslySetInnerHTML (SR-WTS-C).
  const FRAME_CAP_BYTES = 64 * 1024;
  const SPOKEN_CAP_BYTES = 200 * 1024;
  // 60 s = 4x the backend HEARTBEAT_INTERVAL_SEC (15 s, stream.py).
  // Bumped from 20 s on 2026-06-08 after complex turns (Web-Search +
  // Mail-Draft) hit the old cap when a single heartbeat was delayed.
  const IDLE_TIMEOUT_MS = 60_000;

  async function handleTurn(text) {
    if (busyRef.current) return;
    busyRef.current = true;
    setMessages((m) => [...m, { who: "user", text }]);
    setState("thinking");
    setSpoken("");
    setShowCaret(false);
    pushActivity({ text: "Anfrage an Connie", sub: "verarbeite ...", status: "running" });

    // Tracks the assistant reply across token events (the React state lags
    // the JS event loop, so we keep a local mirror for ``speak(...)``).
    let spokenLocal = "";
    let modelUsed = "unbekannt";
    let toolCallNames = [];
    let failoverTriggered = false;
    let sawDone = false;
    let sawError = false;
    let errPayload = null;

    const ctrl = new AbortController();
    turnAbortRef.current = ctrl;
    let idleTimer = null;
    const resetIdleTimer = () => {
      if (idleTimer) clearTimeout(idleTimer);
      idleTimer = setTimeout(() => { try { ctrl.abort(); } catch (_) {} },
                             IDLE_TIMEOUT_MS);
    };
    resetIdleTimer();

    try {
      const resp = await fetch(`${API_BASE}/web/turn`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${WEB_TOKEN}`,
        },
        body: JSON.stringify({ text }),
        signal: ctrl.signal,
      }).catch(() => { throw "network"; });

      if (resp.status === 401 || resp.status === 503) throw resp.status;
      if (!resp.ok) throw "server";

      const reader = resp.body.getReader();
      const decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
      let buf = "";

      setState("speaking");
      setShowCaret(true);

      // SR-WTS-C: handle the SSE buffer with hard caps and a streaming decoder.
      while (true) {
        let chunk;
        try {
          chunk = await reader.read();
        } catch (_) {
          // Abort or network error mid-stream.
          throw "network";
        }
        if (chunk.done) {
          // Final flush so a trailing multi-byte sequence isn't lost.
          buf += decoder.decode(new Uint8Array(), { stream: false });
          break;
        }
        resetIdleTimer();
        buf += decoder.decode(chunk.value, { stream: true });

        if (buf.length > FRAME_CAP_BYTES) {
          // No frame boundary in 64 KB -> drop the connection (CWE-400).
          sawError = true;
          errPayload = { code: "frame_too_large" };
          try { ctrl.abort(); } catch (_) {}
          try { await reader.cancel(); } catch (_) {}
          break;
        }

        // Split on the SSE frame separator. Last entry can be incomplete.
        const parts = buf.split("\n\n");
        buf = parts.pop();
        for (const raw of parts) {
          const frame = parseSseFrame(raw);
          if (!frame) continue;
          const { event, data } = frame;
          if (event === "start") {
            // Nothing to render; UI is already in "thinking".
          } else if (event === "token") {
            const t = (data && typeof data.text === "string") ? data.text : "";
            if (!t) continue;
            // SR-WTS-C lifetime cap on the cumulative token buffer.
            if (spokenLocal.length + t.length > SPOKEN_CAP_BYTES) {
              const hint = "...[Antwort zu lang, gekuerzt]";
              spokenLocal = spokenLocal + hint;
              setSpoken((prev) => prev + hint);
              sawError = true;
              errPayload = { code: "spoken_cap" };
              try { ctrl.abort(); } catch (_) {}
              try { await reader.cancel(); } catch (_) {}
              break;
            }
            spokenLocal = spokenLocal + t;
            // SR-WTS-E functional setter to avoid stale-closure races.
            setSpoken((prev) => prev + t);
          } else if (event === "tool_start") {
            const tool = (data && data.tool) || "";
            setCurrentTool(toolLabelActive(tool));
            pushActivity({
              text: toolLabel(tool),
              sub: "Werkzeug startet ...",
              status: "running",
            });
          } else if (event === "tool_end") {
            const tool = (data && data.tool) || "";
            const ms = (data && data.elapsed_ms) || 0;
            const status = (data && data.status) || "ok";
            setCurrentTool(null);
            pushActivity({
              text: toolLabel(tool),
              sub: status === "ok" ? `fertig (${ms} ms)` : `Fehler (${ms} ms)`,
              status: status === "ok" ? "done" : "error",
            });
            if (tool) toolCallNames.push(tool);
          } else if (event === "reset") {
            // Failover: drop the half-text accumulated from the failed model.
            spokenLocal = "";
            setSpoken(() => "");
          } else if (event === "failover") {
            const from = (data && data.from_model) || "";
            const to = (data && data.to_model) || "";
            failoverTriggered = true;
            pushActivity({
              text: "Modellwechsel",
              sub: `${from} -> ${to}`,
              status: "running",
            });
          } else if (event === "heartbeat") {
            // No render; the resetIdleTimer above is the only effect.
          } else if (event === "error") {
            sawError = true;
            errPayload = data || { code: "error" };
          } else if (event === "done") {
            sawDone = true;
            modelUsed = (data && data.model_used) || modelUsed;
            // Done frame is the authoritative tool list; overrides what we
            // assembled from tool_end events (handles partial streams).
            if (Array.isArray(data && data.tool_calls) && data.tool_calls.length) {
              toolCallNames = data.tool_calls.slice();
            }
            failoverTriggered = !!(data && data.failover_triggered) || failoverTriggered;
            // Server reports how many short-tail turns it still holds for
            // this owner. We snapshot it for the drift banner below; missing
            // field (older deploy) leaves the count unchanged.
            if (data && typeof data.tail_turns === "number") {
              setServerTurnCount(data.tail_turns);
            }
          }
        }
        if (sawError) break;
        if (sawDone) break;
      }

      if (sawError && !sawDone) {
        // SSE stream ended with an error and no done -> show the error path.
        throw "server";
      }

      // Render the full assistant message in the transcript and speak.
      const finalText = spokenLocal || "";
      setMessages((m) => [...m, { who: "connie", text: finalText }]);
      await speak(finalText);            // echo-guard active during playback
      setShowCaret(false);
      setState("ready");
    } catch (err) {
      const status = (errPayload && errPayload.code) || err;
      const msg = errorMessage(
        err === "network" ? "network"
        : typeof err === "number" ? err
        : "server",
      );
      setState("error");
      setSpoken(msg);
      setMessages((m) => [...m, { who: "connie", text: msg }]);
      pushActivity({
        text: "Fehler",
        sub: typeof status === "number" ? `HTTP ${status}` : String(status),
        status: "done",
      });
    } finally {
      if (idleTimer) clearTimeout(idleTimer);
      busyRef.current = false;
      setCurrentTool(null);
      if (turnAbortRef.current === ctrl) turnAbortRef.current = null;
    }
  }

  // ----- E-S6 realtime adapter (OpenAI WebRTC) ------------------------------
  // The realtime path bypasses /web/turn entirely: audio is browser <->
  // OpenAI direct via WebRTC, the metadata channel is the SSE stream
  // /web/voice/events/{id}, and tool-calls are dispatched server-side by
  // the OpenAIRealtimeVoiceAdapter (E-S2..S4). The dashboard's job here
  // is to (a) start/stop the session via the controller from
  // voice-realtime.js, (b) mirror transcript deltas into the speech
  // line, and (c) auto-fail-over to cascade on 503/529/429.
  //
  // A successful realtime start changes the mic-button colour from green
  // (cascade) to red (realtime) via `realtimeSession != null`.

  // Build a user-facing toast text from the structured ConnieRealtime
  // error. Returns the string to show; null when we should stay silent
  // (e.g. user cancelled the mic prompt).
  function failoverToastFor(err) {
    if (!err) return "Realtime nicht erreichbar. Wechsel auf Cascade.";
    const reason = (err && (err.reason || err.message)) || "";
    if (reason === "no_webrtc")
      return "Dein Browser unterstützt WebRTC nicht. Wir bleiben bei Cascade.";
    if (reason === "mic_denied")
      return "Mikrofon-Zugriff verweigert. Bitte Mikro freigeben oder Cascade nutzen.";
    if (err && err.status === 503)
      return "Realtime ist gerade aus. Wir nehmen Cascade.";
    if (err && err.status === 429)
      return "Tageslimit für Sprache erreicht. Wir wechseln auf Cascade.";
    if (err && err.status === 502)
      return "OpenAI Realtime gerade nicht verfügbar. Wir nehmen Cascade.";
    return "Realtime aktuell nicht verfügbar. Wechsel auf Cascade.";
  }

  // Open a realtime session and wire the controller events into the
  // dashboard state. Returns true on success, false on any failure (the
  // caller is expected to fall back to cascade on false). Idempotent:
  // closes any existing realtime session before starting a new one.
  async function startRealtimeIfPossible() {
    if (!window.ConnieRealtime || !window.ConnieRealtime.startRealtimeSession) {
      showToast("Voice-Realtime nicht geladen. Wir nehmen Cascade.", "warn");
      return false;
    }
    // Close any prior realtime session (defence in depth - the sidecar
    // also auto-kicks via SR-RTN-9, but a local close is cleaner).
    await stopRealtimeIfRunning();

    let controller;
    try {
      controller = await window.ConnieRealtime.startRealtimeSession({
        apiBase: API_BASE,
        webToken: WEB_TOKEN,
        voiceId: layout.voiceId || null,
      });
    } catch (err) {
      const msg = failoverToastFor(err);
      showToast(msg, "warn");
      // Used to silently flip the user's voiceMode to "cascade" on any
      // realtime hiccup (old R-RT-4 auto-failover). Bernd disabled that on
      // 2026-06-01: the chosen mode is sticky; we only show the toast and
      // let the user decide. The caller's startRealtimeIfPossible() ->
      // startListening() fallback at the mic button still gives him a one-
      // click cascade for THIS turn without changing the saved preference.
      return false;
    }

    // Wire the SSE event taxonomy into the dashboard state.
    controller.on("user_transcript", (data) => {
      if (data && typeof data.text === "string") {
        setSpoken(data.text);
      }
    });
    controller.on("assistant_transcript", (data) => {
      if (data && typeof data.text === "string") {
        setSpoken(data.text);
      }
    });
    controller.on("turn_started", () => {
      setState("speaking");
      pushActivity({ text: "Realtime-Antwort", sub: "WebRTC offen", status: "running" });
    });
    controller.on("turn_ended", () => {
      setState("ready");
    });
    controller.on("tool_call_requested", (data) => {
      if (data && data.tool_name) {
        pushActivity({
          text: toolLabel(data.tool_name),
          sub: "Werkzeug startet ...",
          status: "running",
        });
      }
    });
    controller.on("tool_call_completed", (data) => {
      if (!data) return;
      const ms = data.elapsed_ms || 0;
      const ok = data.status === "ok";
      pushActivity({
        text: toolLabel(data.tool_name || ""),
        sub: ok ? `fertig (${ms} ms)` : `Fehler (${ms} ms)`,
        status: ok ? "done" : "error",
      });
    });
    controller.on("error", (data) => {
      pushActivity({
        text: "Realtime-Fehler",
        sub: (data && data.code) || "error",
        status: "error",
      });
    });
    // SR-RTN-9: when the sidecar kicks us because a second tab opened a
    // session for the same owner, the session_ended event arrives with
    // reason=multi_browser_kick. We surface a toast and revert UI state.
    controller.on("session_ended", (data) => {
      const reason = (data && data.reason) || "stopped";
      if (reason === "multi_browser_kick") {
        showToast(
          "Voice-Session in einem anderen Tab gestartet, dieser hier ist beendet.",
          "warn",
        );
      } else if (reason === "daily_cap") {
        showToast("Tageslimit für Sprache erreicht.", "warn");
      } else if (reason === "lifetime_cap") {
        showToast("Maximale Sitzungszeit erreicht. Bitte neu starten.", "info");
      } else if (reason === "stale_30s") {
        showToast("Sitzung wegen Inaktivität beendet.", "info");
      }
      setRealtimeSession(null);
      setState("ready");
    });
    controller.on("pc_connection_lost", () => {
      pushActivity({
        text: "Realtime-Verbindung verloren",
        sub: "Session beendet, Modus bleibt Realtime",
        status: "error",
      });
      showToast("Realtime-Verbindung verloren. Neuer Mic-Klick startet eine neue Session.", "warn");
      // Do NOT flip voiceMode to cascade automatically (2026-06-01 Bernd
      // fix): the chosen mode is sticky. Just close the session; the next
      // mic-click reopens realtime.
      stopRealtimeIfRunning();
    });
    controller.on("closed", () => {
      setRealtimeSession(null);
    });

    setRealtimeSession(controller);
    setState("speaking");  // mic-indicator -> red
    return true;
  }

  // Close any active realtime session (idempotent).
  async function stopRealtimeIfRunning() {
    const cur = realtimeSessionRef.current;
    if (!cur) return;
    try { await cur.close("user_close"); } catch (_) {}
    setRealtimeSession(null);
  }

  // SR-RTN-11: "Alle Voice-Sessions beenden" button in the Settings
  // drawer. Closes the local realtime session AND tells the sidecar (the
  // close call inside the controller also clears the per-tab session_id).
  async function closeAllVoiceSessions() {
    await stopRealtimeIfRunning();
    // Belt-and-braces: clear the per-tab session id (the close call already
    // does it, but a second click should still leave a clean state).
    if (window.ConnieRealtime && window.ConnieRealtime.forgetSessionId) {
      window.ConnieRealtime.forgetSessionId();
    }
    showToast("Alle Voice-Sessions geschlossen.", "info");
    return { ok: true };
  }

  // Tear down the realtime session on unmount so a navigate-away does
  // not leak the WebRTC peer-connection / mic into the next page load.
  aEffect(() => () => {
    if (realtimeSessionRef.current) {
      try { realtimeSessionRef.current.close("unmount"); } catch (_) {}
    }
  }, []);

  // SSE frame parser. Returns null on malformed input. Token text in the
  // ``data`` payload is opaque JSON-string and is later rendered through
  // React's escaped interpolation (never dangerouslySetInnerHTML).
  function parseSseFrame(raw) {
    if (!raw) return null;
    let event = null;
    let data = null;
    const lines = raw.split("\n");
    for (const line of lines) {
      if (line.startsWith("event: ")) {
        event = line.slice("event: ".length).trim();
      } else if (line.startsWith("data: ")) {
        // SSE allows multi-line data; we collapse to a single line to keep
        // the rest of the pipeline simple (the backend never emits multi-
        // line ``data:`` because format_sse strips newlines).
        const value = line.slice("data: ".length);
        data = (data == null) ? value : (data + "\n" + value);
      }
    }
    if (event == null || data == null) return null;
    try {
      return { event, data: JSON.parse(data) };
    } catch (_) {
      return null;
    }
  }

  // ----- Speech recognition (STT) -----
  // Tracks whether the user (mic-click again, space-release) intentionally
  // stopped listening. Browser auto-stops (Chrome's ~1 s VAD) don't set
  // this, so the onend handler can restart instead of submitting.
  const listenStopByUserRef = aRef(false);
  function stopListening() {
    listenStopByUserRef.current = true;
    if (recogRef.current) {
      try { recogRef.current.stop(); } catch (_) {}
      // recogRef nullified by onend so the finalText handoff still works
    }
  }
  function startListening() {
    // Echo-guard: never listen while Connie speaks or a turn is running.
    if (busyRef.current || audioPlayingRef.current) return;
    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
    if (!SR) {
      setState("error");
      setSpoken("Spracherkennung wird in diesem Browser nicht unterstützt. Bitte Chrome nutzen oder tippen.");
      return;
    }
    const r = new SR();
    r.lang = "de-DE";
    r.interimResults = true;
    // continuous=true keeps the session alive across short pauses. Chrome
    // still auto-stops on longer silence, so the onend handler restarts
    // when the user has not explicitly released the mic / space yet.
    r.continuous = true;
    r.maxAlternatives = 1;
    let finalText = "";
    recogRef.current = r;
    listenStopByUserRef.current = false;
    setState("listening");
    setSpoken("");
    r.onresult = (e) => {
      let interim = "";
      for (let i = e.resultIndex; i < e.results.length; i++) {
        const t = e.results[i][0].transcript;
        if (e.results[i].isFinal) finalText += t; else interim += t;
      }
      setSpoken((finalText + " " + interim).trim());
    };
    r.onerror = (ev) => {
      const code = ev && ev.error ? String(ev.error) : "";
      // ``no-speech`` fires after a longer pause without speech; treat it like
      // a soft auto-stop and restart if the user hasn't released yet, so a
      // 1-2 s think-pause mid-sentence doesn't end the turn.
      if (code === "no-speech" && !listenStopByUserRef.current && !busyRef.current) {
        try { r.start(); return; } catch (_) {}
      }
      recogRef.current = null;
      setState("ready");
      setSpoken("Ich habe nichts verstanden. Noch einmal?");
    };
    r.onend = () => {
      // Restart on auto-stops while the user is still pressing/listening; only
      // submit when the user explicitly released (mic-click / space-release).
      if (!listenStopByUserRef.current && !busyRef.current) {
        try { r.start(); return; } catch (_) { /* fall through to submit */ }
      }
      recogRef.current = null;
      const t = finalText.trim();
      if (t) handleTurn(t);
      else setState((s) => (s === "listening" ? "ready" : s));
    };
    try { r.start(); } catch (_) {
      listenStopByUserRef.current = false;
      recogRef.current = null;
      setState("ready");
    }
  }

  // ----- Mic button -----
  function onMicClick() {
    // E-S6: realtime path. A click on the (red) mic while a realtime
    // session is open triggers barge-in (interrupt the current Connie
    // response). A click on the (green) mic in realtime mode with no
    // active session opens a new one and lets the user speak.
    if (voiceMode === "realtime") {
      if (realtimeSessionRef.current) {
        // Barge-in only - the session stays open.
        try { realtimeSessionRef.current.interrupt(); } catch (_) {}
        setState("listening");
        return;
      }
      // No active session yet: start one. This requires the click as the
      // user gesture (Chrome auto-play / mic policy anchors on it).
      startRealtimeIfPossible().then((ok) => {
        if (!ok) {
          // Auto-failover already flipped voiceMode to cascade; emulate a
          // cascade-mic click so the user does not have to click twice.
          startListening();
        }
      });
      return;
    }

    // Cascade (default) path: same behaviour as before E-S6.
    if (state === "listening") { stopListening(); return; }   // stop -> onend sends
    if (busyRef.current || audioPlayingRef.current) {
      // Barge-in: abort the SSE turn FIRST (otherwise tokens kept arriving
      // and the post-stream speak() restarted the audio after stopAudio).
      // Then stop audio + clear type timers + flip back to ready.
      try { turnAbortRef.current?.abort(); } catch (_) {}
      turnAbortRef.current = null;
      stopAudio(); clearTypeTimers(); setShowCaret(false);
      busyRef.current = false; setState("ready"); return;
    }
    startListening();
  }

  function onSendText(e) {
    e?.preventDefault?.();
    const text = input.trim();
    if (!text || busyRef.current) return;
    setInput("");
    handleTurn(text);
  }
  function onQuick(key) {
    if (busyRef.current) return;
    handleTurn(QUICK_PROMPTS[key] || key);
  }

  const micBusy = state === "thinking";

  // ----- dashboard-v3: render a column's cards (ordered, visible, wrapped) -----
  // Maps a card-id to its live element. Each card is wrapped in CardShell so it
  // gets the drag-handle / size / hide controls; hidden cards are filtered out.
  function renderCardBody(id) {
    switch (id) {
      case "schedule":
        return <ScheduleCard items={today === null ? null : ctxAppointments(today)} />;
      case "weather":
        return <WeatherCard />;
      case "stocks":
        return <StockPortfolioCard data={portfolioTile} />;
      case "pipeline":
        return <PipelineCard data={pipeline} />;
      case "billomat":
        return (
          <BillomatCard
            data={billomatTile}
            period={billomatPeriod}
            onPeriodChange={onBillomatPeriodChange}
          />
        );
      case "approvals":
        return (
          <ApprovalsCard
            data={approvalsData}
            onDecide={onApprovalDecide}
          />
        );
      case "memory":
        return (
          <MemoryCard
            data={memory}
            syncLabel={relSync(memory && memory.last_sync)}
            graph={graph}
            onFocusNode={onFocusNode}
          />
        );
      case "workflow":
        return (
          <WorkflowCard
            workflows={workflows}
            runs={runs}
            available={(workflowsAvail === false || runsAvail === false) ? false : true}
            onStart={startWorkflow}
            onDecision={decideRun}
            onLoadPreview={loadRunPreview}
          />
        );
      case "reel":
        return <ReelCard />;
      case "working_memory":
        return <WorkingMemoryCard items={recent} />;
      case "activity":
        return <ActivityCard items={activity.slice(0, 6)} />;
      case "transcript":
        return <TranscriptCard messages={messages} />;
      default:
        return null;
    }
  }
  // A thin drop-indicator line shown during a drag at the active insertion spot.
  function DropLine() {
    return <div className="drop-indicator" aria-hidden="true" />;
  }

  function renderColumn(colName) {
    const visible = LAYOUT
      ? LAYOUT.visibleCardsForColumn(NATURAL_COLS, layout, colName)
      : (COLUMN_CARD_IDS[colName] || []);

    // Is the live drag targeting this column? If so we draw the drop line before
    // `beforeId` (or at the end when beforeId is null).
    const dragActive = drag && drag.active;
    const tgt = dragActive && drag.target && drag.target.col === colName ? drag.target : null;

    const out = [];
    visible.forEach((id) => {
      if (tgt && tgt.beforeId === id) out.push(<DropLine key={`drop-${id}`} />);
      out.push(
        <CardShell
          key={id}
          cardId={id}
          col={colName}
          size={LAYOUT ? LAYOUT.sizeOf(layout, id) : "M"}
          dragging={drag && drag.id === id}
          onHide={hideCard}
          onSetSize={setCardSize}
          onDragHandleDown={onDragHandleDown}
        >
          {renderCardBody(id)}
        </CardShell>
      );
    });
    // Append-at-end indicator (beforeId === null and this column is targeted).
    if (tgt && tgt.beforeId === null) out.push(<DropLine key="drop-end" />);
    return out;
  }

  // S3: render one column zone. The wrapper always carries data-colzone so the
  // drag hit-test (computeDropTarget) can target it even when collapsed (a drop
  // then auto-expands it, F4). A header sits on top with a collapse chevron;
  // collapsed columns fold to a thin strip with a rotated label. ``side`` picks
  // the chevron direction so a column always folds toward its own screen edge.
  function ColumnZone(colName, modifier, side) {
    const collapsed = LAYOUT ? LAYOUT.isCollapsed(layout, colName) : false;
    const label = COLUMN_LABELS[colName] || colName;
    // Collapse chevron points toward the edge the column folds to; expand points
    // back inward. Left columns fold left, right columns fold right.
    const collapseIcon = side === "right" ? "chevron-right" : "chevron-left";
    const expandIcon = side === "right" ? "chevron-left" : "chevron-right";
    return (
      <div
        className={`col ${modifier} ${collapsed ? "col--collapsed" : ""}`}
        data-colzone={colName}
      >
        <div className="col__head">
          <button
            className="col__collapse"
            title={collapsed ? "Spalte ausklappen" : "Spalte einklappen"}
            aria-label={collapsed ? `${label} ausklappen` : `${label} einklappen`}
            aria-expanded={!collapsed}
            onClick={() => toggleColumn(colName)}
          >
            <ConnieIcon
              name={collapsed ? expandIcon : collapseIcon}
              size={14}
              strokeWidth={2}
            />
          </button>
          {collapsed && <span className="col__label">{label}</span>}
        </div>
        {!collapsed && renderColumn(colName)}
      </div>
    );
  }

  return (
    <>
      <div className="bg-stage" />
      <div className="app">
        {/* E-S6: top-of-screen toast banner for auto-failover hints,
            multi-browser-kick notices and session-ended reasons. The
            banner is read-only; clicking it dismisses early. */}
        {toast ? (
          <div
            className={`toast toast--${toast.level || "info"}`}
            role="status"
            onClick={() => setToast(null)}
            style={{
              position: "fixed",
              top: 12,
              left: "50%",
              transform: "translateX(-50%)",
              zIndex: 9999,
              background: toast.level === "warn" ? "#fff7e0" : "#e7f3ff",
              border: `1px solid ${toast.level === "warn" ? "#e0b656" : "#aac7e7"}`,
              borderRadius: 8,
              padding: "10px 18px",
              fontSize: 14,
              boxShadow: "0 4px 16px rgba(0,0,0,0.16)",
              cursor: "pointer",
              maxWidth: "80vw",
            }}
          >{toast.text}</div>
        ) : null}
        {showDriftBanner ? (
          <div
            role="status"
            style={{
              position: "fixed",
              top: toast ? 56 : 12, left: "50%", transform: "translateX(-50%)",
              zIndex: 9998, background: "#fff7e0", border: "1px solid #e0b656",
              borderRadius: 8, padding: "10px 14px", fontSize: 13,
              boxShadow: "0 4px 16px rgba(0,0,0,0.16)", maxWidth: "80vw",
              display: "flex", gap: 12, alignItems: "center",
            }}
          >
            <span>
              <strong>Hinweis:</strong> Du siehst hier den gespeicherten Verlauf,
              aber Connie hat den Anfang des Gespraechs nach einem Neustart vergessen.
              Sie kennt nur die letzten {Math.max(serverTurnCount ?? 0, 0)} Drehs.
            </span>
            <button
              onClick={() => setDriftBannerDismissed(true)}
              style={{
                background: "transparent", border: "1px solid #b08c30",
                color: "#7a5800", padding: "4px 10px", borderRadius: 6,
                cursor: "pointer", fontSize: 12,
              }}
            >OK</button>
            <button
              onClick={clearChatHistory}
              title="Lokalen Verlauf zuruecksetzen"
              style={{
                background: "transparent", border: "1px solid #b08c30",
                color: "#7a5800", padding: "4px 10px", borderRadius: 6,
                cursor: "pointer", fontSize: 12,
              }}
            >Verlauf loeschen</button>
          </div>
        ) : null}
        {/* ============= Topbar ============= */}
        <div className="topbar">
          <div className="topbar__left">
            <span className="wordmark">Connie</span>
            <span className="wordmark-dot" />
            <span className="status-online">Online</span>
          </div>
          <div className="topbar__center">
            <span className="brand-tagline"><strong>CONVENTIC</strong> · Die gute KI.</span>
          </div>
          <div className="topbar__right">
            <Clock />
            <div className="avatar-chip" title="THE BOT"><img src="assets/bot_logo_rgb.png" alt="" /></div>
            <button
              className="menu-btn"
              aria-label={binauralOn ? "Hintergrundklang aus" : "Hintergrundklang an"}
              title={binauralOn ? "Hintergrundklang aus (binaural 7 Hz)" : "Hintergrundklang an (binaural 7 Hz)"}
              aria-pressed={binauralOn}
              onClick={toggleBinaural}
            ><ConnieIcon name={binauralOn ? "volume-2" : "volume-x"} size={16} /></button>
            <button
              className="menu-btn" aria-label="Einstellungen" title="Einstellungen"
              onClick={() => setSettingsOpen(true)}
            ><ConnieIcon name="settings" size={16} /></button>
            <button
              className="menu-btn" aria-label="Tastaturkürzel & Hilfe" title="Tastaturkürzel & Hilfe"
              onClick={() => setHelpOpen(true)}
            ><ConnieIcon name="menu" size={16} /></button>
            {canLogout && (
              <button
                className="menu-btn"
                aria-label="Abmelden"
                title={USER_NAME ? `Abmelden (${USER_NAME})` : "Abmelden"}
                onClick={() => {
                  if (window.confirm("Wirklich abmelden? Du landest auf der Login-Seite.")) {
                    doLogout();
                  }
                }}
              ><ConnieIcon name="log-out" size={16} /></button>
            )}
          </div>
        </div>

        {/* ============= Main ============= */}
        {/* S3 widescreen: 2 columns | Orb | 2 columns. Each ColumnZone carries
            data-colzone so the drag hit-test targets it (even when collapsed). */}
        <div className="main">
          {ColumnZone("left", "col--left", "left")}
          {ColumnZone("left2", "col--left2", "left")}

          <div className="col center">
            <div className="orb-wrap"><Orb state={state === "error" ? "ready" : state} /></div>
            <div className="speech">
              <div className="speech__line" ref={speechRef}>
                {spoken}
                {showCaret && <span className="caret" />}
              </div>
              <div style={{ marginTop: 18, display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
                <div className="speech__status" data-state={state}>
                  <span className="dot" />
                  <span>{STATUS_LABELS[state]}</span>
                </div>
                <Waveform active={state === "speaking" || state === "listening"} />
              </div>
            </div>
          </div>

          {ColumnZone("right", "col--right", "right")}
          {ColumnZone("right2", "col--right2", "right")}
        </div>

        {/* ============= Controls ============= */}
        <div className="controls">
          <div className="quicks quicks--left">
            <form onSubmit={onSendText} className="text-input-wrap">
              <input
                className="text-input"
                placeholder="oder tippen ..."
                value={input}
                onChange={(e) => setInput(e.target.value)}
              />
              <button className="text-input-send" type="submit" aria-label="Senden">
                <ConnieIcon name="arrow-up" size={14} strokeWidth={2} />
              </button>
            </form>
          </div>

          <div style={{ position: "relative" }}>
            {/* SR-RTN-12 mode indicator: a thick red ring around the mic
                while a Realtime session is open; the default green ring is
                worn by the cascade path. Tooltip carries the provider id,
                a short session-id snippet and (if known) the expires_in
                so Bernd can see at a glance what is running. */}
            <button
              className={`mic ${state === "listening" ? "mic--active" : ""}${realtimeSession ? " mic--realtime" : ""}`}
              onClick={onMicClick}
              disabled={micBusy}
              aria-label={
                realtimeSession
                  ? "Realtime-Sitzung aktiv - tippen für Barge-in"
                  : state === "listening"
                    ? "Aufnahme beenden"
                    : "Sprechen starten"
              }
              title={
                realtimeSession
                  ? `Realtime: ${realtimeSession.providerId} · ${(realtimeSession.sessionId || "").slice(0, 8)}` +
                    (realtimeSession.expiresIn ? ` · ${realtimeSession.expiresIn}s` : "")
                  : (voiceMode === "realtime"
                      ? "Cascade-Modus (Realtime wählt sich beim Klick aus)"
                      : "Cascade-Modus")
              }
              style={realtimeSession
                ? { boxShadow: "0 0 0 3px #c0392b inset" }
                : undefined}
            >
              <ConnieIcon name={state === "listening" ? "square" : "mic"} size={28} strokeWidth={1.75} />
            </button>
            <div className="mic-label">
              {realtimeSession ? "Realtime aktiv - tippen für Barge-in"
                : state === "listening" ? "Tippen zum Stoppen"
                : state === "thinking" ? "Connie denkt ..."
                : state === "speaking" ? "Tippen zum Unterbrechen"
                : voiceMode === "realtime" ? "Realtime starten"
                : "Push to talk"}
            </div>
            {currentTool && (state === "thinking" || state === "speaking") && (
              <div className="mic-tool" aria-live="polite">
                <span className="mic-tool__dot" />
                {currentTool}
              </div>
            )}
            {/* Keyboard hint: hold Space to talk (hidden while a turn runs). In
                the morning window, also hint that "g" speaks the briefing. */}
            {(state === "ready" || state === "listening") && !realtimeSession && (
              <div className="mic-hint">
                Leertaste halten zum Sprechen{morningWindow ? " · g für Morgen-Briefing" : ""}
              </div>
            )}
          </div>

          <div className="quicks">
            {morningWindow && (
              <button
                className="pill pill--morning"
                onClick={() => { if (!busyRef.current) handleTurn(MORNING_BRIEFING_PROMPT); }}
                title="Gesprochenes Morgen-Briefing (Taste g)"
              ><ConnieIcon name="sun" size={14} /> Guten Morgen</button>
            )}
            <button className="pill" onClick={() => onQuick("briefing")}><ConnieIcon name="sun" size={14} /> Tagesbriefing</button>
            <button className="pill" onClick={() => onQuick("draft_mail")}><ConnieIcon name="mail" size={14} /> Mail entwerfen</button>
            <button className="pill" onClick={() => onQuick("find_deal")}><ConnieIcon name="search" size={14} /> Deal suchen</button>
            <button className="pill" onClick={() => onQuick("prep_meeting")}><ConnieIcon name="presentation" size={14} /> Meeting vorbereiten</button>
            {customQuicks.map((q, i) => (
              <span key={q.id || i} style={{ position: "relative", display: "inline-flex" }}>
                <button
                  className="pill pill--custom"
                  title={`${q.prompt}\n\nRechtsklick: bearbeiten`}
                  onClick={() => { if (!busyRef.current) handleTurn(q.prompt); }}
                  onContextMenu={(e) => { e.preventDefault(); openEditCustomQuick(q); }}
                >
                  <ConnieIcon name={q.icon || "sparkles"} size={14} /> {q.label}
                </button>
              </span>
            ))}
            <button
              className="pill pill--add"
              title="Eigene Schnellaktion hinzufuegen"
              onClick={openNewCustomQuick}
            >
              <ConnieIcon name="plus" size={14} /> Neu
            </button>
          </div>
        </div>
      </div>

      {/* ============= Settings drawer (dashboard-v3.1) ============= */}
      <SettingsPanel
        open={settingsOpen}
        onClose={() => setSettingsOpen(false)}
        voices={voices}
        voicesLoading={voicesLoading}
        voiceId={layout.voiceId}
        onVoiceChange={(vid) => updateLayout({ voiceId: vid })}
        language={layout.language}
        onLanguageChange={(lng) => updateLayout({ language: lng })}
        onPreview={onPreviewVoice}
        previewVoiceId={previewVoiceId}
        textSize={layout.textSize}
        onTextSizeChange={(s) => updateLayout({ textSize: s })}
        hidden={layout.hidden}
        cardTitles={CARD_TITLES}
        onUnhide={unhideCard}
        voiceMode={voiceMode}
        onVoiceModeChange={(m) => {
          setVoiceMode(m);
          if (m !== "realtime") stopRealtimeIfRunning();
        }}
        voiceSessionActive={!!realtimeSession}
        voiceSessionInfo={realtimeSession ? {
          providerId: realtimeSession.providerId,
          sessionId: realtimeSession.sessionId,
        } : null}
        onCloseAllVoiceSessions={closeAllVoiceSessions}
        agenda={agenda}
        agendaLoading={agendaLoading}
        agendaAvailable={agendaAvailable}
        onLoadAgenda={loadAgendaConfig}
        onSaveAgenda={saveAgendaConfig}
        portfolio={portfolioTile === null ? null
          : (Array.isArray(portfolioTile.per_stock) ? portfolioTile.per_stock : [])}
        portfolioLoading={portfolioLoading}
        onLoadPortfolio={loadPortfolio}
        onSavePortfolio={savePortfolio}
        onResolveWkn={resolveWkn}
        location={location === null ? null
          : { label: (location && typeof location.label === "string") ? location.label : "" }}
        locationLoading={locationLoading}
        onLoadLocation={loadLocation}
        onSaveLocation={saveLocation}
        knowledge={knowledge === null ? null
          : (knowledge && Array.isArray(knowledge.facts) ? knowledge.facts : [])}
        knowledgeLoading={knowledgeLoading}
        onLoadKnowledge={loadKnowledge}
        onSaveKnowledge={saveKnowledge}
        onDeleteKnowledge={deleteKnowledge}
        onDeleteKnowledgeSource={deleteKnowledgeSource}
        onUploadKnowledge={uploadKnowledge}
      />

      {/* Shortcuts / help overlay (topbar menu button, #8 + #10) */}
      <HelpOverlay open={helpOpen} onClose={() => setHelpOpen(false)} />

      {/* Custom-Quick Add/Edit modal (2026-06-02) */}
      <CustomQuickEditModal
        existing={customQuickModal}
        onClose={closeCustomQuickModal}
        onSave={saveCustomQuick}
        onDelete={(id) => { removeCustomQuick(id); closeCustomQuickModal(); }}
      />
    </>
  );
}

// ---------- Custom Quick-Add/Edit modal ----------
// `existing`:
//   null  -> hidden
//   {}    -> new entry
//   {id, label, prompt, icon} -> edit existing
const QUICK_ICON_OPTIONS = [
  // Business / sales
  "file-plus", "file-text", "receipt", "credit-card", "wallet",
  "briefcase", "users", "user-plus", "phone", "phone-call",
  // Communication
  "mail", "mail-plus", "send", "message-square", "bell",
  // Schedule / time
  "calendar", "calendar-plus", "clock", "alarm-clock", "sun",
  // Search / info
  "search", "list", "filter", "tag", "bookmark",
  // Action
  "play", "check-circle", "rocket", "zap", "sparkles",
];

function CustomQuickEditModal({ existing, onClose, onSave, onDelete }) {
  const open = existing !== null && existing !== undefined;
  const isEdit = !!(existing && existing.id);
  const [label, setLabel] = aState("");
  const [prompt, setPrompt] = aState("");
  const [icon, setIcon] = aState("sparkles");
  aEffect(() => {
    if (open) {
      setLabel(existing.label || "");
      setPrompt(existing.prompt || "");
      setIcon(existing.icon || "sparkles");
    }
  }, [open, existing && existing.id]);
  if (!open) return null;
  const canSave = label.trim().length > 0 && prompt.trim().length > 0;
  const cellStyle = (active) => ({
    width: 38, height: 38, display: "flex", alignItems: "center", justifyContent: "center",
    borderRadius: 6, cursor: "pointer",
    background: active ? "var(--lime, #E1F528)" : "var(--bg-2, #14142a)",
    color: active ? "#000" : "inherit",
    border: active ? "2px solid var(--lime, #E1F528)" : "1px solid var(--fg-3, #555)",
  });
  return (
    <div
      onClick={onClose}
      style={{
        position: "fixed", inset: 0, background: "rgba(10,10,20,0.55)",
        zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center",
      }}
    >
      <div
        onClick={(e) => e.stopPropagation()}
        style={{
          width: 520, maxWidth: "92vw", maxHeight: "90vh", overflowY: "auto",
          background: "var(--bg-1, #20203a)", color: "var(--fg-1, #fff)",
          borderRadius: 12, padding: "1.5em", border: "1px solid var(--fg-3, #555)",
        }}
      >
        <h3 style={{ margin: "0 0 0.5em 0" }}>
          {isEdit ? "Schnellaktion bearbeiten" : "Neue Schnellaktion"}
        </h3>
        <p style={{ margin: "0 0 1em 0", color: "var(--fg-3, #aaa)", fontSize: 13 }}>
          Label erscheint als Button-Text. Der Prompt geht beim Klick wortgenau an Connie.
        </p>

        <label style={{ display: "block", marginBottom: "0.6em" }}>
          <div style={{ fontSize: 12, color: "var(--fg-3, #aaa)", marginBottom: 4 }}>Label (max 32 Zeichen)</div>
          <input
            type="text" value={label} onChange={(e) => setLabel(e.target.value)}
            maxLength={32} placeholder="z.B. Neues Angebot"
            style={{ width: "100%", padding: "0.5em", borderRadius: 6, border: "1px solid var(--fg-3, #555)", background: "var(--bg-2, #14142a)", color: "inherit" }}
          />
        </label>

        <label style={{ display: "block", marginBottom: "0.6em" }}>
          <div style={{ fontSize: 12, color: "var(--fg-3, #aaa)", marginBottom: 4 }}>Prompt an Connie</div>
          <textarea
            value={prompt} onChange={(e) => setPrompt(e.target.value)}
            maxLength={4000} rows={5}
            placeholder="z.B. Erstelle ein neues Angebot in Billomat. Frag mich nach Kunde, Position und Betrag."
            style={{ width: "100%", padding: "0.5em", borderRadius: 6, border: "1px solid var(--fg-3, #555)", background: "var(--bg-2, #14142a)", color: "inherit", fontFamily: "inherit", resize: "vertical" }}
          />
        </label>

        <div style={{ marginBottom: "1em" }}>
          <div style={{ fontSize: 12, color: "var(--fg-3, #aaa)", marginBottom: 6 }}>
            Icon (Auswahl oder Lucide-Name eintippen)
          </div>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 8 }}>
            {QUICK_ICON_OPTIONS.map((name) => (
              <div
                key={name}
                title={name}
                onClick={() => setIcon(name)}
                style={cellStyle(icon === name)}
              >
                <ConnieIcon name={name} size={18} />
              </div>
            ))}
          </div>
          <input
            type="text" value={icon} onChange={(e) => setIcon(e.target.value)}
            placeholder="oder Lucide-Name (z.B. file-plus)" maxLength={32}
            style={{ width: "100%", padding: "0.4em", borderRadius: 6, border: "1px solid var(--fg-3, #555)", background: "var(--bg-2, #14142a)", color: "inherit", fontSize: 12 }}
          />
          <div style={{ marginTop: 6, fontSize: 11, color: "var(--fg-3, #888)" }}>
            Vorschau: <span style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "0.25em 0.6em", border: "1px solid var(--fg-3, #555)", borderRadius: 999, marginLeft: 4 }}>
              <ConnieIcon name={icon || "sparkles"} size={14} /> {label || "Label"}
            </span> &mdash; volle Liste auf lucide.dev/icons
          </div>
        </div>

        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.6em" }}>
          <div>
            {isEdit && (
              <button
                onClick={() => onDelete(existing.id)}
                className="pill"
                style={{ color: "#f88", borderColor: "#f88" }}
              >Entfernen</button>
            )}
          </div>
          <div style={{ display: "flex", gap: "0.6em" }}>
            <button onClick={onClose} className="pill">Abbrechen</button>
            <button
              disabled={!canSave}
              onClick={() => onSave({
                id: existing.id,
                label: label.trim(), prompt: prompt.trim(), icon: icon.trim() || "sparkles",
              })}
              className="pill"
              style={{ opacity: canSave ? 1 : 0.5, cursor: canSave ? "pointer" : "not-allowed", background: "var(--lime, #E1F528)", color: "#000" }}
            >
              {isEdit ? "Aktualisieren" : "Speichern"}
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
