// Chat — ResultInspector & payload/knowledge/steps panels. Split out of chat.jsx; loads after it.
const { useState: useCiS, useEffect: useCiE, useMemo: useCiM, useRef: useCiR, useLayoutEffect: useCiL } = React;

// ──────────────────────────────────────────────────────────────────────────
// ResultInspector — right panel with turn indicator + Jump-to-latest
// ──────────────────────────────────────────────────────────────────────────
function ResultInspector({ turn, turnIndex, totalTurns, pinnedToLatest, onJumpLatest }) {
  // Auto-step-scroll: while a turn streams, keep the right panel pinned to the
  // step currently in progress (the bottom of the timeline). When the turn
  // finishes — or when viewing an already-finished turn — focus the top, where
  // the result lives. A manual wheel gesture hands control back to the user and
  // suspends auto-follow; a fast continuous wheel flick snaps to top (up) or
  // bottom (down), and a downward snap while still streaming re-arms auto-follow.
  const scrollRef = useCiR(null);
  const autoFollowRef = useCiR(true);   // is auto-scroll currently driving the panel?
  const prevStreamingRef = useCiR(false);
  const streaming = !!(turn && turn.status === 'streaming');
  const streamingRef = useCiR(streaming);
  streamingRef.current = streaming;
  const turnId = turn ? turn.id : null;
  const liveCount = (turn && turn.liveSteps && turn.liveSteps.length) || 0;

  // Knowledge-update toast: while streaming, the knowledge section is almost
  // always scrolled out of view (auto-follow pins the viewport to the steps).
  // So each time a new source arrives we pop a brief, self-dismissing toast
  // announcing the update; tapping it jumps back up to the knowledge.
  const liveSources = (turn && turn.liveSources) || [];
  const liveSourcesLen = streaming ? liveSources.length : 0;
  const prevSrcLenRef = useCiR(0);
  const toastTimerRef = useCiR(null);
  const toastHoverRef = useCiR(false);
  const [knowledgeToast, setKnowledgeToast] = useCiS(null);
  const TOAST_MS = 10000;        // initial display window
  const TOAST_RESUME_MS = 5000;  // shorter window after the pointer leaves
  const startToastTimer = (ms) => {
    if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
    toastTimerRef.current = setTimeout(() => {
      setKnowledgeToast(null);
      toastTimerRef.current = null;
    }, ms);
  };
  // Pause the dismiss countdown while the pointer is over the toast, then start
  // a fresh (shorter) window on leave — so it can never vanish mid-interaction.
  const pauseToast = () => {
    toastHoverRef.current = true;
    if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); toastTimerRef.current = null; }
  };
  const resumeToast = () => { toastHoverRef.current = false; startToastTimer(TOAST_RESUME_MS); };

  useCiE(() => {
    const len = liveSourcesLen;
    const prev = prevSrcLenRef.current;
    prevSrcLenRef.current = len;
    if (!streaming || len <= prev) return;        // only on a real increase
    const latest = liveSources[len - 1];
    setKnowledgeToast({ count: len, name: (latest && latest.name) || null });
    // Fixed display window; we DON'T restart the timer on subsequent updates,
    // so a steady stream of sources can't keep the toast pinned forever — it
    // shows, updates its content in place, then dismisses ~10s later. Skip while
    // the pointer is parked on it (a fresh window starts on mouse-leave).
    if (!toastTimerRef.current && !toastHoverRef.current) startToastTimer(TOAST_MS);
  }, [streaming, liveSourcesLen]);

  // Clear the dismiss timer on unmount.
  useCiE(() => () => { if (toastTimerRef.current) clearTimeout(toastTimerRef.current); }, []);

  // Wheel-driven manual control + rapid-flick snapping. Re-registers per turn:
  // ResultInspector stays mounted across the "No turn yet" → first-turn
  // transition (the scroll container only mounts once `turn` is non-null), so
  // an empty-deps effect would attach the listener before the element exists
  // and never re-run. Keying on `turnId` re-binds once the container appears.
  useCiE(() => {
    const el = scrollRef.current;
    if (!el) return;
    // A flick is detected by VELOCITY, not distance alone: only a burst that
    // covers FLICK_DISTANCE_PX within FLICK_TIME_MS counts. Slow scrolling never
    // qualifies no matter how far it travels — its burst clock runs past the
    // time limit before the distance is reached. A pause longer than
    // FLICK_GAP_MS, or a direction reversal, starts a fresh burst.
    const FLICK_DISTANCE_PX = 1000, FLICK_TIME_MS = 180, FLICK_GAP_MS = 90, AT_BOTTOM_PX = 8;
    let accum = 0, burstStart = 0, lastTs = 0;
    const onWheel = (e) => {
      const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
      if (now - lastTs > FLICK_GAP_MS || (accum > 0) !== (e.deltaY > 0)) {
        accum = 0;
        burstStart = now;        // pause or reversal → restart the burst clock
      }
      lastTs = now;
      accum += e.deltaY;
      // Scrolling up means the user is reading history → stop auto-following.
      // Scrolling down does NOT disable: a downward gesture heads back toward
      // the live step, and the scroll listener below re-arms once it lands at
      // the bottom. (Disabling on every wheel would let trailing momentum
      // events undo a down-flick's re-arm.)
      if (e.deltaY < 0) autoFollowRef.current = false;
      // Fast continuous flick: enough distance, covered quickly enough.
      if (Math.abs(accum) >= FLICK_DISTANCE_PX && now - burstStart <= FLICK_TIME_MS) {
        if (accum < 0) {
          el.scrollTo({ top: 0, behavior: 'smooth' });            // up → result
        } else {
          el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); // down → current step
          if (streamingRef.current) autoFollowRef.current = true;  // re-arm if unfinished
        }
        accum = 0;
        burstStart = now;
      }
    };
    // Reaching the bottom while the task is still streaming resumes auto-follow
    // — covers both a deliberate scroll-to-bottom and the landing of a
    // down-flick snap, regardless of how momentum events fire.
    const onScroll = () => {
      if (streamingRef.current &&
          el.scrollHeight - el.scrollTop - el.clientHeight < AT_BOTTOM_PX) {
        autoFollowRef.current = true;
      }
    };
    el.addEventListener('wheel', onWheel, { passive: true });
    el.addEventListener('scroll', onScroll, { passive: true });
    return () => {
      el.removeEventListener('wheel', onWheel);
      el.removeEventListener('scroll', onScroll);
    };
  }, [turnId]);

  // Switching turns resets manual control and picks the right initial focus
  // (bottom while streaming, top once done). This also fires on natural
  // completion: finishing swaps the streaming draft turn (`__draft__`) for a
  // finalized turn with a new id (`convId#N`), so `turnId` changes and the
  // result is focused at the top.
  useCiL(() => {
    const el = scrollRef.current;
    autoFollowRef.current = true;
    prevStreamingRef.current = streaming;
    // Drop any pending toast from the turn we're leaving.
    prevSrcLenRef.current = 0;
    toastHoverRef.current = false;
    setKnowledgeToast(null);
    if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); toastTimerRef.current = null; }
    if (!el) return;
    el.scrollTop = streaming ? el.scrollHeight : 0;
  }, [turnId]);

  // Follow the in-progress step as new steps stream in.
  useCiL(() => {
    const el = scrollRef.current;
    if (el && streaming && autoFollowRef.current) el.scrollTop = el.scrollHeight;
  }, [liveCount, streaming]);

  // Safety net for an in-place streaming→done transition (turn id unchanged):
  // focus the result at the top, unless the user took manual control. Plain
  // useEffect (not layout) — the scroll is `smooth`/async, so it needn't block
  // paint. When completion also changes turnId (the common __draft__→convId#N
  // swap), the [turnId] effect above already ran and set prevStreamingRef to
  // the new value, so `wasStreaming` is false here and this no-ops — intended,
  // the turn-switch effect handles that path.
  useCiE(() => {
    const el = scrollRef.current;
    const wasStreaming = prevStreamingRef.current;
    prevStreamingRef.current = streaming;
    if (el && wasStreaming && !streaming && autoFollowRef.current) {
      el.scrollTo({ top: 0, behavior: 'smooth' });
    }
  }, [streaming]);

  if (!turn) {
    return (
      <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 48 }}>
        <div style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>
          <Icons.Box size={28} style={{ opacity: 0.4 }}/>
          <div className="t-h3 text-secondary" style={{ marginTop: 10 }}>No turn yet</div>
          <div className="t-small text-tertiary" style={{ marginTop: 4 }}>Ask something to see payloads, sources, and steps here.</div>
        </div>
      </div>
    );
  }
  const resp = turn.response;
  const payloads = (resp && resp.result && resp.result.payloads) || [];
  // Knowledge used: stream live `knowledge` events while the turn runs, then
  // align to the authoritative final union once `result.sources` lands.
  const sources  = streaming
    ? (turn.liveSources || [])
    : ((resp && resp.result && resp.result.sources) || []);
  const jumpToKnowledge = () => {
    const el = scrollRef.current;
    if (el) el.scrollTo({ top: 0, behavior: 'smooth' });
    // Hand control to the user so the next streamed step doesn't yank them back
    // down to the timeline while they're reading the knowledge.
    autoFollowRef.current = false;
  };
  return (
    <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
      <div style={{
        padding: '12px 20px',
        borderBottom: '1px solid var(--border-subtle)',
        display: 'flex', alignItems: 'center', gap: 10,
      }}>
        <div className="t-h3" style={{ margin: 0 }}>Result inspector</div>
        <span className="mono-sm mono" style={{
          padding: '2px 8px', borderRadius: 10,
          background: pinnedToLatest ? 'var(--success-bg)' : 'var(--accent-muted)',
          color:      pinnedToLatest ? 'var(--success)'    : 'var(--accent-text)',
          fontSize: 11,
        }}>
          Turn {turnIndex + 1}/{totalTurns} {pinnedToLatest ? '· live' : '· pinned'}
        </span>
        <div style={{ flex: 1 }}/>
        {!pinnedToLatest && (
          <button className="btn sm" onClick={onJumpLatest} title="Follow latest turn">
            Latest <Icons.ChevronD size={11}/>
          </button>
        )}
      </div>
      <div style={{ flex: 1, minHeight: 0, position: 'relative', display: 'flex', flexDirection: 'column' }}>
        <div ref={scrollRef} style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
          {/* Payloads are hidden until the turn finishes — they only exist once a
              result lands, so there's nothing meaningful to show mid-stream. */}
          {!streaming && <PayloadsPanel payloads={payloads}/>}
          <KnowledgePanel sources={sources} streaming={streaming}/>
          <StepsPanel turn={turn}/>
        </div>
        {knowledgeToast && (
          <KnowledgeToast
            onClick={jumpToKnowledge}
            onMouseEnter={pauseToast}
            onMouseLeave={resumeToast}/>
        )}
      </div>
    </div>
  );
}

