privateboard 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/version.d.ts CHANGED
@@ -12,6 +12,6 @@
12
12
  * number ends up surfaced in the user-facing footer or banner. Keep
13
13
  * this file as the canonical source — every callsite reads from here.
14
14
  */
15
- declare const VERSION = "0.1.23";
15
+ declare const VERSION = "0.1.25";
16
16
 
17
17
  export { VERSION };
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/version.ts
4
- var VERSION = "0.1.23";
4
+ var VERSION = "0.1.25";
5
5
  export {
6
6
  VERSION
7
7
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/version.ts"],"sourcesContent":["/**\n * Single source of truth for the app version.\n *\n * Imported by `cli.ts` (CLI banner / `--version`), `server.ts` (the\n * `/health` payload + the `/api/version` endpoint), and bundled into\n * the frontend via the version endpoint. Bump alongside `package.json`\n * on every release — the existing `npm version <patch|minor|major>`\n * + commit pattern updates package.json automatically; this file\n * needs the matching manual bump.\n *\n * If two strings drift (bumped one but not the other), the wrong\n * number ends up surfaced in the user-facing footer or banner. Keep\n * this file as the canonical source — every callsite reads from here.\n */\nexport const VERSION = \"0.1.23\";\n"],"mappings":";;;AAcO,IAAM,UAAU;","names":[]}
1
+ {"version":3,"sources":["../src/version.ts"],"sourcesContent":["/**\n * Single source of truth for the app version.\n *\n * Imported by `cli.ts` (CLI banner / `--version`), `server.ts` (the\n * `/health` payload + the `/api/version` endpoint), and bundled into\n * the frontend via the version endpoint. Bump alongside `package.json`\n * on every release — the existing `npm version <patch|minor|major>`\n * + commit pattern updates package.json automatically; this file\n * needs the matching manual bump.\n *\n * If two strings drift (bumped one but not the other), the wrong\n * number ends up surfaced in the user-facing footer or banner. Keep\n * this file as the canonical source — every callsite reads from here.\n */\nexport const VERSION = \"0.1.25\";\n"],"mappings":";;;AAcO,IAAM,UAAU;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateboard",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "PrivateBoard · your private board meeting, on call. Local-first, multi-agent thinking amplifier.",
5
5
  "type": "module",
6
6
  "main": "electron-entry.cjs",
@@ -211,6 +211,88 @@ img[data-agent]:hover { filter: brightness(1.15); }
211
211
  }
212
212
  .agent-model-provider:empty { display: none; }
213
213
 
