@enigmax/dashboard 0.1.61 → 0.1.64

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 +520 -54
  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,15 @@
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; }
324
+ /* Recall multi-select: a checkbox per row, a highlight for selected rows, and a bulk-action
325
+ bar that appears only while something is selected. Ctrl/Cmd+click toggles, Shift+click ranges. */
326
+ .rc-check { flex: 0 0 auto; width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent); margin: 0; }
327
+ .set-row.sel { background: rgba(224, 164, 88, .10); }
328
+ .rc-bulk { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
329
+ .rc-bulk .rc-seln { font-size: 12px; color: var(--muted); }
248
330
  /* 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
331
  .set-agents { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
250
332
  .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; }
@@ -378,6 +460,10 @@
378
460
  .modal-fl { display: block; margin-top: 14px; margin-bottom: 5px; font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
379
461
  .modal-fl .modal-opt { text-transform: none; letter-spacing: 0; opacity: .75; }
380
462
  .modal-fl + .modal-input { margin-top: 0; }
463
+ /* Provider-override editor labels + key hint. */
464
+ .prov-l { display: block; margin-top: 12px; margin-bottom: 4px; font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
465
+ .prov-hint { margin-top: 8px; font-size: 12px; color: var(--muted); word-break: break-word; }
466
+ .prov-hint a { color: var(--accent); }
381
467
  /* Usage view provider/account pickers. */
382
468
  .useg-pick { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
383
469
  .useg-pick select { width: auto; min-width: 170px; }
@@ -394,6 +480,19 @@
394
480
  .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
481
  .dd-opt:hover, .dd-opt:focus { background: var(--surface2); outline: none; }
396
482
  .dd-opt.sel { color: var(--accent); }
483
+ .dd-btn:focus-visible { outline: none; border-color: var(--accent); }
484
+ /* Enhanced native <select>s (enhanceSelect): same look, but they hug their content instead
485
+ of the wider provider picker, and fill a flex slot when the original select did. */
486
+ .ddx { display: inline-flex; vertical-align: middle; }
487
+ .ddx .dd-btn { min-width: 120px; }
488
+ .ddx .dd-menu { max-height: 320px; overflow-y: auto; }
489
+ /* Native selects that stay native (inside modals) get a custom caret so they match the
490
+ styled dropdowns - no default OS chevron. */
491
+ select.linput, select.modal-input, select.scope-sel, select.choice {
492
+ -webkit-appearance: none; -moz-appearance: none; appearance: none;
493
+ 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>");
494
+ background-repeat: no-repeat; background-position: right 9px center; padding-right: 28px;
495
+ }
397
496
  /* Icon picker: clickable app cards (a native <select> can't render SVG in options). */
398
497
  .modal-picker { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
399
498
  .pick-card {
@@ -445,6 +544,13 @@
445
544
  </div>
446
545
  </header>
447
546
 
547
+ <div id="updateAlert" class="update-alert" hidden>
548
+ <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>
549
+ <span class="ua-msg" id="updateAlertMsg"></span>
550
+ <button type="button" id="updateAlertBtn" class="toggle on">Update now</button>
551
+ <button type="button" id="updateAlertDismiss" class="ua-x" title="Dismiss" aria-label="Dismiss">&times;</button>
552
+ </div>
553
+
448
554
  <main id="view-savings">
449
555
  <div class="grid">
450
556
  <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>
@@ -1995,23 +2101,28 @@ export {
1995
2101
  try { await fetch("/api/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: Number(value) }) }); refreshAll(true); }
1996
2102
  catch { /* leave as-is */ }
1997
2103
  }
1998
- // Accessible icon dropdown (a native <select> can't render icons in its <option>s). The
1999
- // button shows the selected entry's icon + label; the listbox shows every entry with its
2000
- // icon. entries: [{ value, label, icon }]; getVal()/onPick(value) bind it to state.
2001
- function iconDropdown(rootId, entries, getVal, onPick) {
2002
- const root = $(rootId), btn = root.querySelector(".dd-btn"), menu = root.querySelector(".dd-menu");
2104
+ // Shared dropdown core. Renders one styled listbox over a `.dd` root (button + menu) from a
2105
+ // model: entries() -> [{ value, label, icon }], get() -> current value, set(value) -> commit.
2106
+ // Icons are optional per entry, so the SAME component drives the icon pickers (provider,
2107
+ // usage) AND every plain selector on the page (see enhanceSelect) - one look, one set of
2108
+ // keyboard semantics, with or without logos.
2109
+ function bindDropdown(root, model) {
2110
+ const btn = root.querySelector(".dd-btn"), menu = root.querySelector(".dd-menu");
2003
2111
  const btnIc = btn.querySelector(".dd-ic"), btnLabel = btn.querySelector(".dd-label");
2004
2112
  const opts = () => [...menu.querySelectorAll(".dd-opt")];
2113
+ const ic = (svg) => '<span class="dd-ic"' + (svg ? "" : ' style="display:none"') + ">" + (svg || "") + "</span>";
2005
2114
  const render = () => {
2006
- const cur = getVal(), e = entries.find((x) => x.value === cur) || entries[0];
2007
- btnIc.innerHTML = e.icon || ""; btnLabel.textContent = e.label;
2115
+ const entries = model.entries(), cur = model.get();
2116
+ const e = entries.find((x) => x.value === cur) || entries[0] || { label: "", icon: "" };
2117
+ btnIc.innerHTML = e.icon || ""; btnIc.style.display = e.icon ? "" : "none";
2118
+ btnLabel.textContent = e.label || "";
2008
2119
  menu.innerHTML = entries.map((x) => '<li class="dd-opt' + (x.value === cur ? " sel" : "")
2009
2120
  + '" role="option" tabindex="-1" data-val="' + esc(x.value) + '" aria-selected="' + (x.value === cur)
2010
- + '"><span class="dd-ic">' + (x.icon || "") + "</span><span>" + esc(x.label) + "</span></li>").join("");
2121
+ + '">' + ic(x.icon) + "<span>" + esc(x.label) + "</span></li>").join("");
2011
2122
  };
2012
2123
  const close = () => { menu.hidden = true; btn.setAttribute("aria-expanded", "false"); };
2013
- const open = () => { menu.hidden = false; btn.setAttribute("aria-expanded", "true"); (menu.querySelector(".sel") || opts()[0]) && (menu.querySelector(".sel") || opts()[0]).focus(); };
2014
- const pick = (li) => { close(); btn.focus(); if (li && li.dataset.val) onPick(li.dataset.val); };
2124
+ const open = () => { render(); menu.hidden = false; btn.setAttribute("aria-expanded", "true"); const f = menu.querySelector(".sel") || opts()[0]; if (f) f.focus(); };
2125
+ const pick = (li) => { close(); btn.focus(); if (li && li.dataset.val != null) { model.set(li.dataset.val); render(); } };
2015
2126
  btn.addEventListener("click", (e) => { e.stopPropagation(); menu.hidden ? open() : close(); });
2016
2127
  btn.addEventListener("keydown", (e) => { if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } });
2017
2128
  menu.addEventListener("click", (e) => pick(e.target.closest(".dd-opt")));
@@ -2021,11 +2132,65 @@ export {
2021
2132
  else if (e.key === "ArrowDown") { e.preventDefault(); (list[i + 1] || list[0]).focus(); }
2022
2133
  else if (e.key === "ArrowUp") { e.preventDefault(); (list[i - 1] || list[list.length - 1]).focus(); }
2023
2134
  else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(document.activeElement); }
2135
+ else if (e.key === "Tab") close();
2024
2136
  });
2025
- document.addEventListener("click", close);
2026
2137
  render();
2027
2138
  return { render };
2028
2139
  }
2140
+ // One global outside-click closes any open dropdown - cheaper and leak-free vs a per-instance
2141
+ // listener (enhanceSelect can create/replace many over a session). A `.dd-btn` click stops
2142
+ // propagation, so opening one never trips this.
2143
+ document.addEventListener("click", () => {
2144
+ document.querySelectorAll(".dd-menu:not([hidden])").forEach((m) => {
2145
+ m.hidden = true;
2146
+ const b = m.parentElement && m.parentElement.querySelector(".dd-btn");
2147
+ if (b) b.setAttribute("aria-expanded", "false");
2148
+ });
2149
+ });
2150
+ // entries: [{ value, label, icon }] bound to a pre-rendered `.dd` root by id.
2151
+ function iconDropdown(rootId, entries, getVal, onPick) {
2152
+ return bindDropdown($(rootId), { entries: () => entries, get: getVal, set: onPick });
2153
+ }
2154
+ // Markup for an enhanced dropdown's button + menu, matching the hand-written provider picker.
2155
+ 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>';
2156
+ // Progressive enhancement: replace a native <select> with the shared pretty dropdown while
2157
+ // keeping the <select> itself as the source of truth (hidden). Its .value and `change`
2158
+ // events are untouched - we set .value and dispatch a real `change` on pick - so every
2159
+ // existing reader and delegated handler keeps working. Icons are opt-in via
2160
+ // data-dd-icons="agent" (option value -> brand logo) or per-option data-icon="<key>".
2161
+ function enhanceSelect(select) {
2162
+ if (select.__dd || select.dataset.noDd != null) return;
2163
+ if (select.closest(".modal-back")) return; // a popup menu inside an animated modal would clip; left native (still styled)
2164
+ const iconMode = select.dataset.ddIcons || "";
2165
+ const iconFor = (opt) => { const k = opt.dataset.icon || (iconMode === "agent" ? opt.value : ""); return k ? (agentSvg(k) || "") : ""; };
2166
+ const root = document.createElement("span");
2167
+ root.className = "dd ddx";
2168
+ root.innerHTML = DD_INNER;
2169
+ select.style.display = "none";
2170
+ select.insertAdjacentElement("afterend", root);
2171
+ const api = bindDropdown(root, {
2172
+ entries: () => [...select.options].map((o) => ({ value: o.value, label: o.textContent, icon: iconFor(o) })),
2173
+ get: () => select.value,
2174
+ set: (v) => { if (v !== select.value) { select.value = v; select.dispatchEvent(new Event("change", { bubbles: true })); } api.render(); }
2175
+ });
2176
+ select.__dd = api;
2177
+ // Re-sync the button when options are rebuilt (innerHTML) or the value changes.
2178
+ new MutationObserver(() => api.render()).observe(select, { childList: true });
2179
+ select.addEventListener("change", () => api.render());
2180
+ return api;
2181
+ }
2182
+ // Push a programmatic `select.value = x` (no `change` event) into the enhanced dropdown.
2183
+ const syncDD = (el) => { if (el && el.__dd) el.__dd.render(); };
2184
+ function enhanceAll(root) { (root || document).querySelectorAll("select:not([data-no-dd])").forEach(enhanceSelect); }
2185
+ // Any selector added later (settings rows, project config, account profiles...) is enhanced
2186
+ // automatically, so there is nothing to wire per-view.
2187
+ new MutationObserver((muts) => {
2188
+ for (const m of muts) for (const n of m.addedNodes) {
2189
+ if (n.nodeType !== 1) continue;
2190
+ if (n.tagName === "SELECT") enhanceSelect(n);
2191
+ else if (n.querySelectorAll) n.querySelectorAll("select:not([data-no-dd])").forEach(enhanceSelect);
2192
+ }
2193
+ }).observe(document.body, { childList: true, subtree: true });
2029
2194
  let providerDD = null;
2030
2195
  function wireUsage() {
2031
2196
  // The provider picker is a custom icon dropdown; the account one stays a native select.
@@ -2102,6 +2267,7 @@ export {
2102
2267
  + names.map((n) => '<option value="' + esc(n) + '">' + esc(accountLabel(n)) + "</option>").join("");
2103
2268
  }
2104
2269
  sel.value = usageAccount;
2270
+ syncDD(sel);
2105
2271
  }
2106
2272
 
2107
2273
  // Fill the panel from one scope: the whole report (isAll) or one account's sub-report.
@@ -2330,6 +2496,7 @@ export {
2330
2496
  if (loadedUi === null) loadedUi = data.ui;
2331
2497
  else if (data.ui !== loadedUi) { location.reload(); return; }
2332
2498
  }
2499
+ renderUpdateAlert(data.updates);
2333
2500
  priceOverride = Number(data.priceOverride) || 0;
2334
2501
  speedOverride = Number(data.speedOverride) || 0;
2335
2502
  const s = data.stats || {};
@@ -2388,6 +2555,35 @@ export {
2388
2555
  }
2389
2556
  }
2390
2557
 
2558
+ // --- package update alert --------------------------------------------------
2559
+ // Server-side npm version check (CLI + dashboard + linter), surfaced in /api/stats and
2560
+ // refreshed every poll. Dismiss is sticky only for the exact versions shown - a later
2561
+ // release re-shows the banner. The "Update now" button runs `enigma update` behind the
2562
+ // scenes; the alert then clears on its own as the next polls see the new versions.
2563
+ let dismissedUpdateSig = "";
2564
+ function renderUpdateAlert(u) {
2565
+ const el = $("updateAlert");
2566
+ if (!el) return;
2567
+ const items = ((u && u.packages) || []).filter((p) => p.hasUpdate);
2568
+ const sig = items.map((p) => p.name + "@" + p.latest).join(",");
2569
+ if (!items.length || sig === dismissedUpdateSig) { el.hidden = true; return; }
2570
+ const list = items.map((p) => esc(p.label) + " " + esc(p.installed) + " -> " + esc(p.latest)).join(", ");
2571
+ $("updateAlertMsg").innerHTML = "<strong>Update available</strong> &nbsp;" + list;
2572
+ el.dataset.sig = sig;
2573
+ el.hidden = false;
2574
+ }
2575
+
2576
+ async function runFullUpdate() {
2577
+ const btn = $("updateAlertBtn");
2578
+ if (btn) { btn.disabled = true; btn.textContent = "Updating..."; }
2579
+ try {
2580
+ const res = await fetch("/api/run-update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
2581
+ const out = await res.json();
2582
+ toast(out.note || (out.ok ? "Update started." : "Could not start the update."), out.ok ? "ok" : "err");
2583
+ } catch { toast("Could not reach the server to update.", "err"); }
2584
+ finally { if (btn) { btn.disabled = false; btn.textContent = "Update now"; } }
2585
+ }
2586
+
2391
2587
  // --- dashboard self-update -------------------------------------------------
2392
2588
  // Where the latest published dashboard version lives: the raw package.json on the
2393
2589
  // repo's main branch (no API, no auth, no rate limit). A repo rename 404s here and is
@@ -2435,24 +2631,153 @@ export {
2435
2631
  // --- settings subpage (mirrors the TUI registry over /api/settings) ---
2436
2632
  function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
2437
2633
 
2438
- // A self-dismissing notification so a failed action can't slip by unseen. Errors
2439
- // linger longer than confirmations; both auto-clear so they never pile up.
2440
- function toast(msg, kind) {
2441
- let wrap = $("toasts");
2442
- if (!wrap) { wrap = document.createElement("div"); wrap.id = "toasts"; document.body.appendChild(wrap); }
2443
- const el = document.createElement("div");
2444
- el.className = "toast " + (kind === "err" ? "err" : kind === "ok" ? "ok" : "");
2445
- el.setAttribute("role", kind === "err" ? "alert" : "status");
2446
- el.textContent = msg;
2447
- wrap.appendChild(el);
2448
- setTimeout(() => { el.classList.add("hide"); setTimeout(() => el.remove(), 320); }, kind === "err" ? 6000 : 3000);
2449
- }
2450
- // Set a status note's text and ok/err styling in one place. Errors are also toasted
2451
- // (the inline note alone is easy to miss), so callers just call setNote(el, msg, "err").
2634
+ // --- Toasts (vanilla port of Sonner) -------------------------------------------------
2635
+ // A stacked, self-dismissing notification system: the newest sits in front, older ones
2636
+ // peek behind and fan out on hover; rich-coloured per type; swipe- or click-to-close.
2637
+ // API: toast(msg, opts) and toast.success/error/info/warning/loading/message/dismiss.
2638
+ // opts: { description, duration, id }. A loading toast has Infinity duration; calling a
2639
+ // type with the same id UPDATES it in place (loading -> success/error), so an async flow
2640
+ // is a single morphing toast, not a pile.
2641
+ const toast = (() => {
2642
+ const ICONS = {
2643
+ 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>',
2644
+ 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>',
2645
+ 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>',
2646
+ 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>',
2647
+ 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>',
2648
+ 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>'
2649
+ };
2650
+ const VISIBLE = 3, DURATION = { default: 4000, success: 4000, info: 4000, warning: 5000, error: 6000, loading: Infinity };
2651
+ let list = null, counter = 1, expanded = false;
2652
+ const items = []; // newest-first; { id, el, type, duration, remaining, startedAt, timer, removed }
2653
+ 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; };
2654
+ const wireHover = () => {
2655
+ list.addEventListener("mouseenter", () => { expanded = true; list.dataset.expanded = "true"; pauseAll(); place(); });
2656
+ list.addEventListener("mouseleave", () => { expanded = false; list.dataset.expanded = "false"; resumeAll(); place(); });
2657
+ };
2658
+ const front = () => items.find((t) => !t.removed);
2659
+ const pause = (t) => { if (t.timer) { clearTimeout(t.timer); t.timer = null; t.remaining -= Date.now() - t.startedAt; } };
2660
+ 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)); };
2661
+ const pauseAll = () => items.forEach(pause);
2662
+ const resumeAll = () => items.forEach(resume);
2663
+ // Position every toast: front shows fully, the rest peek behind (collapsed) or fan out
2664
+ // (expanded). --offset is the summed height of the live toasts in front of this one.
2665
+ function place() {
2666
+ let acc = 0, vi = 0;
2667
+ for (const t of items) {
2668
+ if (t.removed) { t.el.style.setProperty("--z", "0"); continue; }
2669
+ // The front toast (and any toast while the stack is expanded) is never
2670
+ // height-clamped, so its offsetHeight is the true natural height - cache it.
2671
+ if (vi === 0 && !expanded) t.height = t.el.offsetHeight;
2672
+ if (vi === 0) list.style.setProperty("--t-front", (t.height || t.el.offsetHeight) + "px");
2673
+ t.el.dataset.front = vi === 0 ? "true" : "false";
2674
+ t.el.dataset.expanded = expanded ? "true" : "false";
2675
+ t.el.dataset.visible = vi < VISIBLE ? "true" : "false";
2676
+ t.el.style.setProperty("--toasts-before", String(vi));
2677
+ t.el.style.setProperty("--offset", acc + "px");
2678
+ t.el.style.setProperty("--z", String(1000 - vi));
2679
+ acc += (t.height || t.el.offsetHeight) + 14;
2680
+ vi++;
2681
+ }
2682
+ }
2683
+ function dismiss(id) {
2684
+ const t = items.find((x) => x.id === id);
2685
+ if (!t || t.removed) return id;
2686
+ t.removed = true; if (t.timer) clearTimeout(t.timer);
2687
+ t.el.dataset.removed = "true"; t.el.dataset.expanded = expanded ? "true" : "false";
2688
+ place();
2689
+ setTimeout(() => { const i = items.indexOf(t); if (i !== -1) items.splice(i, 1); t.el.remove(); place(); }, 420);
2690
+ return id;
2691
+ }
2692
+ function build(t, type, title, description) {
2693
+ t.el.dataset.type = type;
2694
+ t.el.setAttribute("role", type === "error" ? "alert" : "status");
2695
+ const icon = ICONS[type] ? '<div class="etoast-icon">' + ICONS[type] + "</div>" : "";
2696
+ t.el.innerHTML = icon
2697
+ + '<div class="etoast-content"><div class="etoast-title"></div>'
2698
+ + (description ? '<div class="etoast-desc"></div>' : "") + "</div>"
2699
+ + '<button type="button" class="etoast-close" aria-label="Close">' + ICONS.close + "</button>";
2700
+ t.el.querySelector(".etoast-title").textContent = title == null ? "" : String(title);
2701
+ if (description) t.el.querySelector(".etoast-desc").textContent = String(description);
2702
+ t.el.querySelector(".etoast-close").addEventListener("click", (e) => { e.stopPropagation(); dismiss(t.id); });
2703
+ }
2704
+ // Horizontal swipe-to-dismiss (pointer drag), matching Sonner's feel.
2705
+ function wireSwipe(t) {
2706
+ let startX = null;
2707
+ t.el.addEventListener("pointerdown", (e) => { if (e.target.closest(".etoast-close")) return; startX = e.clientX; t.el.setPointerCapture(e.pointerId); pause(t); });
2708
+ t.el.addEventListener("pointermove", (e) => {
2709
+ if (startX == null) return;
2710
+ const dx = e.clientX - startX;
2711
+ t.el.dataset.swiping = "true"; t.el.style.setProperty("--swipe-x", dx + "px");
2712
+ });
2713
+ const end = (e) => {
2714
+ if (startX == null) return;
2715
+ const dx = e.clientX - startX; startX = null; t.el.dataset.swiping = "false";
2716
+ if (Math.abs(dx) > 80) dismiss(t.id);
2717
+ else { t.el.style.removeProperty("--swipe-x"); if (!expanded) resume(t); }
2718
+ };
2719
+ t.el.addEventListener("pointerup", end);
2720
+ t.el.addEventListener("pointercancel", end);
2721
+ }
2722
+ function create(opts) {
2723
+ const type = opts.type || "default";
2724
+ const id = opts.id != null ? opts.id : counter++;
2725
+ const duration = opts.duration != null ? opts.duration : DURATION[type];
2726
+ const existing = items.find((x) => x.id === id && !x.removed);
2727
+ if (existing) { // update in place (e.g. loading -> success)
2728
+ build(existing, type, opts.title, opts.description);
2729
+ existing.duration = duration; existing.remaining = duration; existing.startedAt = Date.now();
2730
+ if (existing.timer) { clearTimeout(existing.timer); existing.timer = null; }
2731
+ if (!expanded) resume(existing);
2732
+ place();
2733
+ return id;
2734
+ }
2735
+ mount();
2736
+ const el = document.createElement("li");
2737
+ el.className = "etoast"; el.tabIndex = 0;
2738
+ const t = { id, el, type, duration, remaining: duration, startedAt: Date.now(), timer: null, removed: false };
2739
+ build(t, type, opts.title, opts.description);
2740
+ wireSwipe(t);
2741
+ items.unshift(t);
2742
+ list.prepend(el);
2743
+ place();
2744
+ requestAnimationFrame(() => { el.dataset.mounted = "true"; place(); if (!expanded) resume(t); });
2745
+ return id;
2746
+ }
2747
+ // Back-compat: a string 2nd arg is the legacy kind ("ok"/"err"); an object is opts.
2748
+ const KIND = { ok: "success", err: "error" };
2749
+ const api = (msg, o) => create(typeof o === "string" ? { type: KIND[o] || "default", title: msg } : { ...o, type: "default", title: msg });
2750
+ ["success", "error", "info", "warning", "loading", "message"].forEach((k) => {
2751
+ api[k] = (msg, o) => create({ ...o, type: k === "message" ? "default" : k, title: msg });
2752
+ });
2753
+ api.dismiss = (id) => { if (id == null) items.slice().forEach((t) => dismiss(t.id)); else dismiss(id); return id; };
2754
+ return api;
2755
+ })();
2756
+
2757
+ // Bridge the old inline-note API onto toasts so every existing call site upgrades at once.
2758
+ // A flow reuses ONE note target (e.g. "settingsNote") start to finish, so we key a single
2759
+ // toast per target: a progress message ("Saving...") opens a loading toast, and the ok/err
2760
+ // result morphs that same toast - no pile-up, no leftover green text. `restartNote`-style
2761
+ // long messages split into title + description at the first sentence break.
2762
+ const noteToastId = new Map();
2763
+ function noteKey(el) { return typeof el === "string" ? el : (el && el.id) || "__note__"; }
2764
+ function splitMsg(msg) {
2765
+ msg = String(msg);
2766
+ const m = msg.match(/^(.*?[.!])\s+(.+)$/s);
2767
+ return m && m[1].length <= 64 ? { title: m[1], description: m[2] } : { title: msg };
2768
+ }
2769
+ // Drop-in replacement for the former setNote(el, msg, kind). `el` is ignored for display
2770
+ // (kept for call compatibility); everything surfaces as a toast.
2452
2771
  function setNote(el, msg, kind) {
2453
- if (typeof el === "string") el = $(el);
2454
- if (el) { el.textContent = msg; el.classList.toggle("ok", kind === "ok"); el.classList.toggle("err", kind === "err"); }
2455
- if (kind === "err") toast(msg, "err");
2772
+ const key = noteKey(el);
2773
+ const prev = noteToastId.get(key);
2774
+ if (msg == null || msg === "") { if (prev != null) { toast.dismiss(prev); noteToastId.delete(key); } return; }
2775
+ const isProgress = !kind && /(\.\.\.|…)\s*$/.test(String(msg));
2776
+ const type = kind === "ok" ? "success" : kind === "err" ? "error" : isProgress ? "loading" : "info";
2777
+ const parts = splitMsg(msg);
2778
+ const id = toast[type](parts.title, { id: prev, description: parts.description });
2779
+ if (isProgress) noteToastId.set(key, id);
2780
+ else noteToastId.delete(key);
2456
2781
  }
2457
2782
 
2458
2783
  // Capitalize a choice value for display ("on-demand" -> "On demand"), keeping the raw
@@ -2599,7 +2924,7 @@ export {
2599
2924
  const want = isBool ? (value === true || value === 1) : String(value);
2600
2925
  const prevBool = ctl.dataset.on === "1";
2601
2926
  const prevChoice = ctl.dataset.prev != null ? ctl.dataset.prev : ctl.value; // <select> already changed natively
2602
- const rollback = () => { if (isBool) applyBool(ctl, prevBool); else ctl.value = prevChoice; };
2927
+ const rollback = () => { if (isBool) applyBool(ctl, prevBool); else { ctl.value = prevChoice; syncDD(ctl); } };
2603
2928
  if (isBool) applyBool(ctl, want);
2604
2929
  setNote(el, "Saving " + key + "...");
2605
2930
  try {
@@ -2608,7 +2933,7 @@ export {
2608
2933
  if (!out.ok) { rollback(); setNote(el, "Could not change " + key + ": " + (out.error || "error"), "err"); return; }
2609
2934
  const s = out.setting;
2610
2935
  if (s && isBool) applyBool(ctl, s.value);
2611
- else if (s) { ctl.value = s.choice; ctl.dataset.prev = s.choice; }
2936
+ else if (s) { ctl.value = s.choice; ctl.dataset.prev = s.choice; syncDD(ctl); }
2612
2937
  if (s && (isBool ? s.value !== want : String(s.choice) !== want)) { setNote(el, key + " did not change (the write had no effect).", "err"); return; }
2613
2938
  setNote(el, out.restartNote || ("Saved " + key + "."), "ok");
2614
2939
  } catch { rollback(); setNote(el, "Could not reach the server to change " + key + ".", "err"); }
@@ -3145,7 +3470,7 @@ export {
3145
3470
 
3146
3471
  // --- accounts & profiles subpage (mirrors the TUI hub over /api/accounts) ---
3147
3472
  let accountsLoaded = false;
3148
- let accTools = [], accAccounts = [], accProfiles = [];
3473
+ let accTools = [], accAccounts = [], accProfiles = [], accPresets = [];
3149
3474
  // Real-time name validator mirroring the server (accounts.ts validateAccountName + the
3150
3475
  // duplicate checks), so the modal blocks an invalid or already-used name as the user types.
3151
3476
  // `taken` = names already in scope (per-tool for accounts, global for profiles); `current`
@@ -3168,16 +3493,23 @@ export {
3168
3493
  + (a.loggedIn ? '<span class="tag">' + esc(a.email || "logged in") + "</span>" : '<span class="tag warn">not logged in</span>');
3169
3494
  const btn = (act, label, cls) => '<button type="button" class="' + (cls || "toggle") + '" data-acc="' + act + '" data-tool="' + esc(a.tool) + '" data-name="' + esc(a.name) + '">' + label + "</button>";
3170
3495
  const removeBtn = '<button type="button" class="btn-danger icon" title="Remove" aria-label="Remove account ' + esc(a.name) + '" data-acc="remove" data-tool="' + esc(a.tool) + '" data-name="' + esc(a.name) + '">' + TRASH_ICON + "</button>";
3496
+ // Provider override (e.g. MiniMax) only for tools that support it, and never the
3497
+ // built-in "default" account (kept on the vendor default). Managed accounts only.
3498
+ const provBtn = (a.supportsProvider && a.removable) ? btn("provider", "Provider") : "";
3171
3499
  const acts = '<div class="set-ctl" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">'
3172
3500
  + (a.active ? "" : btn("activate", "Use"))
3173
3501
  + (a.loggedIn ? "" : btn("login", "Log in..."))
3502
+ + provBtn
3174
3503
  + (a.removable ? btn("rename", "Rename") + removeBtn : "")
3175
3504
  + "</div>";
3176
3505
  const toolIc = agentSvg(a.tool);
3177
3506
  const toolBadge = toolIc
3178
3507
  ? '<span class="aicon set-ic" title="' + esc(a.toolLabel) + '" aria-label="' + esc(a.toolLabel) + '">' + toolIc + "</span>"
3179
3508
  : '<span class="tag ext">' + esc(a.toolLabel) + "</span> ";
3180
- return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(a.name) + " " + toolBadge + badges
3509
+ const provTag = a.provider
3510
+ ? ' <span class="tag meas" title="' + esc(a.provider.baseUrl) + '">via ' + esc(a.provider.preset && a.provider.preset !== "custom" ? a.provider.preset : "custom") + (a.provider.model ? " · " + esc(a.provider.model) : "") + "</span>"
3511
+ : "";
3512
+ return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(a.name) + " " + toolBadge + badges + provTag
3181
3513
  + '</div><div class="set-hint">' + esc(a.dir) + "</div></div>" + acts + "</div>";
3182
3514
  }
3183
3515
  function profileRow(p, tools, accounts) {
@@ -3200,7 +3532,7 @@ export {
3200
3532
  function renderAccounts(d) {
3201
3533
  d = d || {};
3202
3534
  const accounts = d.accounts || [], profiles = d.profiles || [], tools = d.tools || [];
3203
- accTools = tools; accAccounts = accounts; accProfiles = profiles;
3535
+ accTools = tools; accAccounts = accounts; accProfiles = profiles; accPresets = d.presets || [];
3204
3536
  $("accountsBody").innerHTML = accounts.length ? accounts.map(accountRow).join("") : '<div class="empty" style="padding:20px 0">No accounts.</div>';
3205
3537
  $("profilesBody").innerHTML = profiles.length ? profiles.map((p) => profileRow(p, tools, accounts)).join("")
3206
3538
  : '<div class="empty" style="padding:20px 0">No profiles yet. Add one to pin an account per tool.</div>';
@@ -3219,6 +3551,69 @@ export {
3219
3551
  setNote("accountsNote", "Error: " + (out.error || "failed"), "err");
3220
3552
  } catch { if (rollback) rollback(); setNote("accountsNote", "Could not reach the server.", "err"); }
3221
3553
  }
3554
+ // Provider-override editor: a small multi-field modal (backend select + base/model/token)
3555
+ // since openModal only does single fields. Resolves to { clear:true } to drop the override,
3556
+ // an object to set it ({ baseUrl, model?, preset?, token? } - token only when typed), or null.
3557
+ function editProvider(a) {
3558
+ return new Promise((resolve) => {
3559
+ const toolPresets = accPresets.filter((p) => p.tool === a.tool);
3560
+ const cur = a.provider; // { baseUrl, model?, preset?, hasToken } | null
3561
+ const opts = ['<option value="default">Default (Anthropic)</option>']
3562
+ .concat(toolPresets.map((p) => '<option value="' + esc(p.id) + '">' + esc(p.label) + "</option>"))
3563
+ .concat(['<option value="custom">Custom (Anthropic-compatible)</option>']).join("");
3564
+ const back = document.createElement("div");
3565
+ back.className = "modal-back";
3566
+ back.innerHTML = '<div class="modal" role="dialog" aria-modal="true" style="max-width:480px;text-align:left">'
3567
+ + '<div class="modal-title">Provider for ' + esc(a.name) + "</div>"
3568
+ + '<div class="modal-msg">Point ' + esc(a.toolLabel) + " at another Anthropic-compatible backend (e.g. MiniMax), or keep the default.</div>"
3569
+ + '<label class="prov-l">Backend</label><select class="modal-input" id="provSel">' + opts + "</select>"
3570
+ + '<div id="provFields" style="margin-top:8px">'
3571
+ + '<label class="prov-l">Base URL</label><input class="modal-input" id="provBase" placeholder="https://...">'
3572
+ + '<label class="prov-l">Model</label><input class="modal-input" id="provModel" placeholder="(provider default)">'
3573
+ + '<label class="prov-l">API key</label><input class="modal-input" id="provToken" type="password" autocomplete="off" placeholder="' + (cur && cur.hasToken ? "set - leave blank to keep" : "API key") + '">'
3574
+ + '<div class="prov-hint" id="provHint"></div></div>'
3575
+ + '<div class="modal-acts"><button type="button" class="toggle modal-cancel">Cancel</button>'
3576
+ + '<button type="button" class="toggle on modal-ok">Save</button></div></div>';
3577
+ document.body.appendChild(back);
3578
+ requestAnimationFrame(() => back.classList.add("show"));
3579
+ const sel = back.querySelector("#provSel"), fields = back.querySelector("#provFields");
3580
+ const base = back.querySelector("#provBase"), model = back.querySelector("#provModel"), token = back.querySelector("#provToken"), hint = back.querySelector("#provHint");
3581
+ sel.value = cur ? (cur.preset && cur.preset !== "custom" ? cur.preset : "custom") : "default";
3582
+ function applySel() {
3583
+ const v = sel.value;
3584
+ fields.style.display = v === "default" ? "none" : "";
3585
+ if (v === "default") return;
3586
+ const preset = toolPresets.find((p) => p.id === v);
3587
+ if (preset) {
3588
+ base.value = (cur && cur.preset === v && cur.baseUrl) || preset.baseUrl;
3589
+ model.value = (cur && cur.preset === v && cur.model) || preset.model || "";
3590
+ hint.innerHTML = preset.tokenUrl ? 'Get a key at <a href="' + esc(preset.tokenUrl) + '" target="_blank" rel="noopener">' + esc(preset.tokenUrl) + "</a>" : "";
3591
+ } else {
3592
+ base.value = (cur && (!cur.preset || cur.preset === "custom") && cur.baseUrl) || "";
3593
+ model.value = (cur && cur.model) || "";
3594
+ hint.textContent = "";
3595
+ }
3596
+ }
3597
+ applySel();
3598
+ sel.addEventListener("change", applySel);
3599
+ let done = false;
3600
+ const close = (val) => { if (done) return; done = true; back.classList.remove("show"); setTimeout(() => back.remove(), 200); resolve(val); };
3601
+ back.querySelector(".modal-cancel").addEventListener("click", () => close(null));
3602
+ back.addEventListener("mousedown", (e) => { if (e.target === back) close(null); });
3603
+ back.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); close(null); } });
3604
+ back.querySelector(".modal-ok").addEventListener("click", () => {
3605
+ if (sel.value === "default") { close({ clear: true }); return; }
3606
+ const b = base.value.trim();
3607
+ if (!/^https?:\/\//i.test(b)) { hint.textContent = "Enter a base URL starting with http:// or https://"; return; }
3608
+ const out = { baseUrl: b };
3609
+ if (model.value.trim()) out.model = model.value.trim();
3610
+ if (sel.value !== "custom") out.preset = sel.value;
3611
+ if (token.value) out.token = token.value; // only send a typed key (blank keeps the stored one)
3612
+ close(out);
3613
+ });
3614
+ sel.focus();
3615
+ });
3616
+ }
3222
3617
  function wireAccounts() {
3223
3618
  $("accAdd").addEventListener("click", async () => {
3224
3619
  const tool = accTools.length === 1 ? accTools[0].name
@@ -3236,6 +3631,12 @@ export {
3236
3631
  else if (op === "remove") { if (await askConfirm("Remove account", "Remove account '" + name + "' and delete its local config dir?", true)) accAction("account.remove", { tool, name }); }
3237
3632
  else if (op === "rename") { const taken = accAccounts.filter((a) => a.tool === tool).map((a) => a.name); const nn = await askText("Rename account", name, "Rename '" + name + "' to:", nameValidator(taken, name)); if (nn && nn !== name) accAction("account.rename", { tool, name, newName: nn }); }
3238
3633
  else if (op === "login") { setNote("accountsNote", "To log in, run this in a terminal: enigma " + tool + " " + name); }
3634
+ else if (op === "provider") {
3635
+ const a = accAccounts.find((x) => x.tool === tool && x.name === name);
3636
+ if (!a) return;
3637
+ const out = await editProvider(a);
3638
+ if (out) accAction("account.provider", { tool, name, provider: out.clear ? null : out });
3639
+ }
3239
3640
  });
3240
3641
  $("profilesBody").addEventListener("click", async (e) => {
3241
3642
  const b = e.target.closest("button[data-prof-act]"); if (!b) return;
@@ -3387,15 +3788,15 @@ export {
3387
3788
  if (!v.enabled) {
3388
3789
  $("recallBody").innerHTML = '<div class="panel"><div class="set-cat-h">Session memory is off</div>'
3389
3790
  + '<div class="set-hint" style="padding:8px 0">Build a searchable memory of your coding sessions from your own transcripts.</div>'
3390
- + '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><button type="button" class="toggle" data-recall-on="1">Enable recall</button>'
3391
- + '<span class="set-hint">or run <code>enigma config recall on</code></span></div></div>';
3791
+ + '<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>'
3792
+ + '<span class="set-hint">Turn on, or run <code>enigma config recall on</code></span></div></div>';
3392
3793
  $("recallSearch").style.display = "none"; $("recallProviderPanel").style.display = "none"; $("recallAddPanel").style.display = "none"; $("recallList").innerHTML = ""; return;
3393
3794
  }
3394
3795
  const s = v.stats || {};
3395
3796
  const types = Object.entries(s.byType || {}).map(([t, n]) => '<span class="tag">' + esc(t) + " " + n + "</span>").join(" ");
3396
3797
  const last = v.lastSync ? "last sync " + new Date(v.lastSync).toLocaleString() : "not synced yet";
3397
3798
  $("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>'
3398
- + '<button type="button" class="toggle on" data-recall-on="0" title="Turn session memory off">Disable</button></div>'
3799
+ + '<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>'
3399
3800
  + '<div class="set-hint" style="padding:6px 0">' + (s.observations || 0) + " observations · " + (s.sessions || 0) + " sessions · "
3400
3801
  + (s.projects || 0) + " projects · " + Math.round((s.dbBytes || 0) / 1024) + " KB · " + esc(last) + "</div>"
3401
3802
  + (types ? '<div style="padding-top:6px">' + types + "</div>" : "") + "</div>";
@@ -3450,6 +3851,9 @@ export {
3450
3851
  llmBtn.setAttribute("aria-checked", p.llm ? "true" : "false");
3451
3852
  }
3452
3853
  let recallItemsById = {};
3854
+ // Multi-select state for the Recall list: a Set of selected ids, the ordered ids as
3855
+ // rendered (for Shift range selection), and the anchor index for the next Shift+click.
3856
+ let recallSel = new Set(), recallOrderIds = [], recallAnchorIdx = null;
3453
3857
  function recallRowHtml(it, anchorId) {
3454
3858
  const ic = '<span class="aicon" title="' + esc(it.source) + '" aria-label="' + esc(it.source) + '">' + (agentSvg(it.source) || esc(it.source)) + "</span>";
3455
3859
  const date = it.createdAt ? new Date(it.createdAt).toISOString().slice(0, 10) : "";
@@ -3466,18 +3870,50 @@ export {
3466
3870
  + facts
3467
3871
  + (it.concepts && it.concepts.length ? '<div style="margin-top:6px">' + tags(it.concepts) + "</div>" : "")
3468
3872
  + list("Modified", it.files) + list("Read", it.filesRead)
3469
- + '<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap"><button type="button" class="toggle" data-tl="' + it.id + '">Show timeline</button>'
3470
- + (it.id ? '<button type="button" class="btn-danger" data-del="' + it.id + '" title="Delete this memory">Delete</button>' : "") + "</div>"
3873
+ + '<div style="margin-top:8px"><button type="button" class="toggle" data-tl="' + it.id + '">Show timeline</button></div>'
3471
3874
  + '<div class="rtl" id="rtl-' + it.id + '"></div></div>';
3472
3875
  }
3473
3876
  function renderRecallList(items) {
3474
3877
  recallItemsById = {};
3878
+ // A fresh list invalidates the previous selection (ids may have changed).
3879
+ recallSel = new Set(); recallOrderIds = []; recallAnchorIdx = null;
3475
3880
  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; }
3476
- $("recallList").innerHTML = '<div class="panel">' + items.map((it) => {
3477
- if (it.id) recallItemsById[it.id] = it;
3478
- return '<div class="set-row" style="cursor:pointer" data-rid="' + (it.id || "") + '">' + recallRowHtml(it) + "</div>"
3881
+ const rows = items.map((it) => {
3882
+ // data-idx aligns with recallOrderIds (id'd rows only) so Shift ranges are exact.
3883
+ let dataIdx = "";
3884
+ if (it.id) { recallItemsById[it.id] = it; recallOrderIds.push(it.id); dataIdx = recallOrderIds.length - 1; }
3885
+ 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>" : "";
3886
+ const check = it.id ? '<input type="checkbox" class="rc-check" data-id="' + it.id + '" aria-label="Select this memory">' : '<span style="width:15px;flex:0 0 auto"></span>';
3887
+ return '<div class="set-row" style="cursor:pointer" data-rid="' + (it.id || "") + '"' + (it.id ? ' data-idx="' + dataIdx + '"' : "") + '>' + check + recallRowHtml(it) + del + "</div>"
3479
3888
  + '<div id="rd-' + (it.id || "") + '" style="display:none"></div>';
3480
- }).join("") + "</div>";
3889
+ }).join("");
3890
+ // Bulk-action bar (hidden until something is selected) + the rows.
3891
+ $("recallList").innerHTML = '<div id="recallBulk" class="rc-bulk" style="display:none">'
3892
+ + '<span class="rc-seln" id="recallSelN"></span>'
3893
+ + '<button type="button" class="btn-danger" data-bulk-del>Delete selected</button>'
3894
+ + '<button type="button" class="toggle" data-bulk-clear>Clear selection</button></div>'
3895
+ + '<div class="panel">' + rows + "</div>";
3896
+ }
3897
+ /** Reflect the current selection in the DOM: row highlight, checkboxes, and the bulk bar. */
3898
+ function updateRecallSelUI() {
3899
+ document.querySelectorAll("#recallList .set-row[data-rid]").forEach((row) => {
3900
+ const id = Number(row.dataset.rid);
3901
+ const on = recallSel.has(id);
3902
+ row.classList.toggle("sel", on);
3903
+ const cb = row.querySelector(".rc-check");
3904
+ if (cb) cb.checked = on;
3905
+ });
3906
+ const bar = $("recallBulk");
3907
+ if (bar) {
3908
+ bar.style.display = recallSel.size ? "" : "none";
3909
+ const n = $("recallSelN");
3910
+ if (n) n.textContent = recallSel.size + (recallSel.size === 1 ? " memory selected" : " memories selected");
3911
+ }
3912
+ }
3913
+ /** Select every id between two row indices (inclusive), for Shift+click range selection. */
3914
+ function recallSelectRange(fromIdx, toIdx) {
3915
+ const lo = Math.min(fromIdx, toIdx), hi = Math.max(fromIdx, toIdx);
3916
+ for (let i = lo; i <= hi; i++) { const id = recallOrderIds[i]; if (id) recallSel.add(id); }
3481
3917
  }
3482
3918
  async function loadRecallTimeline(id) {
3483
3919
  const el = $("rtl-" + id);
@@ -3572,6 +4008,16 @@ export {
3572
4008
  $("recallType").addEventListener("change", loadRecall);
3573
4009
  $("recallProject").addEventListener("change", loadRecall);
3574
4010
  $("recallList").addEventListener("click", async (e) => {
4011
+ // Bulk actions on the current multi-selection.
4012
+ if (e.target.closest("[data-bulk-clear]")) { recallSel.clear(); recallAnchorIdx = null; updateRecallSelUI(); return; }
4013
+ if (e.target.closest("[data-bulk-del]")) {
4014
+ const ids = [...recallSel];
4015
+ if (!ids.length) return;
4016
+ const noun = ids.length === 1 ? "memory" : "memories";
4017
+ if (!(await askConfirm("Delete memories", "Delete " + ids.length + " selected " + noun + "? This cannot be undone.", true))) return;
4018
+ recallOpPost({ op: "delete", ids }, "Deleting " + ids.length + "...", ids.length + " " + noun + " deleted.");
4019
+ return;
4020
+ }
3575
4021
  const del = e.target.closest("[data-del]");
3576
4022
  if (del) {
3577
4023
  if (!(await askConfirm("Delete memory", "Delete this memory? This cannot be undone.", true))) return;
@@ -3582,6 +4028,22 @@ export {
3582
4028
  if (tl) { loadRecallTimeline(tl.dataset.tl); return; }
3583
4029
  const row = e.target.closest("[data-rid]");
3584
4030
  if (!row || !row.dataset.rid) return;
4031
+ const id = Number(row.dataset.rid);
4032
+ const cb = e.target.closest(".rc-check");
4033
+ // Selection shortcuts: a checkbox click, Ctrl/Cmd+click (toggle one), or Shift+click
4034
+ // (select the range from the last-clicked anchor through this row, inclusive).
4035
+ if (cb || e.ctrlKey || e.metaKey || e.shiftKey) {
4036
+ const idx = row.dataset.idx === undefined ? -1 : Number(row.dataset.idx);
4037
+ if (e.shiftKey && recallAnchorIdx !== null && idx >= 0) {
4038
+ recallSelectRange(recallAnchorIdx, idx);
4039
+ } else {
4040
+ if (recallSel.has(id)) recallSel.delete(id); else recallSel.add(id);
4041
+ if (idx >= 0) recallAnchorIdx = idx;
4042
+ }
4043
+ updateRecallSelUI();
4044
+ return;
4045
+ }
4046
+ // Plain click expands/collapses the detail (unchanged).
3585
4047
  const detail = $("rd-" + row.dataset.rid);
3586
4048
  if (!detail) return;
3587
4049
  if (detail.innerHTML === "") detail.innerHTML = recallDetailHtml(recallItemsById[row.dataset.rid]);
@@ -3611,6 +4073,7 @@ export {
3611
4073
  + projectsCache.map((p) => '<option value="' + esc(p.path) + '">' + esc(p.label) + "</option>").join("")
3612
4074
  + '<option value="__create__">+ Create new project</option>';
3613
4075
  sel.value = currentScope;
4076
+ syncDD(sel);
3614
4077
  }
3615
4078
  async function loadProjectsList() {
3616
4079
  try { projectsCache = ((await (await fetch("/api/projects", { cache: "no-store" })).json()).projects) || []; }
@@ -3690,7 +4153,7 @@ export {
3690
4153
  const sel = $("scopeSel");
3691
4154
  if (sel) sel.addEventListener("change", () => {
3692
4155
  const val = sel.value;
3693
- if (val === "__create__") { sel.value = currentScope; openCreateProject(); return; }
4156
+ if (val === "__create__") { sel.value = currentScope; syncDD(sel); openCreateProject(); return; }
3694
4157
  currentScope = val;
3695
4158
  try { localStorage.setItem("enigmaScope", val); } catch { /* */ }
3696
4159
  route();
@@ -3882,10 +4345,13 @@ export {
3882
4345
  wireResources();
3883
4346
  wireRecall();
3884
4347
  wireScope();
4348
+ enhanceAll(); // upgrade every static <select> into the shared pretty dropdown
3885
4349
  $("cfgExport").addEventListener("click", exportConfig);
3886
4350
  $("cfgImport").addEventListener("click", importConfig);
3887
4351
  $("updateBtn").addEventListener("click", runUpdate);
3888
4352
  $("fixPathBtn").addEventListener("click", runFixPath);
4353
+ $("updateAlertBtn").addEventListener("click", runFullUpdate);
4354
+ $("updateAlertDismiss").addEventListener("click", () => { dismissedUpdateSig = $("updateAlert").dataset.sig || ""; $("updateAlert").hidden = true; });
3889
4355
  $("refreshBtn").addEventListener("click", async () => {
3890
4356
  const b = $("refreshBtn");
3891
4357
  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.61",
3
+ "version": "0.1.64",
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": [