function SectionHeader({ icon, label, count, accessory }) {
  const I = icon ? Icons[icon] : null;
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '14px 20px 8px' }}>
      {I && <I size={13} style={{ color: 'var(--text-tertiary)' }}/>}
      <span className="t-micro text-tertiary">{label}</span>
      {count != null && <span className="mono-sm mono text-tertiary">· {count}</span>}
      {accessory}
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Payloads
// ──────────────────────────────────────────────────────────────────────────
// Only rendered for a finished turn (the parent hides it while streaming), so
// there is no streaming/loading state here.
function PayloadsPanel({ payloads }) {
  const [selected, setSelected] = useCiS(0);
  useCiE(() => { setSelected(0); }, [payloads.map(p => p.filename).join('|')]);
  return (
    <section style={{ borderBottom: '1px solid var(--border-subtle)' }}>
      <SectionHeader icon="Box" label="Payloads" count={payloads.length || null}/>
      <div style={{ padding: '0 20px 16px' }}>
        {payloads.length === 0 ? (
          <EmptyBlock msg="No payloads for this answer."/>
        ) : (
          <>
            <div style={{ display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 4 }}>
              {payloads.map((p, i) => (
                <PayloadChip key={p.filename} payload={p} active={i === selected} onClick={() => setSelected(i)}/>
              ))}
            </div>
            <div style={{ marginTop: 12 }}>
              <PayloadViewer payload={payloads[selected]}/>
            </div>
          </>
        )}
      </div>
    </section>
  );
}

