reasonix 0.12.8 → 0.12.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard/app.js CHANGED
@@ -1164,11 +1164,54 @@ function ChatPanel() {
1164
1164
  // the input area.
1165
1165
  const [stats, setStats] = useState(null);
1166
1166
  const [overviewModel, setOverviewModel] = useState(null);
1167
+ // Whether the project has a built semantic index. Null = unknown
1168
+ // (poll hasn't landed) or non-attached. False = no index → show the
1169
+ // dismissible banner. True = index built → hide it.
1170
+ const [semanticIndex, setSemanticIndex] = useState(null);
1171
+ const [semanticBannerDismissed, setSemanticBannerDismissed] = useState(() => {
1172
+ try {
1173
+ return localStorage.getItem("rx.semanticBannerDismissed") === "1";
1174
+ } catch {
1175
+ return false;
1176
+ }
1177
+ });
1178
+ useEffect(() => {
1179
+ try {
1180
+ localStorage.setItem("rx.semanticBannerDismissed", semanticBannerDismissed ? "1" : "0");
1181
+ } catch {
1182
+ /* ignore */
1183
+ }
1184
+ }, [semanticBannerDismissed]);
1185
+ // Wall-clock timestamp the current turn started at — populated when
1186
+ // busy flips true, cleared when it flips false. Drives the "elapsed
1187
+ // Ns" readout in the in-flight indicator. Refreshed once per second
1188
+ // by `nowTick` so the seconds counter ticks visibly even between
1189
+ // SSE deltas.
1190
+ const [turnStartedAt, setTurnStartedAt] = useState(null);
1191
+ const [nowTick, setNowTick] = useState(0);
1192
+ useEffect(() => {
1193
+ if (!busy) return;
1194
+ const id = setInterval(() => setNowTick((n) => n + 1), 500);
1195
+ return () => clearInterval(id);
1196
+ }, [busy]);
1197
+ useEffect(() => {
1198
+ if (busy) {
1199
+ if (!turnStartedAt) setTurnStartedAt(Date.now());
1200
+ } else {
1201
+ setTurnStartedAt(null);
1202
+ }
1203
+ }, [busy, turnStartedAt]);
1167
1204
  // Sticks to bottom only while the user is already near the bottom.
1168
1205
  // Once they scroll up to read older content the streaming deltas no
1169
1206
  // longer yank the view back. Re-armed when they scroll back to the
1170
1207
  // bottom on their own. 80px threshold absorbs sub-pixel rounding.
1171
1208
  const shouldAutoScroll = useRef(true);
1209
+ // Ref to the scrollable feed container so we don't have to rely on
1210
+ // a global querySelector (which would race the conditional render
1211
+ // — `.chat-feed` only mounts when at least one message is present).
1212
+ // The feed is now always rendered, so `feedRef.current` is set on
1213
+ // first paint and the scroll listener attaches once.
1214
+ const feedRef = useRef(null);
1172
1215
 
1173
1216
  // Initial snapshot — messages + busy + any modal already up.
1174
1217
  useEffect(() => {
@@ -1379,10 +1422,22 @@ function ChatPanel() {
1379
1422
  // immediately. The threshold is generous enough that overshoot
1380
1423
  // (smooth-scroll rebound, sub-pixel rounding) doesn't accidentally
1381
1424
  // re-arm tracking when the user is barely above bottom.
1425
+ //
1426
+ // We also distinguish *user* scroll events from auto-scroll's own
1427
+ // programmatic `scrollTop = scrollHeight` writes. Without that gate
1428
+ // the auto-scroll effect would briefly snap to bottom, fire its
1429
+ // own scroll event, re-set shouldAutoScroll = true, then wonder
1430
+ // why the user complained that they couldn't scroll up — because
1431
+ // every wheel-up was racing against the next delta's auto-snap.
1432
+ // We mark the ref as `auto-scrolling` for one tick around the
1433
+ // programmatic write; the listener ignores events it sees during
1434
+ // that window.
1435
+ const autoScrollInFlight = useRef(false);
1382
1436
  useEffect(() => {
1383
- const el = document.querySelector(".chat-feed");
1437
+ const el = feedRef.current;
1384
1438
  if (!el) return;
1385
1439
  const onScroll = () => {
1440
+ if (autoScrollInFlight.current) return;
1386
1441
  const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
1387
1442
  shouldAutoScroll.current = distFromBottom < 80;
1388
1443
  };
@@ -1394,8 +1449,16 @@ function ChatPanel() {
1394
1449
  // deltas no longer yank the view back; manual wheel/drag wins.
1395
1450
  useEffect(() => {
1396
1451
  if (!shouldAutoScroll.current) return;
1397
- const el = document.querySelector(".chat-feed");
1398
- if (el) el.scrollTop = el.scrollHeight;
1452
+ const el = feedRef.current;
1453
+ if (!el) return;
1454
+ autoScrollInFlight.current = true;
1455
+ el.scrollTop = el.scrollHeight;
1456
+ // Clear the gate after the browser has had a chance to fire the
1457
+ // resulting scroll event (microtask-ish — rAF is overkill, a 0ms
1458
+ // setTimeout is enough to land after the synchronous handler).
1459
+ setTimeout(() => {
1460
+ autoScrollInFlight.current = false;
1461
+ }, 0);
1399
1462
  }, [messages, streaming]);
1400
1463
 
1401
1464
  const allMessages = streaming
@@ -1440,6 +1503,7 @@ function ChatPanel() {
1440
1503
  setEffortLocal(o.reasoningEffort ?? null);
1441
1504
  setStats(o.stats ?? null);
1442
1505
  setOverviewModel(o.model ?? null);
1506
+ setSemanticIndex(o.semanticIndexExists);
1443
1507
  } catch {
1444
1508
  /* swallow */
1445
1509
  }
@@ -1574,11 +1638,31 @@ function ChatPanel() {
1574
1638
  </div>
1575
1639
 
1576
1640
  ${
1577
- busy
1578
- ? html`<div class="chat-status"><span class="spinner"></span> turn in flight · <button onClick=${abort}>Abort (Esc)</button>${statusLine ? html` · <span class="muted">${statusLine}</span>` : null}</div>`
1579
- : statusLine
1580
- ? html`<div class="chat-status"><span class="muted">${statusLine}</span></div>`
1581
- : null
1641
+ !busy && statusLine
1642
+ ? html`<div class="chat-status"><span class="muted">${statusLine}</span></div>`
1643
+ : null
1644
+ }
1645
+ ${
1646
+ semanticIndex === false && !semanticBannerDismissed
1647
+ ? html`<div class="chat-banner">
1648
+ <span class="chat-banner-icon">≈</span>
1649
+ <span class="chat-banner-text">
1650
+ <strong>Semantic search isn't enabled for this project.</strong>
1651
+ <span class="muted">
1652
+ Build the index once and the model can find code by meaning ("where do we handle auth failures?") instead of grep on exact strings.
1653
+ </span>
1654
+ </span>
1655
+ <button
1656
+ class="primary"
1657
+ onClick=${() => appBus.dispatchEvent(new CustomEvent("navigate-tab", { detail: { tabId: "semantic" } }))}
1658
+ >Build it →</button>
1659
+ <button
1660
+ class="chat-banner-close"
1661
+ onClick=${() => setSemanticBannerDismissed(true)}
1662
+ title="dismiss (don't show again)"
1663
+ >×</button>
1664
+ </div>`
1665
+ : null
1582
1666
  }
1583
1667
  ${error ? html`<div class="notice err">${error}</div>` : null}
1584
1668
 
@@ -1596,23 +1680,23 @@ function ChatPanel() {
1596
1680
  : null
1597
1681
  }
1598
1682
 
1599
- ${
1600
- allMessages.length === 0
1601
- ? html`<div class="chat-empty">No conversation yet. Send a prompt below to begin.</div>`
1602
- : html`
1603
- <div class="chat-feed">
1604
- ${allMessages.map(
1605
- (m) => html`
1606
- <${ChatMessage}
1607
- key=${m.id}
1608
- msg=${m}
1609
- streaming=${streaming && streaming.id === m.id}
1610
- />
1611
- `,
1612
- )}
1613
- </div>
1614
- `
1615
- }
1683
+ <div class="chat-feed" ref=${feedRef}>
1684
+ ${
1685
+ allMessages.length === 0
1686
+ ? html`<div class="chat-empty">
1687
+ No conversation yet. Send a prompt below to begin.
1688
+ </div>`
1689
+ : allMessages.map(
1690
+ (m) => html`
1691
+ <${ChatMessage}
1692
+ key=${m.id}
1693
+ msg=${m}
1694
+ streaming=${streaming && streaming.id === m.id}
1695
+ />
1696
+ `,
1697
+ )
1698
+ }
1699
+ </div>
1616
1700
 
1617
1701
  <div class="chat-input-area">
1618
1702
  <textarea
@@ -1636,11 +1720,65 @@ function ChatPanel() {
1636
1720
  </div>
1637
1721
  </div>
1638
1722
 
1723
+ ${
1724
+ busy
1725
+ ? html`<${InFlightRow}
1726
+ streaming=${streaming}
1727
+ startedAt=${turnStartedAt}
1728
+ statusLine=${statusLine}
1729
+ onAbort=${abort}
1730
+ tick=${nowTick}
1731
+ />`
1732
+ : null
1733
+ }
1639
1734
  <${ChatStatusBar} stats=${stats} model=${overviewModel} />
1640
1735
  </div>
1641
1736
  `;
1642
1737
  }
1643
1738
 
1739
+ // Live "what's the model doing right now" strip. Lives just above the
1740
+ // ChatStatusBar so the user's eyes don't have to leave the input area
1741
+ // to see whether the turn is alive — ticks every 500ms via the parent's
1742
+ // nowTick so the seconds counter shows visible motion even when the
1743
+ // SSE stream is silent (model thinking, waiting on a tool, etc).
1744
+ function InFlightRow({ streaming, startedAt, statusLine, onAbort, tick: _tick }) {
1745
+ const elapsedMs = startedAt ? Date.now() - startedAt : 0;
1746
+ const elapsed = (elapsedMs / 1000).toFixed(1);
1747
+ const reasoningLen = streaming?.reasoning?.length ?? 0;
1748
+ const textLen = streaming?.text?.length ?? 0;
1749
+ const phase =
1750
+ reasoningLen > 0 && textLen === 0 ? "thinking" : textLen > 0 ? "streaming" : "waiting";
1751
+ return html`
1752
+ <div class="chat-inflight">
1753
+ <span class="spinner"></span>
1754
+ <span class="chat-inflight-phase">${phase}</span>
1755
+ <span class="chat-inflight-sep">·</span>
1756
+ <span class="muted">${elapsed}s</span>
1757
+ ${
1758
+ textLen > 0 || reasoningLen > 0
1759
+ ? html`
1760
+ <span class="chat-inflight-sep">·</span>
1761
+ <span class="muted">
1762
+ ${reasoningLen > 0 ? html`reasoning ${reasoningLen.toLocaleString()} ch` : null}
1763
+ ${reasoningLen > 0 && textLen > 0 ? html`<span> · </span>` : null}
1764
+ ${textLen > 0 ? html`out ${textLen.toLocaleString()} ch` : null}
1765
+ </span>
1766
+ `
1767
+ : null
1768
+ }
1769
+ ${
1770
+ statusLine
1771
+ ? html`
1772
+ <span class="chat-inflight-sep">·</span>
1773
+ <span class="muted">${statusLine}</span>
1774
+ `
1775
+ : null
1776
+ }
1777
+ <button class="chat-inflight-abort" onClick=${onAbort}>Abort (Esc)</button>
1778
+ </div>
1779
+ `;
1780
+ }
1781
+
1644
1782
  // ---------- Chat status bar ----------
1645
1783
  //
1646
1784
  // Mirrors the TUI's StatsPanel — turn / session cost, cache hit %,
@@ -2722,6 +2860,321 @@ function SkillsPanel() {
2722
2860
 
2723
2861
  // ---------- MCP ----------
2724
2862
 
2863
+ // ---------- Semantic index ----------
2864
+
2865
+ function SemanticPanel() {
2866
+ const [data, setData] = useState(null);
2867
+ const [error, setError] = useState(null);
2868
+ const [busy, setBusy] = useState(false);
2869
+ const [info, setInfo] = useState(null);
2870
+
2871
+ const load = useCallback(async () => {
2872
+ try {
2873
+ const r = await api("/semantic");
2874
+ setData(r);
2875
+ } catch (err) {
2876
+ setError(err.message);
2877
+ }
2878
+ }, []);
2879
+
2880
+ // Poll fast while a job is running OR while ollama is pulling a
2881
+ // model (the latest-line readout updates every few hundred ms during
2882
+ // a download). Slow when idle so the panel doesn't burn network just
2883
+ // sitting open in a tab.
2884
+ useEffect(() => {
2885
+ load();
2886
+ const phase = data?.job?.phase;
2887
+ const running = phase === "scan" || phase === "embed" || phase === "write";
2888
+ const pulling = data?.pull?.status === "pulling";
2889
+ const ms = running || pulling ? 1200 : 5000;
2890
+ const id = setInterval(load, ms);
2891
+ return () => clearInterval(id);
2892
+ }, [load, data?.job?.phase, data?.pull?.status]);
2893
+
2894
+ const start = useCallback(
2895
+ async (rebuild) => {
2896
+ setBusy(true);
2897
+ setError(null);
2898
+ setInfo(null);
2899
+ try {
2900
+ await api("/semantic/start", { method: "POST", body: { rebuild: !!rebuild } });
2901
+ setInfo(rebuild ? "rebuild started" : "incremental index started");
2902
+ await load();
2903
+ } catch (err) {
2904
+ setError(err.message);
2905
+ } finally {
2906
+ setBusy(false);
2907
+ }
2908
+ },
2909
+ [load],
2910
+ );
2911
+
2912
+ const stop = useCallback(async () => {
2913
+ setBusy(true);
2914
+ setError(null);
2915
+ try {
2916
+ await api("/semantic/stop", { method: "POST", body: {} });
2917
+ setInfo("stopping requested — current chunk batch will finish first");
2918
+ await load();
2919
+ } catch (err) {
2920
+ setError(err.message);
2921
+ } finally {
2922
+ setBusy(false);
2923
+ }
2924
+ }, [load]);
2925
+
2926
+ const startDaemon = useCallback(async () => {
2927
+ setBusy(true);
2928
+ setError(null);
2929
+ setInfo("starting ollama daemon (15s timeout)…");
2930
+ try {
2931
+ const r = await api("/semantic/ollama/start", { method: "POST", body: {} });
2932
+ setInfo(
2933
+ r.ready ? "daemon is up" : "daemon didn't come up in time — check `ollama serve` manually",
2934
+ );
2935
+ await load();
2936
+ } catch (err) {
2937
+ setError(err.message);
2938
+ } finally {
2939
+ setBusy(false);
2940
+ }
2941
+ }, [load]);
2942
+
2943
+ const pullModel = useCallback(
2944
+ async (model) => {
2945
+ setBusy(true);
2946
+ setError(null);
2947
+ setInfo(`pulling ${model} — this may take a few minutes on first install`);
2948
+ try {
2949
+ await api("/semantic/ollama/pull", { method: "POST", body: { model } });
2950
+ await load();
2951
+ } catch (err) {
2952
+ setError(err.message);
2953
+ } finally {
2954
+ setBusy(false);
2955
+ }
2956
+ },
2957
+ [load],
2958
+ );
2959
+
2960
+ if (!data && !error) return html`<div class="boot">loading semantic status…</div>`;
2961
+ if (error && !data) return html`<div class="notice err">${error}</div>`;
2962
+
2963
+ if (data && !data.attached) {
2964
+ return html`
2965
+ <div>
2966
+ <div class="panel-header">
2967
+ <h2 class="panel-title">Semantic</h2>
2968
+ <span class="panel-subtitle">code-mode required</span>
2969
+ </div>
2970
+ <div class="empty">${data.reason}</div>
2971
+ </div>
2972
+ `;
2973
+ }
2974
+
2975
+ const job = data.job;
2976
+ const phase = job?.phase;
2977
+ const running = phase === "scan" || phase === "embed" || phase === "write";
2978
+ const pull = data.pull;
2979
+ const pulling = pull?.status === "pulling";
2980
+
2981
+ // Tri-state Ollama check. Each level gates the next:
2982
+ // binary missing → user must install (we won't run a package
2983
+ // manager on their behalf).
2984
+ // daemon down → one-click start (`ollama serve`).
2985
+ // model missing → one-click pull.
2986
+ // all good → ready to index.
2987
+ const o = data.ollama ?? {};
2988
+ const binaryFound = o.binaryFound === true;
2989
+ const daemonRunning = o.daemonRunning === true;
2990
+ const modelPulled = o.modelPulled === true;
2991
+ const modelName = o.modelName ?? "nomic-embed-text";
2992
+ const installedModels = o.installedModels ?? [];
2993
+ const ready = binaryFound && daemonRunning && modelPulled;
2994
+
2995
+ return html`
2996
+ <div>
2997
+ <div class="panel-header">
2998
+ <h2 class="panel-title">Semantic</h2>
2999
+ <span class="panel-subtitle">${data.index.exists ? "index built" : "no index yet"}</span>
3000
+ </div>
3001
+ ${info ? html`<div class="notice">${info}</div>` : null}
3002
+ ${error ? html`<div class="notice err">${error}</div>` : null}
3003
+
3004
+ <div class="section-title">Status</div>
3005
+ <div class="kv">
3006
+ <div><span class="kv-key">project</span><code>${data.root}</code></div>
3007
+ <div>
3008
+ <span class="kv-key">ollama</span>
3009
+ ${
3010
+ binaryFound
3011
+ ? daemonRunning
3012
+ ? html`<span class="pill pill-ok">reachable</span><span class="muted" style="margin-left: 8px;">${installedModels.length} model(s)${
3013
+ installedModels.length > 0
3014
+ ? ` · ${installedModels.slice(0, 3).join(", ")}${installedModels.length > 3 ? "…" : ""}`
3015
+ : ""
3016
+ }</span>`
3017
+ : html`<span class="pill pill-warn">daemon down</span><span class="muted" style="margin-left: 8px;">binary on PATH but not serving</span>`
3018
+ : html`<span class="pill pill-err">not installed</span><span class="muted" style="margin-left: 8px;">${o.error ?? "ollama binary not on PATH"}</span>`
3019
+ }
3020
+ </div>
3021
+ <div>
3022
+ <span class="kv-key">model</span>
3023
+ <code>${modelName}</code>
3024
+ ${
3025
+ modelPulled
3026
+ ? html`<span class="pill pill-ok" style="margin-left: 8px;">pulled</span>`
3027
+ : daemonRunning
3028
+ ? html`<span class="pill pill-warn" style="margin-left: 8px;">not pulled</span>`
3029
+ : html`<span class="pill pill-dim" style="margin-left: 8px;">unknown (daemon down)</span>`
3030
+ }
3031
+ </div>
3032
+ <div>
3033
+ <span class="kv-key">index</span>
3034
+ ${
3035
+ data.index.exists
3036
+ ? html`<span class="muted">present at <code>.reasonix/semantic/</code></span>`
3037
+ : html`<span class="muted">none — run an index to enable <code>semantic_search</code></span>`
3038
+ }
3039
+ </div>
3040
+ </div>
3041
+
3042
+ ${
3043
+ !binaryFound
3044
+ ? html`
3045
+ <div class="section-title">Install Ollama</div>
3046
+ <div class="card" style="font-size: 13px;">
3047
+ Reasonix doesn't run package managers for you. Install Ollama
3048
+ first, then come back to this panel:
3049
+ <ul style="margin: 10px 0 4px 18px; padding: 0;">
3050
+ <li><strong>macOS / Windows:</strong> download from <a href="https://ollama.com/download" target="_blank" rel="noreferrer">ollama.com/download</a></li>
3051
+ <li><strong>Linux:</strong> <code>curl -fsSL https://ollama.com/install.sh | sh</code></li>
3052
+ </ul>
3053
+ <div class="muted" style="margin-top: 8px;">After install, this panel will offer to start the daemon and pull <code>${modelName}</code> for you. Refresh after installing.</div>
3054
+ </div>
3055
+ `
3056
+ : null
3057
+ }
3058
+
3059
+ ${
3060
+ binaryFound && !daemonRunning
3061
+ ? html`
3062
+ <div class="section-title">Daemon</div>
3063
+ <div class="card" style="font-size: 13px;">
3064
+ <code>ollama</code> is on your PATH but the HTTP daemon isn't reachable.
3065
+ <div class="row" style="margin-top: 10px;">
3066
+ <button class="primary" disabled=${busy} onClick=${startDaemon}>Start daemon</button>
3067
+ <span class="muted" style="font-size: 12px; align-self: center;">runs <code>ollama serve</code> detached — survives Reasonix exit</span>
3068
+ </div>
3069
+ </div>
3070
+ `
3071
+ : null
3072
+ }
3073
+
3074
+ ${
3075
+ daemonRunning && !modelPulled
3076
+ ? html`
3077
+ <div class="section-title">Model</div>
3078
+ <div class="card" style="font-size: 13px;">
3079
+ <code>${modelName}</code> isn't installed yet. ${pulling ? "" : "~270 MB download on first pull."}
3080
+ <div class="row" style="margin-top: 10px;">
3081
+ <button
3082
+ class="primary"
3083
+ disabled=${busy || pulling}
3084
+ onClick=${() => pullModel(modelName)}
3085
+ >${pulling ? "pulling…" : `Pull ${modelName}`}</button>
3086
+ </div>
3087
+ ${
3088
+ pull
3089
+ ? html`
3090
+ <div class="kv" style="margin-top: 10px;">
3091
+ <div>
3092
+ <span class="kv-key">status</span>
3093
+ <span class=${`pill ${pull.status === "done" ? "pill-ok" : pull.status === "error" ? "pill-err" : "pill-active"}`}>${pull.status}</span>
3094
+ <span class="muted" style="margin-left: 8px;">${((Date.now() - pull.startedAt) / 1000).toFixed(1)}s</span>
3095
+ </div>
3096
+ ${
3097
+ pull.lastLine
3098
+ ? html`<div><span class="kv-key">last</span><code style="font-size: 11.5px;">${pull.lastLine}</code></div>`
3099
+ : null
3100
+ }
3101
+ </div>
3102
+ `
3103
+ : null
3104
+ }
3105
+ </div>
3106
+ `
3107
+ : null
3108
+ }
3109
+
3110
+ <div class="section-title">Job</div>
3111
+ ${job ? html`<${SemanticJobView} job=${job} running=${running} />` : html`<div class="muted">No job has run in this dashboard yet.</div>`}
3112
+
3113
+ <div class="row" style="margin-top: 14px;">
3114
+ <button class="primary" disabled=${busy || running || !ready} onClick=${() => start(false)}>Index (incremental)</button>
3115
+ <button disabled=${busy || running || !ready} onClick=${() => start(true)}>Rebuild (wipe + full)</button>
3116
+ <button disabled=${busy || !running} onClick=${stop}>Stop</button>
3117
+ </div>
3118
+ </div>
3119
+ `;
3120
+ }
3121
+
3122
+ function SemanticJobView({ job, running }) {
3123
+ const phaseLabel =
3124
+ {
3125
+ scan: "scanning files",
3126
+ embed: "embedding chunks",
3127
+ write: "writing index",
3128
+ done: "done",
3129
+ error: "error",
3130
+ }[job.phase] ?? job.phase;
3131
+ const total = job.chunksTotal ?? 0;
3132
+ const doneN = job.chunksDone ?? 0;
3133
+ const ratio = total > 0 ? Math.min(1, doneN / total) : 0;
3134
+ const elapsed = ((Date.now() - job.startedAt) / 1000).toFixed(1);
3135
+
3136
+ return html`
3137
+ <div class="kv">
3138
+ <div><span class="kv-key">phase</span>
3139
+ <span class=${`pill ${job.phase === "error" ? "pill-err" : running ? "pill-active" : "pill-dim"}`}>${phaseLabel}</span>
3140
+ ${job.aborted ? html`<span class="pill pill-warn" style="margin-left: 6px;">stopping</span>` : null}
3141
+ <span class="muted" style="margin-left: 8px;">${elapsed}s</span>
3142
+ </div>
3143
+ ${
3144
+ job.filesScanned !== null && job.filesScanned !== undefined
3145
+ ? html`<div><span class="kv-key">files</span>scanned ${job.filesScanned}${
3146
+ job.filesChanged != null ? ` · changed ${job.filesChanged}` : ""
3147
+ }${job.filesSkipped ? ` · skipped ${job.filesSkipped}` : ""}</div>`
3148
+ : null
3149
+ }
3150
+ ${
3151
+ total > 0
3152
+ ? html`
3153
+ <div>
3154
+ <span class="kv-key">chunks</span>${doneN} / ${total} (${(ratio * 100).toFixed(0)}%)
3155
+ </div>
3156
+ <div class="bar" style="margin-top: 4px;">
3157
+ <div class="fill" style=${`width: ${(ratio * 100).toFixed(1)}%; background: var(--primary);`}></div>
3158
+ </div>
3159
+ `
3160
+ : null
3161
+ }
3162
+ ${
3163
+ job.error
3164
+ ? html`<div><span class="kv-key">error</span><span class="err">${job.error}</span></div>`
3165
+ : null
3166
+ }
3167
+ ${
3168
+ job.result
3169
+ ? html`<div><span class="kv-key">result</span>added ${job.result.chunksAdded} · removed ${job.result.chunksRemoved}${
3170
+ job.result.chunksSkipped ? ` · failed ${job.result.chunksSkipped}` : ""
3171
+ } · ${(job.result.durationMs / 1000).toFixed(1)}s</div>`
3172
+ : null
3173
+ }
3174
+ </div>
3175
+ `;
3176
+ }
3177
+
2725
3178
  function McpPanel() {
2726
3179
  const [data, setData] = useState(null);
2727
3180
  const [specs, setSpecs] = useState(null);
@@ -3518,6 +3971,14 @@ const TABS = [
3518
3971
  ready: true,
3519
3972
  badge: null,
3520
3973
  },
3974
+ {
3975
+ id: "semantic",
3976
+ name: "Semantic",
3977
+ glyph: "≈",
3978
+ panel: () => html`<${SemanticPanel} />`,
3979
+ ready: true,
3980
+ badge: null,
3981
+ },
3521
3982
  {
3522
3983
  id: "mcp",
3523
3984
  name: "MCP",