@enigmax/dashboard 0.1.60 → 0.1.63

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.
Files changed (2) hide show
  1. package/assets/index.html +453 -59
  2. package/package.json +1 -1
package/assets/index.html CHANGED
@@ -200,19 +200,92 @@
200
200
  background: var(--surface2); color: var(--text); border: 1px solid var(--border);
201
201
  border-radius: var(--radius-sm); padding: 6px 10px; font-size: 12px; font-family: var(--mono); cursor: pointer;
202
202
  }
203
- .set-note { color: var(--accent); font-size: 12px; min-height: 16px; margin-top: 10px; }
204
- /* A note must read as success vs. failure at a glance, not the same accent for both. */
203
+ /* Top-of-page "update available" banner: spans the content width, amber-tinted so it
204
+ reads as a notice without alarming. Hidden until the stats poll reports an update. */
205
+ .update-alert {
206
+ display: flex; align-items: center; gap: 12px;
207
+ max-width: 1040px; margin: 16px auto -8px; padding: 11px 16px;
208
+ background: rgba(215,135,95,0.10); border: 1px solid rgba(215,135,95,0.45);
209
+ border-radius: var(--radius-sm); color: var(--text); font-size: 13px;
210
+ }
211
+ .update-alert[hidden] { display: none; }
212
+ .update-alert .ua-ico { width: 18px; height: 18px; color: var(--accent); flex: none; }
213
+ .update-alert .ua-msg { flex: 1; }
214
+ .update-alert .ua-x { background: none; border: none; color: var(--muted); font-size: 20px; line-height: 1; cursor: pointer; padding: 0 4px; }
215
+ .update-alert .ua-x:hover { color: var(--text); }
216
+ /* Status notes now surface as toasts; the inline element stays for call compatibility but
217
+ collapses when empty so no leftover green/red line shows. */
218
+ .set-note { color: var(--accent); font-size: 12px; margin-top: 10px; }
219
+ .set-note:empty { display: none; margin: 0; }
205
220
  .set-note.ok { color: var(--good); }
206
221
  .set-note.err { color: var(--bad); }
207
- /* Toasts: failures are easy to miss in a small inline note, so every error is also
208
- raised here - fixed, coloured, and self-dismissing - so a failed action is unmissable. */
209
- #toasts { position: fixed; right: 18px; bottom: 18px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; max-width: 360px; }
210
- .toast { padding: 11px 14px; border-radius: 10px; font-size: 13px; line-height: 1.35; color: var(--text);
211
- background: var(--surface2); border: 1px solid var(--border); box-shadow: var(--shadow-1); animation: toastIn 0.18s ease-out; }
212
- .toast.err { border-color: var(--bad); }
213
- .toast.ok { border-color: var(--good); }
214
- .toast.hide { opacity: 0; transition: opacity 0.3s; }
215
- @keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
222
+ /* Toasts (Sonner-style, ported to vanilla). Stacked bottom-right: the newest is in front,
223
+ older ones peek behind and fan out on hover. Rich-coloured per type, self-dismissing,
224
+ swipe- or click-to-close - so a result is never a cutre green line again. */
225
+ #toaster {
226
+ position: fixed; right: var(--t-offset); bottom: var(--t-offset); z-index: 999999;
227
+ width: var(--t-width); list-style: none; margin: 0; padding: 0; outline: none;
228
+ --t-offset: 24px; --t-width: 356px; --t-gap: 14px; --t-front: 0px;
229
+ font-family: var(--sans);
230
+ --t-bg: var(--surface); --t-border: var(--border); --t-text: var(--text);
231
+ --success-bg: hsl(150,38%,9%); --success-border: hsl(147,55%,18%); --success-text: hsl(150,80%,72%);
232
+ --error-bg: hsl(358,42%,12%); --error-border: hsl(357,55%,24%); --error-text: hsl(358,100%,82%);
233
+ --info-bg: hsl(215,45%,11%); --info-border: hsl(223,50%,24%); --info-text: hsl(216,87%,74%);
234
+ --warning-bg: hsl(40,55%,9%); --warning-border: hsl(40,55%,20%); --warning-text: hsl(46,87%,70%);
235
+ transition: transform .35s ease;
236
+ }
237
+ #toaster[data-expanded="true"] { transform: translateY(-6px); }
238
+ .etoast {
239
+ position: absolute; bottom: 0; right: 0; left: 0; width: 100%; box-sizing: border-box;
240
+ display: flex; align-items: center; gap: 10px; padding: 15px 16px;
241
+ background: var(--t-bg); border: 1px solid var(--t-border); color: var(--t-text);
242
+ border-radius: 12px; box-shadow: 0 6px 22px rgba(0,0,0,.34); font-size: 13px;
243
+ z-index: var(--z); opacity: 0; touch-action: none; overflow-wrap: anywhere;
244
+ transform: translateY(110%); transform-origin: bottom center;
245
+ transition: transform .42s cubic-bezier(.21,1.02,.73,1), opacity .35s, height .35s, box-shadow .2s;
246
+ }
247
+ .etoast[data-mounted="true"] { opacity: 1; transform: translateY(0); }
248
+ /* Collapsed (default): older toasts sit behind the front one, scaled + clipped to its height. */
249
+ .etoast[data-front="false"][data-expanded="false"] {
250
+ transform: translateY(calc(var(--toasts-before) * -14px)) scale(calc(1 - var(--toasts-before) * .06));
251
+ height: var(--t-front);
252
+ }
253
+ .etoast[data-front="false"][data-expanded="false"] > * { opacity: 0; }
254
+ /* Expanded (hover): fan out, each lifted by the summed height of the ones in front of it. */
255
+ .etoast[data-expanded="true"] { transform: translateY(calc(-1 * var(--offset))); }
256
+ .etoast[data-visible="false"] { opacity: 0; pointer-events: none; }
257
+ .etoast[data-removed="true"] { opacity: 0; }
258
+ .etoast[data-removed="true"][data-expanded="false"] { transform: translateY(150%) scale(.9); }
259
+ .etoast[data-removed="true"][data-expanded="true"] { transform: translateY(calc(-1 * var(--offset) + 110%)); }
260
+ .etoast[data-swiping="true"] { transition: opacity .2s; transform: translateX(var(--swipe-x, 0px)); }
261
+ .etoast > * { transition: opacity .35s; }
262
+ .etoast-icon { display: flex; align-items: center; flex-shrink: 0; width: 20px; height: 20px; }
263
+ .etoast-icon svg { width: 20px; height: 20px; display: block; }
264
+ .etoast-content { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
265
+ .etoast-title { font-weight: 600; line-height: 1.4; }
266
+ .etoast-desc { font-size: 12px; line-height: 1.45; color: var(--muted); }
267
+ .etoast-close {
268
+ position: absolute; top: 0; left: 0; transform: translate(-35%,-35%);
269
+ width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
270
+ padding: 0; border-radius: 50%; cursor: pointer; opacity: 0; z-index: 1;
271
+ background: var(--t-bg); border: 1px solid var(--t-border); color: var(--t-text);
272
+ transition: opacity .15s, background .2s, border-color .2s;
273
+ }
274
+ .etoast:hover .etoast-close, .etoast-close:focus-visible { opacity: 1; }
275
+ .etoast-close:hover { background: var(--surface2); }
276
+ .etoast[data-type="success"] { background: var(--success-bg); border-color: var(--success-border); color: var(--success-text); }
277
+ .etoast[data-type="error"] { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); }
278
+ .etoast[data-type="info"] { background: var(--info-bg); border-color: var(--info-border); color: var(--info-text); }
279
+ .etoast[data-type="warning"] { background: var(--warning-bg); border-color: var(--warning-border); color: var(--warning-text); }
280
+ .etoast[data-type="success"] .etoast-desc, .etoast[data-type="error"] .etoast-desc,
281
+ .etoast[data-type="info"] .etoast-desc, .etoast[data-type="warning"] .etoast-desc { color: inherit; opacity: .82; }
282
+ .etoast[data-type] .etoast-close { background: inherit; border-color: currentColor; color: inherit; }
283
+ .etoast-spin { width: 18px; height: 18px; animation: etoast-spin 0.9s linear infinite; transform-origin: center; }
284
+ @keyframes etoast-spin { to { transform: rotate(360deg); } }
285
+ @media (max-width: 600px) {
286
+ #toaster { right: 14px; left: 14px; width: auto; --t-width: 100%; }
287
+ }
288
+ @media (prefers-reduced-motion) { .etoast, .etoast > * { transition: opacity .2s !important; } }
216
289
  /* Settings: two-column grid of rows on wide screens, so a long config list stays compact. */