function PayloadChip({ payload, active, onClick }) {
  const iconName = payload.type === 'table' ? 'Table' : payload.type === 'chart' ? 'Activity' : 'Metric';
  const I = Icons[iconName];
  return (
    <button onClick={onClick} style={{
      display: 'flex', alignItems: 'center', gap: 6,
      padding: '5px 10px', borderRadius: 7, whiteSpace: 'nowrap',
      fontSize: 12, fontWeight: 500,
      border: '1px solid ' + (active ? 'var(--text-primary)' : 'var(--border-subtle)'),
      background: active ? 'var(--selected)' : 'var(--surface)',
      color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
    }}>
      <I size={12}/>
      <span>{payload.type}</span>
      <span className="mono-sm mono text-tertiary" style={{ fontSize: 11 }}>{payload.format}</span>
    </button>
  );
}

function PayloadViewer({ payload }) {
  const [state, setState] = useCiS({ loading: false, content: null, error: null });
  useCiE(() => {
    if (!payload) { setState({ loading: false, content: null, error: null }); return; }
    let cancelled = false;
    let got = null;
    setState({ loading: true, content: null, error: null });
    fetchFile(payload.url || payload.filename)
      .then(c => { got = c; if (!cancelled) setState({ loading: false, content: c, error: null }); })
      .catch(e => { if (!cancelled) setState({ loading: false, content: null, error: e.message || String(e) }); });
    return () => {
      cancelled = true;
      if (got && got.kind === 'blobUrl' && got.body) URL.revokeObjectURL(got.body);
    };
  }, [payload && (payload.url || payload.filename)]);

  if (!payload) return null;
  if (state.loading) return <div className="skeleton" style={{ height: 160 }}/>;
  if (state.error) return <EmptyBlock msg={`Failed to load · ${state.error}`} tone="error"/>;
  const c = state.content;
  if (!c) return null;
  if (payload.type === 'metric') return <MetricCard data={c.body}/>;
  if (payload.type === 'chart') {
    if (c.kind === 'svgDataUrl' || c.kind === 'blobUrl') return <ChartImage src={c.body}/>;
    return <EmptyBlock msg="Chart format not renderable."/>;
  }
  if (payload.type === 'table') {
    if (c.kind === 'text') return <CsvTable csv={c.body} filename={payload.filename}/>;
    if (c.kind === 'json') return <JsonPreview data={c.body}/>;
  }
  return <JsonPreview data={c.body}/>;
}