214
+ /* Editable model picker · the trigger + popover reuse the
215
+ agent-profile model dropdown vocabulary (`.ap-model-trigger` /
216
+ `.ap-model-picker` / `.ap-model-group` / `.ap-model-opt`), defined
217
+ in agent-profile.css so the styles are shared automatically. Both
218
+ stylesheets are loaded on the main app page (see index.html), so
219
+ the classes resolve identically inside the overlay. */
220
+ .agent-model-edit[hidden] { display: none; }
221
+ /* Lift the `.ap-model-picker` popover above the overlay backdrop ·
222
+ the overlay is z-index 9700; the agent-profile picker default is
223
+ 9100, which would sit behind the modal. Scoped modifier class so
224
+ we don't regress the profile-page stacking. */
225
+ .ap-model-picker.ap-model-picker-overlay { z-index: 9800; }
226
+
227
+ .agent-model-meta {
228
+ display: flex;
229
+ align-items: center;
230
+ flex-wrap: wrap;
231
+ gap: 8px;
232
+ margin-top: 8px;
233
+ font-family: var(--mono);
234
+ font-size: 10px;
235
+ letter-spacing: 0.16em;
236
+ text-transform: uppercase;
237
+ color: var(--text-soft);
238
+ }
239
+ .agent-model-saving {
240
+ color: var(--text-faint);
241
+ letter-spacing: 0.14em;
242
+ }
243
+ .agent-model-saving[hidden] { display: none; }
244
+ .agent-model-error {
245
+ color: var(--red, #B5706A);
246
+ letter-spacing: 0.04em;
247
+ text-transform: none;
248
+ font-size: 11px;
249
+ flex-basis: 100%;
250
+ }
251
+ .agent-model-error[hidden] { display: none; }
252
+
253
+ /* Kick-from-room block · alert-coloured destructive action. Reads as
254
+ "this will remove someone" from idle state — not just on hover —
255
+ so the user perceives the danger BEFORE they reach for it. Uses
256
+ the project's `--red` token (#B5706A · the same alert tint
257
+ referenced in `.notes-item-unfav` and the brief-llm failure rows)
258
+ so the colour vocabulary matches other destructive surfaces.
259
+ `[data-agent-room-actions][hidden]` gates the block · the overlay
260
+ JS only un-hides it when the agent is kickable
261
+ (in-room + director + non-adjourned). */
262
+ .agent-room-actions[hidden] { display: none; }
263
+ .agent-kick-btn {
264
+ appearance: none;
265
+ display: block;
266
+ width: 100%;
267
+ background: color-mix(in srgb, var(--red, #B5706A) 6%, transparent);
268
+ border: 0.5px solid color-mix(in srgb, var(--red, #B5706A) 55%, var(--line-bright));
269
+ color: var(--red, #B5706A);
270
+ font-family: var(--mono);
271
+ font-size: 11px;
272
+ /* Weight 400 (was 700) · the alert tint + uppercase letter-spacing
273
+ already carry the "destructive" register; bold on top read as
274
+ shouting. Quieter weight pairs better with the calm overlay
275
+ surface around it. */
276
+ font-weight: 400;
277
+ letter-spacing: 0.14em;
278
+ text-transform: uppercase;
279
+ padding: 10px 14px;
280
+ cursor: pointer;
281
+ transition: border-color 0.12s, color 0.12s, background 0.12s;
282
+ }
283
+ .agent-kick-btn:hover {
284
+ background: color-mix(in srgb, var(--red, #B5706A) 14%, transparent);
285
+ border-color: var(--red, #B5706A);
286
+ /* Bright white text on hover lifts the affordance from "noted
287
+ danger" to "click to commit". */
288
+ color: #FFFFFF;
289
+ }
290
+ .agent-kick-btn:active { transform: translateY(1px); }
291
+ .agent-kick-btn:disabled {
292
+ opacity: 0.45;
293
+ cursor: wait;
294
+ }
295
+
214
296
  .agent-traits {
215
297
  display: flex;
216
298
  flex-wrap: wrap;
@@ -216,10 +216,30 @@
216
216
 
217
217
  <div class="agent-block agent-model-block">
218
218
  <div class="agent-block-label" data-i18n="ao_model">Model</div>
219
- <div class="agent-model-display">
219
+ <div class="agent-model-display agent-model-display-readonly">
220
220
  <span class="agent-model-name"></span>
221
221
  <span class="agent-model-provider"></span>
222
222
  </div>
223
+ <!-- Interactive model picker · only mounted when window.app
224
+ is present (in-room view); on standalone gallery pages
225
+ the readonly display above shows instead. Trigger reuses
226
+ the agent-profile model dropdown vocabulary (ap-model-*)
227
+ so the in-room overlay and the full profile page share
228
+ ONE control treatment for model selection · same panel
229
+ bar, name + provider chip + caret, same popover rows. -->
230
+ <div class="agent-model-edit private-only" data-agent-model-edit hidden>
231
+ <button type="button" class="ap-model-trigger" data-agent-model-trigger>
232
+ <span class="ap-model-trigger-text">
233
+ <span class="ap-model-trigger-name" data-agent-model-value></span>
234
+ <span class="ap-model-trigger-provider" data-agent-model-provider-tag></span>
235
+ </span>
236
+ <span class="ap-model-trigger-caret">▾</span>
237
+ </button>
238
+ <div class="agent-model-meta">
239
+ <span class="agent-model-saving" data-agent-model-saving hidden data-i18n="ao_model_saving">Saving…</span>
240
+ <span class="agent-model-error" data-agent-model-error hidden></span>
241
+ </div>
242
+ </div>
223
243
  </div>
224
244
 
225
245
  <div class="agent-block">
@@ -259,6 +279,17 @@
259
279
  </div>
260
280
  </div>
261
281
 
282
+ <!-- Kick-from-room block · only visible when this overlay is
283
+ opened from inside a live room AND the agent is a director
284
+ member of that room (NOT the chair · chair is structural).
285
+ Renders a confirm dialog before firing the PATCH so a
286
+ misclick can't silently boot a director mid-conversation. -->
287
+ <div class="agent-block agent-room-actions private-only" data-agent-room-actions hidden>
288
+ <button type="button" class="agent-kick-btn" data-agent-kick-btn>
289
+ <span data-i18n="ao_kick_button">Remove from this room</span>
290
+ </button>
291
+ </div>
292
+
262
293
  </div>
263
294
  <footer class="agent-card-foot">
264
295
  <div class="meta private-only"><span data-i18n="ao_tenure_meta">tenure ·</span> <span class="lime agent-tenure"></span></div>
@@ -383,22 +414,140 @@
383
414
 
384
415
  // Model · resolved from the live record (catalog entries don't
385
416
  // ship a modelV). Fall back to displaying the raw id if we don't
386
- // have a friendly label for it yet.
387
- const modelV = live ? live.modelV : null;
388
- const modelMeta = modelV ? MODEL_LABELS[modelV] : null;
389
- const modelBlock = card.querySelector(".agent-model-block");
390
- const modelNameEl = card.querySelector(".agent-model-name");
391
- const modelProvEl = card.querySelector(".agent-model-provider");
392
- if (modelMeta) {
393
- modelNameEl.textContent = modelMeta.name;
394
- modelProvEl.textContent = modelMeta.provider;
395
- modelBlock.style.display = "";
396
- } else if (modelV) {
397
- modelNameEl.textContent = modelV;
398
- modelProvEl.textContent = "";
399
- modelBlock.style.display = "";
400
- } else {
401
- modelBlock.style.display = "none";
417
+ // have a friendly label for it yet. When `live` is present (the
418
+ // common case · in-room view + signed-in roster), the read-only
419
+ // chip swaps to an interactive `<select>` so the user can switch
420
+ // the director's model without leaving the conversation.
421
+ // Model block + kick block setup · wrapped in try/catch so a
422
+ // missing selector or a broken sub-call can't prevent the rest
423
+ // of open() from reaching `overlay.classList.add("open")`. Without
424
+ // this guard, ANY thrown error here would silently abort the
425
+ // open() flow and the user would experience "clicked the avatar
426
+ // but nothing happened" with no visible affordance for the bug.
427
+ try {
428
+ const modelV = live ? live.modelV : null;
429
+ const modelMeta = modelV ? MODEL_LABELS[modelV] : null;
430
+ const modelBlock = card.querySelector(".agent-model-block");
431
+ const modelReadonly = card.querySelector(".agent-model-display-readonly");
432
+ const modelEdit = card.querySelector("[data-agent-model-edit]");
433
+ const modelNameEl = card.querySelector(".agent-model-display-readonly .agent-model-name");
434
+ const modelProvEl = card.querySelector(".agent-model-display-readonly .agent-model-provider");
435
+ const modelTrigger = card.querySelector("[data-agent-model-trigger]");
436
+ const modelValueEl = card.querySelector("[data-agent-model-value]");
437
+ const modelProviderTagEl = card.querySelector("[data-agent-model-provider-tag]");
438
+ const modelSavingEl = card.querySelector("[data-agent-model-saving]");
439
+ const modelErrorEl = card.querySelector("[data-agent-model-error]");
440
+ // Reset transient state on every open so a prior save's
441
+ // saving/error chips don't leak into the new agent's view.
442
+ if (modelSavingEl) modelSavingEl.hidden = true;
443
+ if (modelErrorEl) { modelErrorEl.hidden = true; modelErrorEl.textContent = ""; }
444
+
445
+ if (modelBlock) {
446
+ if (modelMeta) {
447
+ if (modelNameEl) modelNameEl.textContent = modelMeta.name;
448
+ if (modelProvEl) modelProvEl.textContent = modelMeta.provider;
449
+ modelBlock.style.display = "";
450
+ } else if (modelV) {
451
+ if (modelNameEl) modelNameEl.textContent = modelV;
452
+ if (modelProvEl) modelProvEl.textContent = "";
453
+ modelBlock.style.display = "";
454
+ } else {
455
+ modelBlock.style.display = "none";
456
+ }
457
+ }
458
+
459
+ // Wire the editable picker only when this overlay is mounted in
460
+ // the live in-room context (window.app + live record). Otherwise
461
+ // keep the readonly chip · standalone gallery pages can't PATCH.
462
+ const canEditModel = !!(live && window.app
463
+ && typeof fetch === "function"
464
+ && !document.body.classList.contains("public"));
465
+ if (canEditModel && modelTrigger && modelEdit && modelReadonly) {
466
+ // Trigger · name on the left, provider chip on the right,
467
+ // caret last. Matches the agent-profile `.ap-model-trigger`
468
+ // pattern so both surfaces read as one control vocabulary.
469
+ if (modelValueEl) {
470
+ modelValueEl.textContent = modelMeta
471
+ ? modelMeta.name
472
+ : (modelV || "—");
473
+ }
474
+ if (modelProviderTagEl) {
475
+ modelProviderTagEl.textContent = modelMeta ? modelMeta.provider : "";
476
+ }
477
+
478
+ // Replace any prior trigger listener (the DOM node persists
479
+ // across open() calls, so naïve addEventListener stacks
480
+ // duplicates · cloneNode wipes the listeners).
481
+ const newTrigger = modelTrigger.cloneNode(true);
482
+ modelTrigger.parentNode.replaceChild(newTrigger, modelTrigger);
483
+ // Re-fetch refs AFTER clone since the old references point
484
+ // at the detached node.
485
+ const triggerValEl = newTrigger.querySelector("[data-agent-model-value]");
486
+ const triggerProvEl = newTrigger.querySelector("[data-agent-model-provider-tag]");
487
+ newTrigger.addEventListener("click", (ev) => {
488
+ ev.stopPropagation();
489
+ if (newTrigger.classList.contains("open")) {
490
+ closeAgentModelPicker();
491
+ return;
492
+ }
493
+ openAgentModelPicker(slug, {
494
+ trigger: newTrigger,
495
+ valueEl: triggerValEl,
496
+ providerEl: triggerProvEl,
497
+ saving: modelSavingEl,
498
+ error: modelErrorEl,
499
+ readonlyName: modelNameEl,
500
+ readonlyProv: modelProvEl,
501
+ });
502
+ });
503
+
504
+ // Show editor, hide readonly chip.
505
+ modelReadonly.style.display = "none";
506
+ modelEdit.hidden = false;
507
+ } else if (modelEdit && modelReadonly) {
508
+ modelReadonly.style.display = "";
509
+ modelEdit.hidden = true;
510
+ }
511
+ } catch (e) {
512
+ // Don't let model-picker wiring abort the overlay open.
513
+ try { console.warn("[agent-overlay] model picker setup failed:", e); } catch (_) {}
514
+ }
515
+
516
+ // Kick-from-room button · visible only when (a) we're inside a
517
+ // live room, (b) this agent is a director member of that room
518
+ // (chair excluded · the role is structural), (c) the room isn't
519
+ // adjourned (member changes are rejected by the server then).
520
+ try {
521
+ const kickBlock = card.querySelector("[data-agent-room-actions]");
522
+ const kickBtn = card.querySelector("[data-agent-kick-btn]");
523
+ if (kickBlock && kickBtn) {
524
+ const app = window.app;
525
+ const room = app && app.currentRoom;
526
+ const members = (app && app.currentMembers) || [];
527
+ const isMember = members.some((m) => m && m.id === slug);
528
+ const isChair = !!(live && live.roleKind === "moderator");
529
+ const adjourned = room && room.status === "adjourned";
530
+ const canKick = !!room && isMember && !isChair && !adjourned;
531
+ if (canKick) {
532
+ kickBlock.hidden = false;
533
+ kickBtn.disabled = false;
534
+ // Replace any prior listener to avoid stacking after re-open.
535
+ const newKick = kickBtn.cloneNode(true);
536
+ kickBtn.parentNode.replaceChild(newKick, kickBtn);
537
+ newKick.addEventListener("click", () => {
538
+ const promptKey = ovT("ao_kick_confirm", { name: a.name });
539
+ if (!window.confirm(promptKey)) return;
540
+ kickFromRoom(slug, room.id, members, {
541
+ btn: newKick,
542
+ onDone: () => close(),
543
+ });
544
+ });
545
+ } else {
546
+ kickBlock.hidden = true;
547
+ }
548
+ }
549
+ } catch (e) {
550
+ try { console.warn("[agent-overlay] kick button setup failed:", e); } catch (_) {}
402
551
  }
403
552
 
404
553
  card.querySelector(".agent-traits").innerHTML = (a.traits || [])
@@ -576,11 +725,304 @@
576
725
 
577
726
  function close() {
578
727
  overlayOpenSlug = null;
728
+ // Close the model picker first · it lives in document.body and
729
+ // wouldn't dismiss with the overlay otherwise (would leave a
730
+ // floating popover orphaned over the page).
731
+ try { closeAgentModelPicker(); } catch (_) {}
579
732
  overlay.classList.remove("open");
580
733
  overlay.setAttribute("aria-hidden", "true");
581
734
  document.body.style.overflow = "";
582
735
  }
583
736
 
737
+ /** Open the model picker popover · mirrors the agent-profile
738
+ * `.ap-model-picker` vocabulary (panel surface, hairline border,
739
+ * grouped by provider via `.ap-model-group` headers, `.ap-model-opt`
740
+ * rows with sans label + mono uppercase hint). Built fresh on each
741
+ * open, attached to document.body so its `position: fixed` escapes
742
+ * the overlay card's `overflow-y: auto` clip. z-index lifted above
743
+ * the overlay's 9700 via the `.ap-model-picker-overlay` modifier
744
+ * class so the popover floats above the modal backdrop. */
745
+ let _agentModelPopover = null;
746
+ let _agentModelPopoverOutsideHandler = null;
747
+ let _agentModelPopoverKeyHandler = null;
748
+ function closeAgentModelPicker() {
749
+ if (_agentModelPopover && _agentModelPopover.parentNode) {
750
+ _agentModelPopover.parentNode.removeChild(_agentModelPopover);
751
+ }
752
+ _agentModelPopover = null;
753
+ const openTrigger = card.querySelector("[data-agent-model-trigger].open");
754
+ if (openTrigger) openTrigger.classList.remove("open");
755
+ if (_agentModelPopoverOutsideHandler) {
756
+ document.removeEventListener("mousedown", _agentModelPopoverOutsideHandler, true);
757
+ _agentModelPopoverOutsideHandler = null;
758
+ }
759
+ if (_agentModelPopoverKeyHandler) {
760
+ document.removeEventListener("keydown", _agentModelPopoverKeyHandler, true);
761
+ _agentModelPopoverKeyHandler = null;
762
+ }
763
+ }
764
+ function openAgentModelPicker(slug, ui) {
765
+ closeAgentModelPicker();
766
+ const live = window.app && window.app.agentsById
767
+ ? window.app.agentsById[slug]
768
+ : null;
769
+ if (!live) return;
770
+ const currentV = live.modelV || "";
771
+
772
+ // Group reachable models by provider (server filters by the
773
+ // user's active credential, so only that credential's family
774
+ // shows up). Falls back to the full MODEL_LABELS catalog only
775
+ // when the /api/models cache hasn't loaded yet — short cold-
776
+ // start window; cache lands in ~50ms.
777
+ const cache = (typeof window.boardroomModels === "function")
778
+ ? window.boardroomModels()
779
+ : null;
780
+ const reachable = (cache && Array.isArray(cache.reachable) && cache.reachable.length > 0)
781
+ ? cache.reachable
782
+ : null;
783
+
784
+ const byProvider = new Map();
785
+ if (reachable) {
786
+ for (const m of reachable) {
787
+ const meta = MODEL_LABELS[m.modelV];
788
+ const providerName = meta ? meta.provider : m.provider;
789
+ const displayName = meta ? meta.name : m.displayName;
790
+ if (!byProvider.has(providerName)) byProvider.set(providerName, []);
791
+ byProvider.get(providerName).push({ v: m.modelV, name: displayName });
792
+ }
793
+ } else {
794
+ for (const v of Object.keys(MODEL_LABELS)) {
795
+ const meta = MODEL_LABELS[v];
796
+ if (!byProvider.has(meta.provider)) byProvider.set(meta.provider, []);
797
+ byProvider.get(meta.provider).push({ v, name: meta.name });
798
+ }
799
+ }
800
+
801
+ // If the agent's currently-stored modelV isn't in the reachable
802
+ // set (active credential just switched, or registry retired the
803
+ // model), prepend a "current (unreachable)" entry so the user
804
+ // can still see what their agent is on — and pick a different
805
+ // reachable model to swap to.
806
+ const reachableHas = reachable
807
+ ? reachable.some((m) => m.modelV === currentV)
808
+ : !!MODEL_LABELS[currentV];
809
+ let unknownRow = "";
810
+ if (currentV && !reachableHas) {
811
+ const meta = MODEL_LABELS[currentV];
812
+ const label = meta ? meta.name : currentV;
813
+ const hint = meta ? "unreachable · pick a model below" : "unrecognised id";
814
+ unknownRow =
815
+ `<div class="ap-model-group">Current (unreachable)</div>` +
816
+ `<button type="button" class="ap-model-opt active" data-agent-model-pick="${escapeHtml(currentV)}" disabled>` +
817
+ `<span class="ap-model-opt-label">${escapeHtml(label)}</span>` +
818
+ `<span class="ap-model-opt-hint">${escapeHtml(hint)}</span>` +
819
+ `</button>`;
820
+ }
821
+
822
+ const rows = [];
823
+ for (const [provider, items] of byProvider.entries()) {
824
+ rows.push(`<div class="ap-model-group">${escapeHtml(provider)}</div>`);
825
+ for (const it of items) {
826
+ const isActive = it.v === currentV;
827
+ rows.push(
828
+ `<button type="button" class="ap-model-opt${isActive ? " active" : ""}" data-agent-model-pick="${escapeHtml(it.v)}">` +
829
+ `<span class="ap-model-opt-label">${escapeHtml(it.name)}</span>` +
830
+ `<span class="ap-model-opt-hint">${escapeHtml(it.v)}</span>` +
831
+ `</button>`,
832
+ );
833
+ }
834
+ }
835
+
836
+ // Empty-reachable safety · cache loaded but no models accessible
837
+ // (e.g. xAI-only active provider with empty registry rows).
838
+ if (rows.length === 0 && !unknownRow) {
839
+ rows.push(
840
+ `<div class="ap-model-picker-loading">No models reachable with your current API key. Configure another provider in Preferences.</div>`,
841
+ );
842
+ }
843
+
844
+ const pop = document.createElement("div");
845
+ pop.className = "ap-model-picker ap-model-picker-overlay";
846
+ pop.setAttribute("role", "listbox");
847
+ pop.innerHTML = unknownRow + rows.join("");
848
+ document.body.appendChild(pop);
849
+
850
+ // Position · anchored under the trigger, left edge aligned with
851
+ // the trigger's left edge. Pop width matches trigger width with
852
+ // a sane minimum so even short triggers get a usable menu.
853
+ const r = ui.trigger.getBoundingClientRect();
854
+ const popMinWidth = 240;
855
+ const popWidth = Math.max(r.width, popMinWidth);
856
+ const vw = window.innerWidth || document.documentElement.clientWidth;
857
+ const vh = window.innerHeight || document.documentElement.clientHeight;
858
+ let popLeft = r.left;
859
+ // Don't run off the right edge of the viewport.
860
+ if (popLeft + popWidth > vw - 8) popLeft = Math.max(8, vw - popWidth - 8);
861
+ let popTop = r.bottom + 4;
862
+ pop.style.minWidth = popWidth + "px";
863
+ pop.style.left = popLeft + "px";
864
+ pop.style.top = popTop + "px";
865
+ // After mount the pop has measured height; if it would overflow
866
+ // the viewport bottom, flip it ABOVE the trigger instead.
867
+ const popH = pop.getBoundingClientRect().height;
868
+ if (popTop + popH > vh - 8) {
869
+ popTop = Math.max(8, r.top - popH - 4);
870
+ pop.style.top = popTop + "px";
871
+ }
872
+
873
+ ui.trigger.classList.add("open");
874
+ _agentModelPopover = pop;
875
+
876
+ // Row pick · save through the existing fetch helper, swap chip /
877
+ // trigger value on success, close popover regardless of outcome
878
+ // (errors surface inline below the trigger via `ui.error`).
879
+ pop.addEventListener("click", (ev) => {
880
+ const btn = ev.target.closest("[data-agent-model-pick]");
881
+ if (!btn) return;
882
+ const v = btn.getAttribute("data-agent-model-pick");
883
+ if (!v) return;
884
+ // Optimistic UI · update trigger label + provider chip
885
+ // immediately so the user gets feedback even before the
886
+ // PATCH lands.
887
+ const meta = MODEL_LABELS[v];
888
+ if (ui.valueEl) ui.valueEl.textContent = meta ? meta.name : v;
889
+ if (ui.providerEl) ui.providerEl.textContent = meta ? meta.provider : "";
890
+ closeAgentModelPicker();
891
+ if (v === currentV) return; // no-op pick
892
+ saveModelForAgent(slug, v, {
893
+ select: null, // legacy field · saveModelForAgent only uses it to disable
894
+ saving: ui.saving,
895
+ error: ui.error,
896
+ providerTag: ui.providerEl,
897
+ readonlyName: ui.readonlyName,
898
+ readonlyProv: ui.readonlyProv,
899
+ previous: currentV,
900
+ // On error revert the trigger label + provider chip back to
901
+ // the previous model so the visible state matches what the
902
+ // server actually has.
903
+ onError: () => {
904
+ const prevMeta = MODEL_LABELS[currentV];
905
+ if (ui.valueEl) ui.valueEl.textContent = prevMeta ? prevMeta.name : (currentV || "—");
906
+ if (ui.providerEl) ui.providerEl.textContent = prevMeta ? prevMeta.provider : "";
907
+ },
908
+ });
909
+ });
910
+
911
+ // Outside-click + Esc close · use capture phase so the popover
912
+ // beats other click handlers (e.g. agent-overlay's own backdrop
913
+ // dismiss) when the user clicks elsewhere with the popover open.
914
+ _agentModelPopoverOutsideHandler = (ev) => {
915
+ if (pop.contains(ev.target) || ui.trigger.contains(ev.target)) return;
916
+ closeAgentModelPicker();
917
+ };
918
+ _agentModelPopoverKeyHandler = (ev) => {
919
+ if (ev.key === "Escape") {
920
+ ev.stopPropagation();
921
+ closeAgentModelPicker();
922
+ }
923
+ };
924
+ // defer attachment a tick so the click that opened us doesn't
925
+ // immediately close it via the outside-click handler.
926
+ setTimeout(() => {
927
+ document.addEventListener("mousedown", _agentModelPopoverOutsideHandler, true);
928
+ document.addEventListener("keydown", _agentModelPopoverKeyHandler, true);
929
+ }, 0);
930
+ }
931
+
932
+ /** Persist a new modelV for the agent · PATCH /api/agents/:slug
933
+ * with `{ modelV }`. On success, update the live roster in place
934
+ * and refresh the readonly chip so a re-open shows the new model
935
+ * without a network round-trip. On failure, revert the select
936
+ * back to the previous value + surface the server error inline
937
+ * (mirrors the inline-error pattern from agent-profile.js so
938
+ * users don't have to dismiss an alert just to retry). */
939
+ function saveModelForAgent(slug, v, ui) {
940
+ const live = window.app && window.app.agentsById
941
+ ? window.app.agentsById[slug]
942
+ : null;
943
+ if (!live) return;
944
+ if (ui.saving) ui.saving.hidden = false;
945
+ if (ui.error) { ui.error.hidden = true; ui.error.textContent = ""; }
946
+ if (ui.select) ui.select.disabled = true;
947
+ fetch("/api/agents/" + encodeURIComponent(slug), {
948
+ method: "PATCH",
949
+ headers: { "content-type": "application/json" },
950
+ body: JSON.stringify({ modelV: v }),
951
+ })
952
+ .then(async (r) => {
953
+ if (r.ok) return r.json();
954
+ const j = await r.json().catch(() => ({}));
955
+ const detail = j && typeof j.error === "string"
956
+ ? j.error
957
+ : ("HTTP " + r.status);
958
+ throw new Error(detail);
959
+ })
960
+ .then((updated) => {
961
+ // Reflect the saved modelV in the in-memory roster so other
962
+ // surfaces (agent profile, sidebar badges) read the new value.
963
+ live.modelV = updated.modelV || v;
964
+ const meta = MODEL_LABELS[live.modelV];
965
+ if (ui.readonlyName) ui.readonlyName.textContent = meta ? meta.name : live.modelV;
966
+ if (ui.readonlyProv) ui.readonlyProv.textContent = meta ? meta.provider : "";
967
+ if (ui.providerTag) ui.providerTag.textContent = meta ? meta.provider : "";
968
+ if (typeof window.app.refreshAgents === "function") {
969
+ window.app.refreshAgents().catch(() => {});
970
+ }
971
+ })
972
+ .catch((e) => {
973
+ // Revert visible state to the previous value so what the
974
+ // user sees matches what the server has, then surface the
975
+ // error so they can retry.
976
+ if (ui.select && ui.previous) ui.select.value = ui.previous;
977
+ if (typeof ui.onError === "function") ui.onError(e);
978
+ if (ui.error) {
979
+ ui.error.hidden = false;
980
+ ui.error.textContent = (e && e.message ? e.message : String(e));
981
+ }
982
+ })
983
+ .finally(() => {
984
+ if (ui.saving) ui.saving.hidden = true;
985
+ if (ui.select) ui.select.disabled = false;
986
+ });
987
+ }
988
+
989
+ /** Remove an agent from the current room. PATCH
990
+ * /api/rooms/:roomId/members with the desired-state list (all
991
+ * current director ids MINUS this one). The endpoint diffs against
992
+ * current state, fires a chair farewell, and re-emits config-event
993
+ * so other clients see the change. On failure, alert the user with
994
+ * the server's error string (most likely "room must keep at least
995
+ * one director"). */
996
+ function kickFromRoom(slug, roomId, members, opts) {
997
+ const remaining = (members || [])
998
+ .filter((m) => m && m.id && m.id !== slug)
999
+ .map((m) => m.id);
1000
+ if (opts.btn) opts.btn.disabled = true;
1001
+ fetch("/api/rooms/" + encodeURIComponent(roomId) + "/members", {
1002
+ method: "PATCH",
1003
+ headers: { "content-type": "application/json" },
1004
+ body: JSON.stringify({ agentIds: remaining }),
1005
+ })
1006
+ .then(async (r) => {
1007
+ if (r.ok) return r.json();
1008
+ const j = await r.json().catch(() => ({}));
1009
+ const detail = j && typeof j.error === "string"
1010
+ ? j.error
1011
+ : ("HTTP " + r.status);
1012
+ throw new Error(detail);
1013
+ })
1014
+ .then(() => {
1015
+ // Server's config-event SSE will trigger app.js to patch
1016
+ // currentMembers + re-render the queue / cast. Close the
1017
+ // overlay so the user sees the updated room state.
1018
+ if (typeof opts.onDone === "function") opts.onDone();
1019
+ })
1020
+ .catch((e) => {
1021
+ window.alert(ovT("ao_kick_failed", { detail: (e && e.message ? e.message : String(e)) }));
1022
+ if (opts.btn) opts.btn.disabled = false;
1023
+ });
1024
+ }
1025
+
584
1026
  // Public surface · other modules (e.g. agent-profile.js's voice
585
1027
  // unlock CTA) need to dismiss the overlay before opening their
586
1028
  // own modal, so we expose `close` and an `isOpen` predicate.