217
290
  .set-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 0 32px; }
218
291
  .set-grid .set-row { padding: 10px 0; }
@@ -245,6 +318,9 @@
245
318
  .btn-danger.icon { display: inline-flex; align-items: center; justify-content: center; padding: 6px 9px; }
246
319
  .btn-danger.icon:hover { color: var(--bad); border-color: var(--bad); }
247
320
  .btn-danger svg { width: 15px; height: 15px; display: block; }
321
+ /* Per-memory delete in the Recall list: kept to the right and revealed on row hover/focus. */
322
+ .recall-del { margin-left: auto; opacity: 0; transition: opacity .12s; }
323
+ .set-row:hover .recall-del, .recall-del:focus-visible { opacity: 1; }
248
324
  /* Per-agent skill toggles: one pill per app (the app's icon), green when the skill is on for it; the app name is the tooltip. */
249
325
  .set-agents { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
250
326
  .achip { display: inline-flex; align-items: center; justify-content: center; padding: 5px 8px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface2); color: var(--muted); cursor: pointer; text-decoration: none; transition: color .15s, background .15s, border-color .15s; }
@@ -394,6 +470,19 @@
394
470
  .dd-opt { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text); white-space: nowrap; cursor: pointer; }
395
471
  .dd-opt:hover, .dd-opt:focus { background: var(--surface2); outline: none; }
396
472
  .dd-opt.sel { color: var(--accent); }
473
+ .dd-btn:focus-visible { outline: none; border-color: var(--accent); }
474
+ /* Enhanced native <select>s (enhanceSelect): same look, but they hug their content instead
475
+ of the wider provider picker, and fill a flex slot when the original select did. */
476
+ .ddx { display: inline-flex; vertical-align: middle; }
477
+ .ddx .dd-btn { min-width: 120px; }
478
+ .ddx .dd-menu { max-height: 320px; overflow-y: auto; }
479
+ /* Native selects that stay native (inside modals) get a custom caret so they match the
480
+ styled dropdowns - no default OS chevron. */
481
+ select.linput, select.modal-input, select.scope-sel, select.choice {
482
+ -webkit-appearance: none; -moz-appearance: none; appearance: none;
483
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='13' height='13' viewBox='0 0 24 24' fill='none' stroke='%23808a99' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
484
+ background-repeat: no-repeat; background-position: right 9px center; padding-right: 28px;
485
+ }
397
486
  /* Icon picker: clickable app cards (a native <select> can't render SVG in options). */