function MetricCard({ data }) {
  if (data && typeof data === 'object' && data.value != null) {
    return (
      <div style={{ padding: '18px 20px', border: '1px solid var(--border-subtle)', borderRadius: 10, background: 'var(--surface)' }}>
        {data.label && <div className="t-micro text-tertiary">{data.label}</div>}
        <div style={{ fontSize: 36, lineHeight: '44px', fontWeight: 500, letterSpacing: '-0.02em', marginTop: 4 }}>
          {data.value}
        </div>
        <div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap', alignItems: 'center' }}>
          {data.delta && <span className="chip accent"><span className="dot"/> {data.delta}</span>}
          {data.period && <span className="mono-sm mono text-tertiary">{data.period}</span>}
          {data.source && <span className="mono-sm mono text-tertiary">· {data.source}</span>}
        </div>
      </div>
    );
  }
  return <JsonPreview data={data}/>;
}

function CsvTable({ csv, filename }) {
  const rows = useCiM(() => csv.split(/\r?\n/).filter(Boolean).map(r => r.split(',')), [csv]);
  const header = rows[0] || [];
  const body = rows.slice(1);
  const MAX = 50;
  return (
    <div style={{ border: '1px solid var(--border-subtle)', borderRadius: 10, overflow: 'hidden' }}>
      <div style={{ maxHeight: 280, overflowY: 'auto' }}>
        <table className="parcle" style={{ fontSize: 12 }}>
          <thead><tr>{header.map((h, i) => <th key={i}>{h}</th>)}</tr></thead>
          <tbody>
            {body.slice(0, MAX).map((r, i) => (
              <tr key={i} style={{ cursor: 'default' }}>{r.map((c, j) => <td key={j} className="mono" style={{ fontSize: 12 }}>{c}</td>)}</tr>
            ))}
          </tbody>
        </table>
      </div>
      <div style={{ padding: '6px 12px', borderTop: '1px solid var(--border-subtle)', display: 'flex', justifyContent: 'space-between' }}>
        <span className="mono-sm mono text-tertiary">{body.length} row{body.length !== 1 ? 's' : ''}{body.length > MAX ? ` · showing ${MAX}` : ''}</span>
        <span className="mono-sm mono text-tertiary">{filename}</span>
      </div>
    </div>
  );
}

function ChartImage({ src }) {
  return (
    <div style={{ padding: 12, border: '1px solid var(--border-subtle)', borderRadius: 10, background: 'var(--surface)', display: 'flex', justifyContent: 'center' }}>
      <img src={src} style={{ maxWidth: '100%', borderRadius: 6 }} alt=""/>
    </div>
  );
}

function JsonPreview({ data }) {
  return <CodeBlock language="json">{JSON.stringify(data, null, 2)}</CodeBlock>;
}

// ──────────────────────────────────────────────────────────────────────────
// Knowledge used
// ──────────────────────────────────────────────────────────────────────────
function KnowledgePanel({ sources, streaming }) {
  const groups = useCiM(() => {
    const g = { concept: [], database: [], file: [] };
    (sources || []).forEach(s => { if (g[s.type]) g[s.type].push(s); });
    return g;
  }, [sources]);
  const tabs = [
    { id: 'concept',  label: 'Concepts', icon: 'Sparkle', items: groups.concept },
    { id: 'database', label: 'Tables',   icon: 'Table',   items: groups.database },
    { id: 'file',     label: 'Files',    icon: 'Doc',     items: groups.file },
  ].filter(t => t.items.length > 0);
  const [active, setActive] = useCiS(tabs[0] ? tabs[0].id : 'concept');
  useCiE(() => {
    if (tabs.length && !tabs.find(t => t.id === active)) setActive(tabs[0].id);
  }, [sources]);
  const sel = tabs.find(t => t.id === active) || tabs[0];

  const hasSources = tabs.length > 0;
  // Body shows only when there's something to render: live/final sources, or
  // the "no sources" note on a finished turn. While streaming with nothing yet,
  // we collapse to just the header row (count "0" + the spinning collecting
  // badge) instead of an empty placeholder block.
  const showBody = hasSources || !streaming;
  return (
    <section style={{ borderBottom: '1px solid var(--border-subtle)' }}>
      {/* The collecting/collected badge next to the count is the in-progress
          signal: it spins "collecting" while the stream is open and settles to
          a static "collected" once `final` confirms the source set. */}
      <SectionHeader icon="Link" label="Knowledge used"
        count={streaming ? ((sources && sources.length) || 0) : ((sources && sources.length) || null)}
        accessory={(streaming || hasSources) ? <KnowledgeStateBadge done={!streaming}/> : null}/>
      {showBody && (
        <div style={{ padding: '0 20px 16px' }}>
          {!hasSources ? (
            <EmptyBlock msg="No sources referenced."/>
          ) : (
            <>
              <div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--border-subtle)', marginBottom: 12 }}>
                {tabs.map(t => {
                  const I = Icons[t.icon];
                  const on = t.id === active;
                  return (
                    <button key={t.id} onClick={() => setActive(t.id)}
                      style={{
                        display: 'flex', alignItems: 'center', gap: 6,
                        padding: '8px 12px', fontSize: 12, fontWeight: 500,
                        color: on ? 'var(--text-primary)' : 'var(--text-secondary)',
                        borderBottom: `2px solid ${on ? 'var(--text-primary)' : 'transparent'}`,
                        marginBottom: -1,
                      }}>
                      <I size={12}/> {t.label}
                      <span className="mono-sm mono text-tertiary">{t.items.length}</span>
                    </button>
                  );
                })}
              </div>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
                {sel.items.map(s => <SourceRow key={s.id} source={s} group={sel.id} provisional={streaming}/>)}
              </div>
            </>
          )}
        </div>
      )}
    </section>
  );
}