398
487
  .modal-picker { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
399
488
  .pick-card {
@@ -445,6 +534,13 @@
445
534
  </div>
446
535
  </header>
447
536
 
537
+ <div id="updateAlert" class="update-alert" hidden>
538
+ <svg class="ua-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56"/><path d="M12 7v5l3 2"/></svg>
539
+ <span class="ua-msg" id="updateAlertMsg"></span>
540
+ <button type="button" id="updateAlertBtn" class="toggle on">Update now</button>
541
+ <button type="button" id="updateAlertDismiss" class="ua-x" title="Dismiss" aria-label="Dismiss">&times;</button>
542
+ </div>
543
+
448
544
  <main id="view-savings">
449
545
  <div class="grid">
450
546
  <div class="card hero"><div class="label">Money Saved</div><div id="money" class="value good">-</div><div id="moneyFoot" class="foot">Estimated, by source</div></div>
@@ -710,10 +806,13 @@
710
806
  <div id="recallBody"><div class="empty" style="padding:12px 0">Loading...</div></div>
711
807
  <div id="recallProviderPanel" class="panel" style="display:none">
712
808
  <div class="set-cat-h">Memory provider</div>
713
- <div class="set-hint" style="padding:4px 0">Which LLM curates your memory (keeps only what's worth remembering). <code>claude-local</code> uses your Claude Code login - no key. For another provider, pick it and set the model/base/key; the key can also come from <code>ENIGMA_RECALL_API_KEY</code>.</div>
714
- <div class="set-row"><div class="set-meta"><div class="set-name">LLM curation</div><div class="set-hint">off = deterministic heuristic, no network</div></div><div class="set-ctl"><button id="recallLlmBtn" type="button" class="toggle">...</button></div></div>
809
+ <div class="set-hint" style="padding:4px 0">Which model curates your memory - it keeps only what's worth remembering. The default reuses your local Claude Code login (no key). Or pick Anthropic or an OpenAI-compatible API and add its key. The key is stored encrypted, and <code>ENIGMA_RECALL_API_KEY</code> overrides it.</div>
810
+ <div class="set-row"><div class="set-meta"><div class="set-name">LLM curation</div><div class="set-hint">Off falls back to a local heuristic - no network calls</div></div><div class="set-ctl"><button id="recallLlmBtn" type="button" class="toggle" data-kind="bool" role="switch" aria-checked="false" aria-label="LLM curation">off</button></div></div>
715
811
  <div class="ladd" style="gap:8px;flex-wrap:wrap;margin-top:8px">
716
- <select id="recallProv" class="linput" style="max-width:160px"></select>
812
+ <div class="dd" id="recallProvDD">
813
+ <button type="button" class="dd-btn" aria-haspopup="listbox" aria-expanded="false"><span class="dd-ic"></span><span class="dd-label"></span><svg class="dd-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
814
+ <ul class="dd-menu" role="listbox" aria-label="Memory provider" hidden></ul>
815
+ </div>
717
816
  <input id="recallModel" class="linput" placeholder="model (provider default)" style="flex:1;min-width:160px">
718
817
  </div>
719
818
  <div id="recallKeyRow" class="ladd" style="gap:8px;flex-wrap:wrap;margin-top:8px">
@@ -730,6 +829,24 @@
730
829
  <select id="recallProject" class="linput" style="max-width:200px"></select>
731
830
  </div>
732
831
  </div>
832
+ <div id="recallAddPanel" class="panel" style="display:none">
833
+ <div class="set-cat-h">Add a memory</div>
834
+ <div class="set-hint" style="padding:4px 0">Write one yourself, or generate it from a short note with the configured model.</div>
835
+ <div class="ladd" style="gap:8px;flex-wrap:wrap">
836
+ <select id="recallNewType" class="linput" style="max-width:150px"></select>
837
+ <input id="recallNewTitle" class="linput" placeholder="Title" style="flex:1;min-width:200px">
838
+ <input id="recallNewProject" class="linput" placeholder="Project (optional)" style="flex:1;min-width:140px">
839
+ </div>
840
+ <div class="ladd" style="gap:8px;flex-wrap:wrap;margin-top:8px">
841
+ <input id="recallNewNarrative" class="linput" placeholder="What's worth remembering (optional)" style="flex:1;min-width:240px">
842
+ <input id="recallNewFacts" class="linput" placeholder="Facts, comma-separated (optional)" style="flex:1;min-width:200px">
843
+ </div>
844
+ <div style="margin-top:8px"><button id="recallCreate" type="button" class="toggle on">Save memory</button></div>
845
+ <div class="ladd" style="gap:8px;flex-wrap:wrap;margin-top:12px;border-top:1px solid rgba(128,128,128,.2);padding-top:12px">
846
+ <input id="recallGenNote" class="linput" placeholder="Note to generate a memory from..." style="flex:1;min-width:260px">
847
+ <button id="recallGenerate" type="button" class="toggle" title="Generate a structured memory from your note">Generate with LLM</button>
848
+ </div>
849
+ </div>
733
850
  <div id="recallList"></div>
734
851
  </section>
735
852
 
@@ -1974,23 +2091,28 @@ export {
1974
2091
  try { await fetch("/api/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: Number(value) }) }); refreshAll(true); }
1975
2092
  catch { /* leave as-is */ }
1976
2093
  }
1977
- // Accessible icon dropdown (a native <select> can't render icons in its <option>s). The
1978
- // button shows the selected entry's icon + label; the listbox shows every entry with its
1979
- // icon. entries: [{ value, label, icon }]; getVal()/onPick(value) bind it to state.
1980
- function iconDropdown(rootId, entries, getVal, onPick) {
1981
- const root = $(rootId), btn = root.querySelector(".dd-btn"), menu = root.querySelector(".dd-menu");
2094
+ // Shared dropdown core. Renders one styled listbox over a `.dd` root (button + menu) from a
2095
+ // model: entries() -> [{ value, label, icon }], get() -> current value, set(value) -> commit.
2096
+ // Icons are optional per entry, so the SAME component drives the icon pickers (provider,
2097
+ // usage) AND every plain selector on the page (see enhanceSelect) - one look, one set of
2098
+ // keyboard semantics, with or without logos.
2099
+ function bindDropdown(root, model) {
2100
+ const btn = root.querySelector(".dd-btn"), menu = root.querySelector(".dd-menu");
1982
2101
  const btnIc = btn.querySelector(".dd-ic"), btnLabel = btn.querySelector(".dd-label");
1983
2102
  const opts = () => [...menu.querySelectorAll(".dd-opt")];
2103
+ const ic = (svg) => '<span class="dd-ic"' + (svg ? "" : ' style="display:none"') + ">" + (svg || "") + "</span>";
1984
2104
  const render = () => {
1985
- const cur = getVal(), e = entries.find((x) => x.value === cur) || entries[0];
1986
- btnIc.innerHTML = e.icon || ""; btnLabel.textContent = e.label;
2105
+ const entries = model.entries(), cur = model.get();
2106
+ const e = entries.find((x) => x.value === cur) || entries[0] || { label: "", icon: "" };
2107
+ btnIc.innerHTML = e.icon || ""; btnIc.style.display = e.icon ? "" : "none";
2108
+ btnLabel.textContent = e.label || "";
1987
2109
  menu.innerHTML = entries.map((x) => '<li class="dd-opt' + (x.value === cur ? " sel" : "")
1988
2110
  + '" role="option" tabindex="-1" data-val="' + esc(x.value) + '" aria-selected="' + (x.value === cur)
1989
- + '"><span class="dd-ic">' + (x.icon || "") + "</span><span>" + esc(x.label) + "</span></li>").join("");
2111
+ + '">' + ic(x.icon) + "<span>" + esc(x.label) + "</span></li>").join("");
1990
2112
  };
1991
2113
  const close = () => { menu.hidden = true; btn.setAttribute("aria-expanded", "false"); };
1992
- const open = () => { menu.hidden = false; btn.setAttribute("aria-expanded", "true"); (menu.querySelector(".sel") || opts()[0]) && (menu.querySelector(".sel") || opts()[0]).focus(); };
1993
- const pick = (li) => { close(); btn.focus(); if (li && li.dataset.val) onPick(li.dataset.val); };
2114
+ const open = () => { render(); menu.hidden = false; btn.setAttribute("aria-expanded", "true"); const f = menu.querySelector(".sel") || opts()[0]; if (f) f.focus(); };
2115
+ const pick = (li) => { close(); btn.focus(); if (li && li.dataset.val != null) { model.set(li.dataset.val); render(); } };
1994
2116
  btn.addEventListener("click", (e) => { e.stopPropagation(); menu.hidden ? open() : close(); });
1995
2117
  btn.addEventListener("keydown", (e) => { if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } });
1996
2118
  menu.addEventListener("click", (e) => pick(e.target.closest(".dd-opt")));
@@ -2000,11 +2122,65 @@ export {
2000
2122
  else if (e.key === "ArrowDown") { e.preventDefault(); (list[i + 1] || list[0]).focus(); }
2001
2123
  else if (e.key === "ArrowUp") { e.preventDefault(); (list[i - 1] || list[list.length - 1]).focus(); }
2002
2124
  else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(document.activeElement); }
2125
+ else if (e.key === "Tab") close();
2003
2126
  });
2004
- document.addEventListener("click", close);
2005
2127
  render();
2006
2128
  return { render };
2007
2129
  }
2130
+ // One global outside-click closes any open dropdown - cheaper and leak-free vs a per-instance
2131
+ // listener (enhanceSelect can create/replace many over a session). A `.dd-btn` click stops
2132
+ // propagation, so opening one never trips this.
2133
+ document.addEventListener("click", () => {
2134
+ document.querySelectorAll(".dd-menu:not([hidden])").forEach((m) => {
2135
+ m.hidden = true;
2136
+ const b = m.parentElement && m.parentElement.querySelector(".dd-btn");
2137
+ if (b) b.setAttribute("aria-expanded", "false");
2138
+ });
2139
+ });
2140
+ // entries: [{ value, label, icon }] bound to a pre-rendered `.dd` root by id.
2141
+ function iconDropdown(rootId, entries, getVal, onPick) {
2142
+ return bindDropdown($(rootId), { entries: () => entries, get: getVal, set: onPick });
2143
+ }
2144
+ // Markup for an enhanced dropdown's button + menu, matching the hand-written provider picker.
2145
+ const DD_INNER = '<button type="button" class="dd-btn" aria-haspopup="listbox" aria-expanded="false"><span class="dd-ic"></span><span class="dd-label"></span><svg class="dd-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button><ul class="dd-menu" role="listbox" hidden></ul>';
2146
+ // Progressive enhancement: replace a native <select> with the shared pretty dropdown while
2147
+ // keeping the <select> itself as the source of truth (hidden). Its .value and `change`
2148
+ // events are untouched - we set .value and dispatch a real `change` on pick - so every
2149
+ // existing reader and delegated handler keeps working. Icons are opt-in via
2150
+ // data-dd-icons="agent" (option value -> brand logo) or per-option data-icon="<key>".
2151
+ function enhanceSelect(select) {
2152
+ if (select.__dd || select.dataset.noDd != null) return;
2153
+ if (select.closest(".modal-back")) return; // a popup menu inside an animated modal would clip; left native (still styled)
2154
+ const iconMode = select.dataset.ddIcons || "";
2155
+ const iconFor = (opt) => { const k = opt.dataset.icon || (iconMode === "agent" ? opt.value : ""); return k ? (agentSvg(k) || "") : ""; };
2156
+ const root = document.createElement("span");
2157
+ root.className = "dd ddx";
2158
+ root.innerHTML = DD_INNER;
2159
+ select.style.display = "none";
2160
+ select.insertAdjacentElement("afterend", root);
2161
+ const api = bindDropdown(root, {
2162
+ entries: () => [...select.options].map((o) => ({ value: o.value, label: o.textContent, icon: iconFor(o) })),
2163
+ get: () => select.value,
2164
+ set: (v) => { if (v !== select.value) { select.value = v; select.dispatchEvent(new Event("change", { bubbles: true })); } api.render(); }
2165
+ });
2166
+ select.__dd = api;
2167
+ // Re-sync the button when options are rebuilt (innerHTML) or the value changes.
2168
+ new MutationObserver(() => api.render()).observe(select, { childList: true });
2169
+ select.addEventListener("change", () => api.render());
2170
+ return api;
2171
+ }
2172
+ // Push a programmatic `select.value = x` (no `change` event) into the enhanced dropdown.
2173
+ const syncDD = (el) => { if (el && el.__dd) el.__dd.render(); };
2174
+ function enhanceAll(root) { (root || document).querySelectorAll("select:not([data-no-dd])").forEach(enhanceSelect); }
2175
+ // Any selector added later (settings rows, project config, account profiles...) is enhanced
2176
+ // automatically, so there is nothing to wire per-view.
2177
+ new MutationObserver((muts) => {
2178
+ for (const m of muts) for (const n of m.addedNodes) {
2179
+ if (n.nodeType !== 1) continue;
2180
+ if (n.tagName === "SELECT") enhanceSelect(n);
2181
+ else if (n.querySelectorAll) n.querySelectorAll("select:not([data-no-dd])").forEach(enhanceSelect);
2182
+ }
2183
+ }).observe(document.body, { childList: true, subtree: true });
2008
2184
  let providerDD = null;
2009
2185
  function wireUsage() {
2010
2186
  // The provider picker is a custom icon dropdown; the account one stays a native select.
@@ -2081,6 +2257,7 @@ export {
2081
2257
  + names.map((n) => '<option value="' + esc(n) + '">' + esc(accountLabel(n)) + "</option>").join("");
2082
2258
  }
2083
2259
  sel.value = usageAccount;
2260
+ syncDD(sel);
2084
2261
  }
2085
2262
 
2086
2263
  // Fill the panel from one scope: the whole report (isAll) or one account's sub-report.
@@ -2309,6 +2486,7 @@ export {
2309
2486
  if (loadedUi === null) loadedUi = data.ui;
2310
2487
  else if (data.ui !== loadedUi) { location.reload(); return; }
2311
2488
  }
2489
+ renderUpdateAlert(data.updates);
2312
2490
  priceOverride = Number(data.priceOverride) || 0;
2313
2491
  speedOverride = Number(data.speedOverride) || 0;
2314
2492
  const s = data.stats || {};
@@ -2367,6 +2545,35 @@ export {
2367
2545
  }
2368
2546
  }
2369
2547
 
2548
+ // --- package update alert --------------------------------------------------
2549
+ // Server-side npm version check (CLI + dashboard + linter), surfaced in /api/stats and
2550
+ // refreshed every poll. Dismiss is sticky only for the exact versions shown - a later
2551
+ // release re-shows the banner. The "Update now" button runs `enigma update` behind the
2552
+ // scenes; the alert then clears on its own as the next polls see the new versions.
2553
+ let dismissedUpdateSig = "";
2554
+ function renderUpdateAlert(u) {
2555
+ const el = $("updateAlert");
2556
+ if (!el) return;
2557
+ const items = ((u && u.packages) || []).filter((p) => p.hasUpdate);
2558
+ const sig = items.map((p) => p.name + "@" + p.latest).join(",");
2559
+ if (!items.length || sig === dismissedUpdateSig) { el.hidden = true; return; }
2560
+ const list = items.map((p) => esc(p.label) + " " + esc(p.installed) + " -> " + esc(p.latest)).join(", ");
2561
+ $("updateAlertMsg").innerHTML = "<strong>Update available</strong> &nbsp;" + list;
2562
+ el.dataset.sig = sig;
2563
+ el.hidden = false;
2564
+ }
2565
+
2566
+ async function runFullUpdate() {
2567
+ const btn = $("updateAlertBtn");
2568
+ if (btn) { btn.disabled = true; btn.textContent = "Updating..."; }
2569
+ try {
2570
+ const res = await fetch("/api/run-update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
2571
+ const out = await res.json();
2572
+ toast(out.note || (out.ok ? "Update started." : "Could not start the update."), out.ok ? "ok" : "err");
2573
+ } catch { toast("Could not reach the server to update.", "err"); }
2574
+ finally { if (btn) { btn.disabled = false; btn.textContent = "Update now"; } }
2575
+ }
2576
+
2370
2577
  // --- dashboard self-update -------------------------------------------------
2371
2578
  // Where the latest published dashboard version lives: the raw package.json on the
2372
2579
  // repo's main branch (no API, no auth, no rate limit). A repo rename 404s here and is
@@ -2414,24 +2621,153 @@ export {
2414
2621
  // --- settings subpage (mirrors the TUI registry over /api/settings) ---
2415
2622
  function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
2416
2623
 
2417
- // A self-dismissing notification so a failed action can't slip by unseen. Errors
2418
- // linger longer than confirmations; both auto-clear so they never pile up.
2419
- function toast(msg, kind) {
2420
- let wrap = $("toasts");
2421
- if (!wrap) { wrap = document.createElement("div"); wrap.id = "toasts"; document.body.appendChild(wrap); }
2422
- const el = document.createElement("div");
2423
- el.className = "toast " + (kind === "err" ? "err" : kind === "ok" ? "ok" : "");
2424
- el.setAttribute("role", kind === "err" ? "alert" : "status");
2425
- el.textContent = msg;
2426
- wrap.appendChild(el);
2427
- setTimeout(() => { el.classList.add("hide"); setTimeout(() => el.remove(), 320); }, kind === "err" ? 6000 : 3000);
2428
- }
2429
- // Set a status note's text and ok/err styling in one place. Errors are also toasted
2430
- // (the inline note alone is easy to miss), so callers just call setNote(el, msg, "err").
2624
+ // --- Toasts (vanilla port of Sonner) -------------------------------------------------
2625
+ // A stacked, self-dismissing notification system: the newest sits in front, older ones
2626
+ // peek behind and fan out on hover; rich-coloured per type; swipe- or click-to-close.
2627
+ // API: toast(msg, opts) and toast.success/error/info/warning/loading/message/dismiss.
2628
+ // opts: { description, duration, id }. A loading toast has Infinity duration; calling a
2629
+ // type with the same id UPDATES it in place (loading -> success/error), so an async flow
2630
+ // is a single morphing toast, not a pile.
2631
+ const toast = (() => {
2632
+ const ICONS = {
2633
+ success: '<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd"/></svg>',
2634
+ error: '<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></svg>',
2635
+ info: '<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd"/></svg>',
2636
+ warning: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd"/></svg>',
2637
+ loading: '<svg class="etoast-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" opacity="0.85"/></svg>',
2638
+ close: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
2639
+ };
2640
+ const VISIBLE = 3, DURATION = { default: 4000, success: 4000, info: 4000, warning: 5000, error: 6000, loading: Infinity };
2641
+ let list = null, counter = 1, expanded = false;
2642
+ const items = []; // newest-first; { id, el, type, duration, remaining, startedAt, timer, removed }
2643
+ const mount = () => { if (!list) { list = document.createElement("ol"); list.id = "toaster"; list.setAttribute("aria-live", "polite"); list.tabIndex = -1; document.body.appendChild(list); wireHover(); } return list; };
2644
+ const wireHover = () => {
2645
+ list.addEventListener("mouseenter", () => { expanded = true; list.dataset.expanded = "true"; pauseAll(); place(); });
2646
+ list.addEventListener("mouseleave", () => { expanded = false; list.dataset.expanded = "false"; resumeAll(); place(); });
2647
+ };
2648
+ const front = () => items.find((t) => !t.removed);
2649
+ const pause = (t) => { if (t.timer) { clearTimeout(t.timer); t.timer = null; t.remaining -= Date.now() - t.startedAt; } };
2650
+ const resume = (t) => { if (t.duration === Infinity || t.removed || t.timer) return; t.startedAt = Date.now(); t.timer = setTimeout(() => dismiss(t.id), Math.max(0, t.remaining)); };
2651
+ const pauseAll = () => items.forEach(pause);
2652
+ const resumeAll = () => items.forEach(resume);
2653
+ // Position every toast: front shows fully, the rest peek behind (collapsed) or fan out
2654
+ // (expanded). --offset is the summed height of the live toasts in front of this one.
2655
+ function place() {
2656
+ let acc = 0, vi = 0;
2657
+ for (const t of items) {
2658
+ if (t.removed) { t.el.style.setProperty("--z", "0"); continue; }
2659
+ // The front toast (and any toast while the stack is expanded) is never
2660
+ // height-clamped, so its offsetHeight is the true natural height - cache it.
2661
+ if (vi === 0 && !expanded) t.height = t.el.offsetHeight;
2662
+ if (vi === 0) list.style.setProperty("--t-front", (t.height || t.el.offsetHeight) + "px");
2663
+ t.el.dataset.front = vi === 0 ? "true" : "false";
2664
+ t.el.dataset.expanded = expanded ? "true" : "false";
2665
+ t.el.dataset.visible = vi < VISIBLE ? "true" : "false";
2666
+ t.el.style.setProperty("--toasts-before", String(vi));
2667
+ t.el.style.setProperty("--offset", acc + "px");
2668
+ t.el.style.setProperty("--z", String(1000 - vi));
2669
+ acc += (t.height || t.el.offsetHeight) + 14;
2670
+ vi++;
2671
+ }
2672
+ }
2673
+ function dismiss(id) {
2674
+ const t = items.find((x) => x.id === id);
2675
+ if (!t || t.removed) return id;
2676
+ t.removed = true; if (t.timer) clearTimeout(t.timer);
2677
+ t.el.dataset.removed = "true"; t.el.dataset.expanded = expanded ? "true" : "false";
2678
+ place();
2679
+ setTimeout(() => { const i = items.indexOf(t); if (i !== -1) items.splice(i, 1); t.el.remove(); place(); }, 420);
2680
+ return id;
2681
+ }
2682
+ function build(t, type, title, description) {
2683
+ t.el.dataset.type = type;
2684
+ t.el.setAttribute("role", type === "error" ? "alert" : "status");
2685
+ const icon = ICONS[type] ? '<div class="etoast-icon">' + ICONS[type] + "</div>" : "";
2686
+ t.el.innerHTML = icon
2687
+ + '<div class="etoast-content"><div class="etoast-title"></div>'
2688
+ + (description ? '<div class="etoast-desc"></div>' : "") + "</div>"
2689
+ + '<button type="button" class="etoast-close" aria-label="Close">' + ICONS.close + "</button>";
2690
+ t.el.querySelector(".etoast-title").textContent = title == null ? "" : String(title);
2691
+ if (description) t.el.querySelector(".etoast-desc").textContent = String(description);
2692
+ t.el.querySelector(".etoast-close").addEventListener("click", (e) => { e.stopPropagation(); dismiss(t.id); });
2693
+ }
2694
+ // Horizontal swipe-to-dismiss (pointer drag), matching Sonner's feel.
2695
+ function wireSwipe(t) {
2696
+ let startX = null;
2697
+ t.el.addEventListener("pointerdown", (e) => { if (e.target.closest(".etoast-close")) return; startX = e.clientX; t.el.setPointerCapture(e.pointerId); pause(t); });
2698
+ t.el.addEventListener("pointermove", (e) => {
2699
+ if (startX == null) return;
2700
+ const dx = e.clientX - startX;
2701
+ t.el.dataset.swiping = "true"; t.el.style.setProperty("--swipe-x", dx + "px");
2702
+ });
2703
+ const end = (e) => {
2704
+ if (startX == null) return;
2705
+ const dx = e.clientX - startX; startX = null; t.el.dataset.swiping = "false";
2706
+ if (Math.abs(dx) > 80) dismiss(t.id);
2707
+ else { t.el.style.removeProperty("--swipe-x"); if (!expanded) resume(t); }
2708
+ };
2709
+ t.el.addEventListener("pointerup", end);
2710
+ t.el.addEventListener("pointercancel", end);
2711
+ }
2712
+ function create(opts) {
2713
+ const type = opts.type || "default";
2714
+ const id = opts.id != null ? opts.id : counter++;
2715
+ const duration = opts.duration != null ? opts.duration : DURATION[type];
2716
+ const existing = items.find((x) => x.id === id && !x.removed);
2717
+ if (existing) { // update in place (e.g. loading -> success)
2718
+ build(existing, type, opts.title, opts.description);
2719
+ existing.duration = duration; existing.remaining = duration; existing.startedAt = Date.now();
2720
+ if (existing.timer) { clearTimeout(existing.timer); existing.timer = null; }
2721
+ if (!expanded) resume(existing);
2722
+ place();
2723
+ return id;
2724
+ }
2725
+ mount();
2726
+ const el = document.createElement("li");
2727
+ el.className = "etoast"; el.tabIndex = 0;
2728
+ const t = { id, el, type, duration, remaining: duration, startedAt: Date.now(), timer: null, removed: false };
2729
+ build(t, type, opts.title, opts.description);
2730
+ wireSwipe(t);
2731
+ items.unshift(t);
2732
+ list.prepend(el);
2733
+ place();
2734
+ requestAnimationFrame(() => { el.dataset.mounted = "true"; place(); if (!expanded) resume(t); });
2735
+ return id;
2736
+ }
2737
+ // Back-compat: a string 2nd arg is the legacy kind ("ok"/"err"); an object is opts.
2738
+ const KIND = { ok: "success", err: "error" };
2739
+ const api = (msg, o) => create(typeof o === "string" ? { type: KIND[o] || "default", title: msg } : { ...o, type: "default", title: msg });
2740
+ ["success", "error", "info", "warning", "loading", "message"].forEach((k) => {
2741
+ api[k] = (msg, o) => create({ ...o, type: k === "message" ? "default" : k, title: msg });
2742
+ });
2743
+ api.dismiss = (id) => { if (id == null) items.slice().forEach((t) => dismiss(t.id)); else dismiss(id); return id; };
2744
+ return api;
2745
+ })();
2746
+
2747
+ // Bridge the old inline-note API onto toasts so every existing call site upgrades at once.
2748
+ // A flow reuses ONE note target (e.g. "settingsNote") start to finish, so we key a single
2749
+ // toast per target: a progress message ("Saving...") opens a loading toast, and the ok/err
2750
+ // result morphs that same toast - no pile-up, no leftover green text. `restartNote`-style
2751
+ // long messages split into title + description at the first sentence break.
2752
+ const noteToastId = new Map();
2753
+ function noteKey(el) { return typeof el === "string" ? el : (el && el.id) || "__note__"; }
2754
+ function splitMsg(msg) {
2755
+ msg = String(msg);
2756
+ const m = msg.match(/^(.*?[.!])\s+(.+)$/s);
2757
+ return m && m[1].length <= 64 ? { title: m[1], description: m[2] } : { title: msg };
2758
+ }
2759
+ // Drop-in replacement for the former setNote(el, msg, kind). `el` is ignored for display
2760
+ // (kept for call compatibility); everything surfaces as a toast.
2431
2761
  function setNote(el, msg, kind) {
2432
- if (typeof el === "string") el = $(el);
2433
- if (el) { el.textContent = msg; el.classList.toggle("ok", kind === "ok"); el.classList.toggle("err", kind === "err"); }
2434
- if (kind === "err") toast(msg, "err");
2762
+ const key = noteKey(el);
2763
+ const prev = noteToastId.get(key);
2764
+ if (msg == null || msg === "") { if (prev != null) { toast.dismiss(prev); noteToastId.delete(key); } return; }
2765
+ const isProgress = !kind && /(\.\.\.|…)\s*$/.test(String(msg));
2766
+ const type = kind === "ok" ? "success" : kind === "err" ? "error" : isProgress ? "loading" : "info";
2767
+ const parts = splitMsg(msg);
2768
+ const id = toast[type](parts.title, { id: prev, description: parts.description });
2769
+ if (isProgress) noteToastId.set(key, id);
2770
+ else noteToastId.delete(key);
2435
2771
  }
2436
2772
 
2437
2773
  // Capitalize a choice value for display ("on-demand" -> "On demand"), keeping the raw
@@ -2578,7 +2914,7 @@ export {
2578
2914
  const want = isBool ? (value === true || value === 1) : String(value);
2579
2915
  const prevBool = ctl.dataset.on === "1";
2580
2916
  const prevChoice = ctl.dataset.prev != null ? ctl.dataset.prev : ctl.value; // <select> already changed natively
2581
- const rollback = () => { if (isBool) applyBool(ctl, prevBool); else ctl.value = prevChoice; };
2917
+ const rollback = () => { if (isBool) applyBool(ctl, prevBool); else { ctl.value = prevChoice; syncDD(ctl); } };
2582
2918
  if (isBool) applyBool(ctl, want);
2583
2919
  setNote(el, "Saving " + key + "...");
2584
2920
  try {
@@ -2587,7 +2923,7 @@ export {
2587
2923
  if (!out.ok) { rollback(); setNote(el, "Could not change " + key + ": " + (out.error || "error"), "err"); return; }
2588
2924
  const s = out.setting;
2589
2925
  if (s && isBool) applyBool(ctl, s.value);
2590
- else if (s) { ctl.value = s.choice; ctl.dataset.prev = s.choice; }
2926
+ else if (s) { ctl.value = s.choice; ctl.dataset.prev = s.choice; syncDD(ctl); }
2591
2927
  if (s && (isBool ? s.value !== want : String(s.choice) !== want)) { setNote(el, key + " did not change (the write had no effect).", "err"); return; }
2592
2928
  setNote(el, out.restartNote || ("Saved " + key + "."), "ok");
2593
2929
  } catch { rollback(); setNote(el, "Could not reach the server to change " + key + ".", "err"); }
@@ -3361,25 +3697,27 @@ export {
3361
3697
  v = v || {};
3362
3698
  if (!v.available) {
3363
3699
  $("recallBody").innerHTML = '<div class="panel"><div class="empty" style="padding:16px 0">Recall needs the enigma binary - open the dashboard via the enigma app.</div></div>';
3364
- $("recallSearch").style.display = "none"; $("recallProviderPanel").style.display = "none"; $("recallList").innerHTML = ""; return;
3700
+ $("recallSearch").style.display = "none"; $("recallProviderPanel").style.display = "none"; $("recallAddPanel").style.display = "none"; $("recallList").innerHTML = ""; return;
3365
3701
  }
3366
3702
  if (!v.enabled) {
3367
3703
  $("recallBody").innerHTML = '<div class="panel"><div class="set-cat-h">Session memory is off</div>'
3368
3704
  + '<div class="set-hint" style="padding:8px 0">Build a searchable memory of your coding sessions from your own transcripts.</div>'
3369
- + '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><button type="button" class="toggle" data-recall-on="1">Enable recall</button>'
3370
- + '<span class="set-hint">or run <code>enigma config recall on</code></span></div></div>';
3371
- $("recallSearch").style.display = "none"; $("recallProviderPanel").style.display = "none"; $("recallList").innerHTML = ""; return;
3705
+ + '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><button type="button" class="toggle" data-kind="bool" role="switch" aria-checked="false" data-recall-on="1" aria-label="Session memory" title="Turn session memory on">Enable recall</button>'
3706
+ + '<span class="set-hint">Turn on, or run <code>enigma config recall on</code></span></div></div>';
3707
+ $("recallSearch").style.display = "none"; $("recallProviderPanel").style.display = "none"; $("recallAddPanel").style.display = "none"; $("recallList").innerHTML = ""; return;
3372
3708
  }
3373
3709
  const s = v.stats || {};
3374
3710
  const types = Object.entries(s.byType || {}).map(([t, n]) => '<span class="tag">' + esc(t) + " " + n + "</span>").join(" ");
3375
3711
  const last = v.lastSync ? "last sync " + new Date(v.lastSync).toLocaleString() : "not synced yet";
3376
3712
  $("recallBody").innerHTML = '<div class="panel"><div style="display:flex;justify-content:space-between;align-items:center;gap:10px"><div class="set-cat-h" style="margin:0">Stored memory</div>'
3377
- + '<button type="button" class="toggle on" data-recall-on="0" title="Turn session memory off">Disable</button></div>'
3713
+ + '<button type="button" class="toggle on" data-kind="bool" role="switch" aria-checked="true" data-recall-on="0" aria-label="Session memory" title="Turn session memory off">Disable</button></div>'
3378
3714
  + '<div class="set-hint" style="padding:6px 0">' + (s.observations || 0) + " observations · " + (s.sessions || 0) + " sessions · "
3379
3715
  + (s.projects || 0) + " projects · " + Math.round((s.dbBytes || 0) / 1024) + " KB · " + esc(last) + "</div>"
3380
3716
  + (types ? '<div style="padding-top:6px">' + types + "</div>" : "") + "</div>";
3381
3717
  $("recallSearch").style.display = "";
3718
+ $("recallAddPanel").style.display = "";
3382
3719
  if (!$("recallType").options.length) $("recallType").innerHTML = '<option value="">All types</option>' + RECALL_TYPES.map((t) => '<option value="' + t + '">' + t + "</option>").join("");
3720
+ if (!$("recallNewType").options.length) $("recallNewType").innerHTML = RECALL_TYPES.map((t) => '<option value="' + t + '">' + t + "</option>").join("");
3383
3721
  const sig = (v.projects || []).join("|");
3384
3722
  if (sig !== recallProjSig) {
3385
3723
  const keep = $("recallProject").value;
@@ -3390,13 +3728,30 @@ export {
3390
3728
  renderRecallProvider(v.provider);
3391
3729
  renderRecallList(v.items);
3392
3730
  }
3393
- let recallProvSig = "";
3731
+ // Provider picker shows the right logo + a human label (the stored value stays the
3732
+ // internal id). claude-local and anthropic are Anthropic models (Claude logo); openai
3733
+ // is any OpenAI-compatible endpoint (OpenAI/Codex logo).
3734
+ const RECALL_PROV_META = {
3735
+ "claude-local": { label: "Claude Code (local login)", icon: "claude" },
3736
+ "anthropic": { label: "Anthropic API", icon: "claude" },
3737
+ "openai": { label: "OpenAI-compatible", icon: "codex" },
3738
+ };
3739
+ let recallProvDD = null, recallProvVal = "claude-local";
3394
3740
  function renderRecallProvider(p) {
3395
3741
  if (!p) { $("recallProviderPanel").style.display = "none"; return; }
3396
3742
  $("recallProviderPanel").style.display = "";
3397
- const sig = (p.providers || []).join("|");
3398
- if (sig !== recallProvSig) { $("recallProv").innerHTML = (p.providers || []).map((x) => '<option value="' + x + '">' + x + "</option>").join(""); recallProvSig = sig; }
3399
- $("recallProv").value = p.provider;
3743
+ recallProvVal = p.provider;
3744
+ if (!recallProvDD) {
3745
+ const entries = (p.providers || []).map((v) => {
3746
+ const m = RECALL_PROV_META[v] || { label: v, icon: "" };
3747
+ return { value: v, label: m.label, icon: agentSvg(m.icon) };
3748
+ });
3749
+ recallProvDD = iconDropdown("recallProvDD", entries, () => recallProvVal, (val) => {
3750
+ recallProvVal = val;
3751
+ $("recallKeyRow").style.display = val === "claude-local" ? "none" : "";
3752
+ });
3753
+ }
3754
+ recallProvDD.render();
3400
3755
  $("recallModel").value = p.model || "";
3401
3756
  $("recallBase").value = p.base || "";
3402
3757
  $("recallKey").placeholder = p.keyFromEnv ? "set via ENIGMA_RECALL_API_KEY" : (p.hasKey ? "set - leave blank to keep" : "API key");
@@ -3407,6 +3762,7 @@ export {
3407
3762
  llmBtn.textContent = p.llm ? "on" : "off";
3408
3763
  llmBtn.classList.toggle("on", !!p.llm);
3409
3764
  llmBtn.dataset.on = p.llm ? "1" : "0";
3765
+ llmBtn.setAttribute("aria-checked", p.llm ? "true" : "false");
3410
3766
  }
3411
3767
  let recallItemsById = {};
3412
3768
  function recallRowHtml(it, anchorId) {
@@ -3433,7 +3789,8 @@ export {
3433
3789
  if (!items || !items.length) { $("recallList").innerHTML = '<div class="empty" style="padding:16px 0">No memories yet - run a sync, or adjust your search.</div>'; return; }
3434
3790
  $("recallList").innerHTML = '<div class="panel">' + items.map((it) => {
3435
3791
  if (it.id) recallItemsById[it.id] = it;
3436
- return '<div class="set-row" style="cursor:pointer" data-rid="' + (it.id || "") + '">' + recallRowHtml(it) + "</div>"
3792
+ const del = it.id ? '<button type="button" class="btn-danger icon recall-del" title="Delete memory" aria-label="Delete this memory" data-del="' + it.id + '">' + TRASH_ICON + "</button>" : "";
3793
+ return '<div class="set-row" style="cursor:pointer" data-rid="' + (it.id || "") + '">' + recallRowHtml(it) + del + "</div>"
3437
3794
  + '<div id="rd-' + (it.id || "") + '" style="display:none"></div>';
3438
3795
  }).join("") + "</div>";
3439
3796
  }
@@ -3482,6 +3839,16 @@ export {
3482
3839
  setNote("recallNote", out.ok ? "Saved." : (out.error || "Failed."), out.ok ? "ok" : "err");
3483
3840
  } catch { setNote("recallNote", "Could not reach the server.", "err"); }
3484
3841
  }
3842
+ // Generic recall action post (delete/create/generate); refreshes the view and returns ok.
3843
+ async function recallOpPost(body, msg, okMsg) {
3844
+ setNote("recallNote", msg || "Working...");
3845
+ try {
3846
+ const out = await (await fetch("/api/recall", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })).json();
3847
+ if (out.view) renderRecall(out.view);
3848
+ setNote("recallNote", out.ok ? (okMsg || "Done.") : (out.error || "Failed."), out.ok ? "ok" : "err");
3849
+ return out.ok;
3850
+ } catch { setNote("recallNote", "Could not reach the server.", "err"); return false; }
3851
+ }
3485
3852
  function wireRecall() {
3486
3853
  $("recallSync").addEventListener("click", () => recallAction("sync"));
3487
3854
  $("recallClear").addEventListener("click", () => recallAction("clear", "Delete all stored session memory? This cannot be undone."));
@@ -3489,20 +3856,43 @@ export {
3489
3856
  const t = e.target.closest("[data-recall-on]");
3490
3857
  if (t) setRecallEnabled(t.dataset.recallOn === "1");
3491
3858
  });
3492
- // Provider panel: toggle which select fields apply; save provider; flip LLM curation.
3493
- $("recallProv").addEventListener("change", () => { $("recallKeyRow").style.display = $("recallProv").value === "claude-local" ? "none" : ""; });
3859
+ // Provider panel: save provider; flip LLM curation. The provider picker is the
3860
+ // custom icon dropdown (recallProvVal), wired in renderRecallProvider.
3494
3861
  $("recallProvSave").addEventListener("click", () => {
3495
- const e = { provider: $("recallProv").value, model: $("recallModel").value.trim(), base: $("recallBase").value.trim() };
3862
+ const e = { provider: recallProvVal, model: $("recallModel").value.trim(), base: $("recallBase").value.trim() };
3496
3863
  if (!$("recallKey").disabled && $("recallKey").value) e.key = $("recallKey").value; // only send a key the user typed
3497
3864
  recallProviderPost(e, "Saving provider...");
3498
3865
  $("recallKey").value = "";
3499
3866
  });
3500
3867
  $("recallLlmBtn").addEventListener("click", () => recallProviderPost({ llm: $("recallLlmBtn").dataset.on !== "1" }, "Updating curation..."));
3868
+ // Add a memory: manual form, or generate one from a note via the configured model.
3869
+ $("recallCreate").addEventListener("click", async () => {
3870
+ const title = $("recallNewTitle").value.trim();
3871
+ if (!title) { setNote("recallNote", "A title is required.", "err"); return; }
3872
+ const facts = $("recallNewFacts").value.split(",").map((s) => s.trim()).filter(Boolean);
3873
+ const ok = await recallOpPost({
3874
+ op: "create", type: $("recallNewType").value, title, project: $("recallNewProject").value.trim(),
3875
+ narrative: $("recallNewNarrative").value.trim(), facts,
3876
+ }, "Saving memory...", "Memory saved.");
3877
+ if (ok) { $("recallNewTitle").value = ""; $("recallNewNarrative").value = ""; $("recallNewFacts").value = ""; }
3878
+ });
3879
+ $("recallGenerate").addEventListener("click", async () => {
3880
+ const prompt = $("recallGenNote").value.trim();
3881
+ if (!prompt) { setNote("recallNote", "Write a note to generate from.", "err"); return; }
3882
+ const ok = await recallOpPost({ op: "generate", prompt, project: $("recallNewProject").value.trim() }, "Generating memory...", "Memory generated.");
3883
+ if (ok) $("recallGenNote").value = "";
3884
+ });
3501
3885
  let t; const deb = () => { clearTimeout(t); t = setTimeout(loadRecall, 250); };
3502
3886
  $("recallQ").addEventListener("input", deb);
3503
3887
  $("recallType").addEventListener("change", loadRecall);
3504
3888
  $("recallProject").addEventListener("change", loadRecall);
3505
- $("recallList").addEventListener("click", (e) => {
3889
+ $("recallList").addEventListener("click", async (e) => {
3890
+ const del = e.target.closest("[data-del]");
3891
+ if (del) {
3892
+ if (!(await askConfirm("Delete memory", "Delete this memory? This cannot be undone.", true))) return;
3893
+ recallOpPost({ op: "delete", id: Number(del.dataset.del) }, "Deleting...", "Memory deleted.");
3894
+ return;
3895
+ }
3506
3896
  const tl = e.target.closest("[data-tl]");
3507
3897
  if (tl) { loadRecallTimeline(tl.dataset.tl); return; }
3508
3898
  const row = e.target.closest("[data-rid]");
@@ -3536,6 +3926,7 @@ export {
3536
3926
  + projectsCache.map((p) => '<option value="' + esc(p.path) + '">' + esc(p.label) + "</option>").join("")
3537
3927
  + '<option value="__create__">+ Create new project</option>';
3538
3928
  sel.value = currentScope;
3929
+ syncDD(sel);
3539
3930
  }
3540
3931
  async function loadProjectsList() {
3541
3932
  try { projectsCache = ((await (await fetch("/api/projects", { cache: "no-store" })).json()).projects) || []; }
@@ -3615,7 +4006,7 @@ export {
3615
4006
  const sel = $("scopeSel");
3616
4007
  if (sel) sel.addEventListener("change", () => {
3617
4008
  const val = sel.value;
3618
- if (val === "__create__") { sel.value = currentScope; openCreateProject(); return; }
4009
+ if (val === "__create__") { sel.value = currentScope; syncDD(sel); openCreateProject(); return; }
3619
4010
  currentScope = val;
3620
4011
  try { localStorage.setItem("enigmaScope", val); } catch { /* */ }
3621
4012
  route();
@@ -3807,10 +4198,13 @@ export {
3807
4198
  wireResources();
3808
4199
  wireRecall();
3809
4200
  wireScope();
4201
+ enhanceAll(); // upgrade every static <select> into the shared pretty dropdown
3810
4202
  $("cfgExport").addEventListener("click", exportConfig);
3811
4203
  $("cfgImport").addEventListener("click", importConfig);
3812
4204
  $("updateBtn").addEventListener("click", runUpdate);
3813
4205
  $("fixPathBtn").addEventListener("click", runFixPath);
4206
+ $("updateAlertBtn").addEventListener("click", runFullUpdate);
4207
+ $("updateAlertDismiss").addEventListener("click", () => { dismissedUpdateSig = $("updateAlert").dataset.sig || ""; $("updateAlert").hidden = true; });
3814
4208
  $("refreshBtn").addEventListener("click", async () => {
3815
4209
  const b = $("refreshBtn");
3816
4210
  if (b.classList.contains("spinning")) return; // already refreshing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/dashboard",
3
- "version": "0.1.60",
3
+ "version": "0.1.63",
4
4
  "description": "Local browser dashboard UI for enigma: the static page and chart assets enigma serves on its loopback dashboard (savings, real tool usage, in-browser settings). Installed on demand by enigma-cli; not a runtime dependency.",
5
5
  "type": "module",
6
6
  "files": [