function SourceRow({ source, group, provisional }) {
  const icon = group === 'database' ? 'Table' : group === 'file' ? 'Doc' : 'Sparkle';
  const I = Icons[icon];
  return (
    <div style={{
      display: 'flex', alignItems: 'center', gap: 10,
      padding: '8px 10px', borderRadius: 7,
      // Dashed border while the turn is still streaming = tentative; rows
      // "solidify" to a solid border once `final` confirms the source set.
      border: `1px ${provisional ? 'dashed' : 'solid'} var(--border-subtle)`,
      background: 'var(--surface)',
    }}>
      <I size={14} style={{ color: 'var(--text-tertiary)' }}/>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{source.name}</div>
        <div className="mono-sm mono text-tertiary">{source.id}</div>
      </div>
    </div>
  );
}

// Knowledge-used state pill next to the header count. Spins "collecting" while
// the turn streams; settles to a static "collected" once `final` confirms the
// set. Lives to the right of the count via SectionHeader's `accessory` slot.
function KnowledgeStateBadge({ done }) {
  return (
    <span style={{
      display: 'inline-flex', alignItems: 'center', gap: 5,
      padding: '2px 8px', borderRadius: 10,
      background: done ? 'var(--success-bg)' : 'var(--accent-muted)',
      color:      done ? 'var(--success)'    : 'var(--accent-text)',
      fontSize: 10.5, fontWeight: 500,
    }}>
      {done ? <Icons.Check size={10}/> : <SpinnerDot/>}
      {done ? 'collected' : 'collecting'}
    </span>
  );
}

// Transient knowledge-update toast in the inspector's bottom-right corner: pops
// on each new source, then self-dismisses (visibility is driven by parent
// state, not measured). Elevated surface card matching the app's palette, with
// an accent icon badge and a jump-up affordance. Tapping it jumps back up to
// the Knowledge-used section.
function KnowledgeToast({ onClick, onMouseEnter, onMouseLeave }) {
  return (
    <button onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
      title="Jump to Knowledge used"
      style={{
        position: 'absolute', right: 18, bottom: 18,
        display: 'inline-flex', alignItems: 'center', gap: 12,
        padding: '14px 18px', borderRadius: 14, maxWidth: 380, textAlign: 'left',
        background: 'var(--surface-raised)', color: 'var(--text-primary)',
        border: '1px solid var(--border)', cursor: 'pointer',
        boxShadow: '0 10px 30px rgba(0,0,0,0.16)', zIndex: 5,
        animation: 'parcle-toast-in 200ms ease-out',
      }}>
      <span style={{
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        width: 36, height: 36, borderRadius: 10, flexShrink: 0,
        background: 'var(--accent-muted)', color: 'var(--accent-text)',
      }}>
        <Icons.Link size={18}/>
      </span>
      <span style={{ fontSize: 14, fontWeight: 600 }}>Knowledge use updated</span>
      <Icons.ChevronD size={16} style={{ transform: 'rotate(180deg)', color: 'var(--text-tertiary)', flexShrink: 0, marginLeft: 4 }}/>
    </button>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Steps timeline
// ──────────────────────────────────────────────────────────────────────────
function StepsPanel({ turn }) {
  const streaming = turn.status === 'streaming';
  const steps = (turn.response && turn.response.steps) || [];
  const liveSteps = turn.liveSteps || [];
  // Live steps are full Step objects (api_contract_ask.md): each carries a
  // final per-step status (`done` / `failed` / `skipped`), so they render with
  // the same StepRow visual as the final steps list. The overall ask spinner
  // (top of the chat bubble) plus a trailing pending row convey "more may
  // arrive" while the stream is still open.
  const data = streaming ? liveSteps : steps;
  const empty = data.length === 0 && !streaming;
  return (
    <section>
      <SectionHeader icon="Activity" label="Steps" count={data.length || null}/>
      <div style={{ padding: '0 20px 24px' }}>
        {empty ? (
          <EmptyBlock msg="No steps yet."/>
        ) : (
          <ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
            {data.map((s, i) => <StepRow key={(s.id || 'step') + '_' + i} step={s}/>)}
            {streaming && <PendingStepRow/>}
          </ol>
        )}
      </div>
    </section>
  );
}

function PendingStepRow() {
  return (
    <li style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
      <SpinnerDot/>
      <span style={{ fontSize: 13, color: 'var(--text-tertiary)', fontStyle: 'italic' }}>Working…</span>
    </li>
  );
}

function StepRow({ step }) {
  const data = step.data || {};
  const sqlEntries = [];
  if (typeof data.sql === 'string' && data.sql.trim()) {
    sqlEntries.push({ label: 'SQL', sql: data.sql.trim() });
  }
  if (data.sqls && typeof data.sqls === 'object' && !Array.isArray(data.sqls)) {
    for (const [engine, q] of Object.entries(data.sqls)) {
      if (typeof q === 'string' && q.trim()) {
        sqlEntries.push({ label: engine, sql: q.trim() });
      }
    }
  }
  const thought = typeof step.thought === 'string' && step.thought.trim() ? step.thought.trim() : null;
  return (
    <li style={{ display: 'flex', gap: 10, padding: '6px 0', alignItems: 'flex-start' }}>
      <StepDot status={step.status}/>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          <span style={{ fontSize: 13, fontWeight: 500 }}>{step.title}</span>
          <span className="mono-sm mono" style={{
            fontSize: 11,
            color: step.status === 'failed' ? 'var(--error)'
                 : step.status === 'skipped' ? 'var(--text-tertiary)'
                 : 'var(--success)',
          }}>{step.status}</span>
        </div>
        {thought && (
          <div style={{
            display: 'flex', gap: 8, alignItems: 'flex-start',
            marginTop: 6, padding: '8px 12px',
            background: 'var(--accent-muted)',
            borderLeft: '3px solid var(--accent)',
            borderRadius: '0 6px 6px 0',
            fontSize: 13, lineHeight: 1.55,
            color: 'var(--accent-text)',
          }}>
            <Icons.Sparkle size={12} style={{ flexShrink: 0, marginTop: 3 }}/>
            <span style={{ fontStyle: 'italic' }}>{thought}</span>
          </div>
        )}
        {sqlEntries.length > 0 && (
          <div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
            {sqlEntries.map((e, i) => (
              <SqlBlock key={e.label + '_' + i} sql={e.sql} label={e.label}/>
            ))}
          </div>
        )}
        {step.message && <StepMessage text={step.message}/>}
      </div>
    </li>
  );
}

// Step message with 3-line preview + Show more / Show less.
// Long-form messages (3+ newlines or > 280 chars) collapse by default; short
// ones render in full. State lives here so StepRow stays a pure function.
function StepMessage({ text }) {
  const [open, setOpen] = useCiS(false);
  const long = text.split('\n').length > 3 || text.length > 280;
  const baseStyle = {
    fontSize: 12, marginTop: 6,
    color: 'var(--text-secondary)',
    whiteSpace: 'pre-wrap',
    lineHeight: 1.5,
  };
  if (!long) return <div style={baseStyle}>{text}</div>;
  const collapsedStyle = open ? baseStyle : {
    ...baseStyle,
    display: '-webkit-box',
    WebkitLineClamp: 3,
    WebkitBoxOrient: 'vertical',
    overflow: 'hidden',
    // maxHeight = 3 lines × lineHeight 1.5 — fallback if -webkit-box is disabled.
    maxHeight: '4.5em',
  };
  return (
    <div style={{ marginTop: 6 }}>
      <div style={collapsedStyle}>{text}</div>
      <button onClick={() => setOpen(v => !v)}
        style={{
          fontSize: 11, marginTop: 4, padding: 0,
          background: 'transparent', color: 'var(--accent-text)',
          border: 'none', cursor: 'pointer',
        }}>
        {open ? 'Show less' : 'Show more'}
      </button>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// SQL block: keyword-highlighted, wrapped, copy button. Used inside steps
// to surface the generated SQL without forcing the user to open raw JSON.
// ──────────────────────────────────────────────────────────────────────────

const SQL_KEYWORDS = new Set([
  'SELECT','FROM','WHERE','GROUP','BY','ORDER','HAVING','LIMIT','OFFSET',
  'JOIN','LEFT','RIGHT','INNER','OUTER','FULL','CROSS','ON','USING','AS',
  'IN','AND','OR','NOT','IS','LIKE','ILIKE','BETWEEN','EXISTS','DISTINCT',
  'CASE','WHEN','THEN','ELSE','END','NULL','TRUE','FALSE',
  'INSERT','INTO','VALUES','UPDATE','SET','DELETE','CREATE','TABLE','VIEW',
  'INDEX','DROP','ALTER','WITH','UNION','ALL','ANY','SOME','ASC','DESC',
  'CAST','CONVERT','OVER','PARTITION','ROW_NUMBER','RANK','DENSE_RANK',
]);

function highlightSql(sql) {
  const re = /(\s+)|(--[^\n]*|\/\*[\s\S]*?\*\/)|('(?:[^']|'')*')|("(?:[^"]|"")*")|(\b\d+(?:\.\d+)?\b)|([A-Za-z_][\w]*)|([(),;.*=<>+\-\/%!|&~^]+)|(.)/g;
  const out = [];
  let m; let i = 0;
  while ((m = re.exec(sql)) !== null) {
    const [, ws, comment, sqStr, dqStr, num, ident, punct, other] = m;
    const key = 't' + (i++);
    if (ws) {
      out.push(ws);
    } else if (comment) {
      out.push(<span key={key} style={{ color: 'var(--text-tertiary)', fontStyle: 'italic' }}>{comment}</span>);
    } else if (sqStr || dqStr) {
      out.push(<span key={key} style={{ color: 'var(--success)' }}>{sqStr || dqStr}</span>);
    } else if (num) {
      out.push(<span key={key} style={{ color: 'var(--accent-text)' }}>{num}</span>);
    } else if (ident) {
      if (SQL_KEYWORDS.has(ident.toUpperCase())) {
        out.push(<span key={key} style={{ color: 'var(--accent)', fontWeight: 600 }}>{ident}</span>);
      } else {
        out.push(ident);
      }
    } else if (punct) {
      out.push(<span key={key} style={{ color: 'var(--text-tertiary)' }}>{punct}</span>);
    } else if (other) {
      out.push(other);
    }
  }
  return out;
}

function SqlBlock({ sql, label = 'SQL' }) {
  const [copied, setCopied] = useCiS(false);
  const tokens = useCiM(() => highlightSql(sql), [sql]);
  const timerRef = useCiR(null);
  useCiE(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
  const onCopy = async () => {
    try {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        await navigator.clipboard.writeText(sql);
        setCopied(true);
        if (timerRef.current) clearTimeout(timerRef.current);
        timerRef.current = setTimeout(() => setCopied(false), 1500);
      }
    } catch {
      // clipboard blocked (non-HTTPS / permission denied) — leave button as "Copy"
    }
  };
  return (
    <div style={{
      background: 'var(--bg)', border: '1px solid var(--border-subtle)',
      borderRadius: 8, overflow: 'hidden',
    }}>
      <div style={{
        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        padding: '6px 10px 6px 12px', borderBottom: '1px solid var(--border-subtle)',
        background: 'var(--surface)',
      }}>
        <span className="mono-sm mono text-tertiary" style={{ letterSpacing: 0.3 }}>{label}</span>
        <button className="btn sm ghost" onClick={onCopy}>
          <Icons.Copy size={12}/> {copied ? 'Copied' : 'Copy'}
        </button>
      </div>
      <pre className="mono" style={{
        margin: 0, padding: '10px 14px',
        fontSize: 12, lineHeight: '20px',
        whiteSpace: 'pre-wrap', wordBreak: 'break-word',
        overflowX: 'auto', color: 'var(--text-primary)',
      }}>{tokens}</pre>
    </div>
  );
}

function StepDot({ status }) {
  const color =
    status === 'failed' ? 'var(--error)' :
    status === 'skipped' ? 'var(--text-tertiary)' :
    'var(--success)';
  const glyph = status === 'failed' ? '✕' : status === 'skipped' ? '–' : '✓';
  return (
    <span style={{
      width: 16, height: 16, borderRadius: '50%', flexShrink: 0,
      background: color, color: 'var(--bg)',
      display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
      fontSize: 10, fontWeight: 600, lineHeight: 1, marginTop: 3,
    }}>{glyph}</span>
  );
}

function EmptyBlock({ msg, tone }) {
  return (
    <div style={{
      padding: '18px 14px', borderRadius: 8,
      border: '1px dashed var(--border-subtle)',
      textAlign: 'center', fontSize: 12,
      color: tone === 'error' ? 'var(--error)' : 'var(--text-tertiary)',
    }}>{msg}</div>
  );
}
