privateboard 0.1.22 → 0.1.24

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.
@@ -0,0 +1,527 @@
1
+ /* ═══════════════════════════════════════════
2
+ IN-ROOM @ MENTION PICKER
3
+ ═══════════════════════════════════════════
4
+ Hooks the room's input bar textarea (`.ib-textarea[data-send-input]`).
5
+ Typing `@` (at start of text or after whitespace) opens a floating
6
+ checkbox menu above the input listing the directors currently in the
7
+ room (chair + active members). Behaviour is "tick = insert /
8
+ untick = remove" · every checkbox toggle rewrites the picker-owned
9
+ text zone immediately, so the user sees the @handle land in the
10
+ textarea as they click. No separate Enter/commit step.
11
+
12
+ Region model
13
+ ────────────
14
+ When the picker opens we remember `trigger.start` (the position of
15
+ the `@` glyph) and `zoneEnd` (the end of the picker-managed
16
+ substring). On every check toggle we rebuild that range from the
17
+ current `selectedOrder`, splice it into the textarea, and bump
18
+ `zoneEnd`. When nothing is selected we restore the original
19
+ `@<query>` so the user can keep typing to refine the filter.
20
+
21
+ On close (Esc / click outside / Enter / next room nav) we push the
22
+ final `selectedOrder` onto `pending` · `submitFromComposer()` reads
23
+ it via `window.MentionPicker.consumePendingMentions(text)` and
24
+ filters by handle-still-present-in-body so backspaced-out handles
25
+ drop out cleanly.
26
+ */
27
+ (function () {
28
+ /* ── State ───────────────────────────────────────────────── */
29
+ const state = {
30
+ open: false,
31
+ trigger: null, // { start, end, query } at open time (start is fixed)
32
+ zoneEnd: -1, // dynamic end of picker-managed region in textarea
33
+ query: "", // current filter; refinable only while selection empty
34
+ selected: new Set(), // agent ids currently checked (multi)
35
+ selectedOrder: [], // [id, ...] in insertion order, for stable text rebuild
36
+ filtered: [],
37
+ activeIdx: -1, // keyboard cursor
38
+ pending: [], // [{ id, handle }] persisted across opens until send
39
+ };
40
+ let pickerEl = null;
41
+ let textarea = null;
42
+ let _suppressClose = false;
43
+ const HIDE_DEBOUNCE_MS = 60;
44
+
45
+ /* ── Styles ──────────────────────────────────────────────── */
46
+ function ensureStyles() {
47
+ if (document.getElementById("mention-picker-styles")) return;
48
+ const css = `
49
+ .mention-picker {
50
+ position: absolute;
51
+ bottom: calc(100% + 6px);
52
+ left: 8px;
53
+ z-index: 40;
54
+ width: 320px;
55
+ max-height: 300px;
56
+ padding: 6px;
57
+ border: 1px solid var(--line-strong);
58
+ border-radius: 12px;
59
+ background: color-mix(in srgb, color-mix(in srgb, var(--panel-3) 78%, var(--bg) 22%) 92%, transparent);
60
+ backdrop-filter: blur(24px) saturate(180%);
61
+ -webkit-backdrop-filter: blur(24px) saturate(180%);
62
+ box-shadow: 0 6px 28px rgba(0,0,0,0.28);
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: 4px;
66
+ font-family: var(--sans, system-ui), sans-serif;
67
+ font-size: 13px;
68
+ color: var(--text);
69
+ }
70
+ .mention-picker[hidden] { display: none; }
71
+ .mention-picker-head {
72
+ display: flex;
73
+ align-items: baseline;
74
+ justify-content: space-between;
75
+ padding: 4px 8px 2px;
76
+ color: var(--text-soft);
77
+ font-size: 11px;
78
+ letter-spacing: 0.1em;
79
+ text-transform: uppercase;
80
+ }
81
+ .mention-picker-list {
82
+ list-style: none;
83
+ margin: 0;
84
+ padding: 0;
85
+ overflow-y: auto;
86
+ max-height: 220px;
87
+ }
88
+ .mention-row {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 8px;
92
+ padding: 6px 8px;
93
+ border-radius: 8px;
94
+ cursor: pointer;
95
+ user-select: none;
96
+ }
97
+ .mention-row:hover,
98
+ .mention-row[data-active="true"] {
99
+ background: color-mix(in srgb, var(--lime) 12%, transparent);
100
+ }
101
+ .mention-row-cb {
102
+ flex: 0 0 auto;
103
+ width: 14px;
104
+ height: 14px;
105
+ margin: 0;
106
+ accent-color: var(--lime);
107
+ pointer-events: none;
108
+ }
109
+ .mention-row-av {
110
+ flex: 0 0 auto;
111
+ width: 22px;
112
+ height: 22px;
113
+ border-radius: 50%;
114
+ object-fit: cover;
115
+ background: color-mix(in srgb, var(--panel-2) 60%, transparent);
116
+ }
117
+ .mention-row-name {
118
+ flex: 1 1 auto;
119
+ font-weight: 500;
120
+ color: var(--text);
121
+ overflow: hidden;
122
+ text-overflow: ellipsis;
123
+ white-space: nowrap;
124
+ }
125
+ .mention-row-handle {
126
+ flex: 0 0 auto;
127
+ color: var(--text-soft);
128
+ font-size: 11px;
129
+ font-family: var(--mono, ui-monospace), monospace;
130
+ }
131
+ .mention-row-empty {
132
+ padding: 10px 12px;
133
+ color: var(--text-soft);
134
+ font-size: 12px;
135
+ font-style: italic;
136
+ }
137
+ .mention-picker-foot {
138
+ padding: 4px 8px 2px;
139
+ color: var(--text-soft);
140
+ font-size: 11px;
141
+ letter-spacing: 0.06em;
142
+ display: flex;
143
+ justify-content: space-between;
144
+ }
145
+ .input-bar:has(.mention-picker) { position: relative; }
146
+ `;
147
+ const style = document.createElement("style");
148
+ style.id = "mention-picker-styles";
149
+ style.textContent = css;
150
+ document.head.appendChild(style);
151
+ }
152
+
153
+ /* ── DOM helpers ─────────────────────────────────────────── */
154
+ function ensurePicker(input) {
155
+ const bar = input.closest(".input-bar");
156
+ if (!bar) return null;
157
+ let el = bar.querySelector("[data-mention-picker]");
158
+ if (el) return el;
159
+ ensureStyles();
160
+ el = document.createElement("div");
161
+ el.className = "mention-picker";
162
+ el.setAttribute("data-mention-picker", "");
163
+ el.setAttribute("role", "listbox");
164
+ el.setAttribute("aria-label", "Mention a director");
165
+ el.hidden = true;
166
+ el.innerHTML = `
167
+ <div class="mention-picker-head">
168
+ <span>@ Mention</span>
169
+ <span data-mention-count></span>
170
+ </div>
171
+ <ul class="mention-picker-list" data-mention-list></ul>
172
+ <div class="mention-picker-foot">
173
+ <span>↑↓ navigate · Space / click toggle</span>
174
+ <span>Enter / Esc close</span>
175
+ </div>
176
+ `;
177
+ bar.appendChild(el);
178
+ return el;
179
+ }
180
+
181
+ function escapeHtml(s) {
182
+ return String(s).replace(/[&<>"']/g, (c) => ({
183
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
184
+ }[c]));
185
+ }
186
+
187
+ /* ── Data ────────────────────────────────────────────────── */
188
+ function getDirectors() {
189
+ const app = window.app;
190
+ if (!app) return [];
191
+ const members = Array.isArray(app.currentMembers) ? app.currentMembers : [];
192
+ const chair = app.currentChair || null;
193
+ const seen = new Set();
194
+ const out = [];
195
+ if (chair && chair.id) {
196
+ seen.add(chair.id);
197
+ out.push(normalizeDirector(chair, true));
198
+ }
199
+ for (const m of members) {
200
+ if (!m || !m.id || seen.has(m.id)) continue;
201
+ seen.add(m.id);
202
+ out.push(normalizeDirector(m, false));
203
+ }
204
+ return out.filter((d) => !!d.handle);
205
+ }
206
+
207
+ function normalizeDirector(a, isChair) {
208
+ const rawHandle = a.handle || a.slug || a.id || "";
209
+ const handle = String(rawHandle).replace(/^[@/]/, "").trim();
210
+ return {
211
+ id: a.id,
212
+ name: a.name || handle || a.id,
213
+ handle,
214
+ avatar: a.avatarPath || a.avatar || a.avatar_url || "",
215
+ isChair,
216
+ };
217
+ }
218
+
219
+ function directorById(id) {
220
+ return getDirectors().find((d) => d.id === id) || null;
221
+ }
222
+
223
+ /* ── Trigger detection ───────────────────────────────────── */
224
+ function findActiveMention(ta) {
225
+ const value = ta.value;
226
+ const caret = ta.selectionStart;
227
+ if (typeof caret !== "number") return null;
228
+ for (let i = caret - 1; i >= 0; i--) {
229
+ const ch = value[i];
230
+ if (ch === "@") {
231
+ const before = i === 0 ? "" : value[i - 1];
232
+ if (before === "" || /\s/.test(before)) {
233
+ const query = value.substring(i + 1, caret);
234
+ if (/\s/.test(query)) return null;
235
+ return { start: i, end: caret, query };
236
+ }
237
+ return null;
238
+ }
239
+ if (/\s/.test(ch)) return null;
240
+ }
241
+ return null;
242
+ }
243
+
244
+ /* ── Filter + render ─────────────────────────────────────── */
245
+ function applyFilter() {
246
+ const q = (state.query || "").toLowerCase();
247
+ const all = getDirectors();
248
+ if (!q) {
249
+ state.filtered = all;
250
+ return;
251
+ }
252
+ state.filtered = all.filter((d) =>
253
+ d.handle.toLowerCase().includes(q) ||
254
+ d.name.toLowerCase().includes(q),
255
+ );
256
+ }
257
+
258
+ function render() {
259
+ if (!pickerEl) return;
260
+ const list = pickerEl.querySelector("[data-mention-list]");
261
+ const count = pickerEl.querySelector("[data-mention-count]");
262
+ const selCount = state.selected.size;
263
+ count.textContent = selCount ? `${selCount} picked` : "";
264
+ if (!state.filtered.length) {
265
+ list.innerHTML = `<li class="mention-row-empty">No matching director</li>`;
266
+ return;
267
+ }
268
+ list.innerHTML = state.filtered.map((d, i) => {
269
+ const checked = state.selected.has(d.id) ? "checked" : "";
270
+ const active = i === state.activeIdx ? "true" : "false";
271
+ const avHTML = d.avatar
272
+ ? `<img class="mention-row-av" src="${escapeHtml(d.avatar)}" alt="">`
273
+ : `<span class="mention-row-av"></span>`;
274
+ const handleSuffix = d.isChair ? " · chair" : "";
275
+ return `
276
+ <li class="mention-row" data-mention-row data-agent-id="${escapeHtml(d.id)}"
277
+ data-handle="${escapeHtml(d.handle)}" data-active="${active}" data-idx="${i}">
278
+ <input type="checkbox" class="mention-row-cb" ${checked} tabindex="-1">
279
+ ${avHTML}
280
+ <span class="mention-row-name">${escapeHtml(d.name)}</span>
281
+ <span class="mention-row-handle">@${escapeHtml(d.handle)}${handleSuffix}</span>
282
+ </li>
283
+ `;
284
+ }).join("");
285
+ }
286
+
287
+ /* ── Textarea region rewrite ──────────────────────────────
288
+ The picker owns the substring `[trigger.start, zoneEnd)`. Every
289
+ check toggle calls this to materialise the current selection as
290
+ `@h1 @h2 ` (with trailing space when non-empty) or the original
291
+ `@<query>` (when empty). Setting textarea.value programmatically
292
+ would normally fire our input handler and reopen / close the
293
+ picker · we sidestep by NOT dispatching the input event and
294
+ instead calling autosize directly. */
295
+ function rebuildZone() {
296
+ if (!textarea || !state.trigger) return;
297
+ const inserts = state.selectedOrder
298
+ .map((id) => directorById(id))
299
+ .filter(Boolean);
300
+ const insertText = inserts.length
301
+ ? inserts.map((d) => `@${d.handle}`).join(" ") + " "
302
+ : "@" + (state.query || "");
303
+ const before = textarea.value.substring(0, state.trigger.start);
304
+ const after = textarea.value.substring(state.zoneEnd);
305
+ textarea.value = before + insertText + after;
306
+ state.zoneEnd = state.trigger.start + insertText.length;
307
+ textarea.setSelectionRange(state.zoneEnd, state.zoneEnd);
308
+ // Autosize manually since we skipped dispatching `input`.
309
+ if (window.app && typeof window.app.autosizeRoomInputTextarea === "function") {
310
+ window.app.autosizeRoomInputTextarea();
311
+ }
312
+ textarea.focus();
313
+ }
314
+
315
+ /* ── Open / close / toggle ───────────────────────────────── */
316
+ function open(ta, trigger) {
317
+ textarea = ta;
318
+ pickerEl = ensurePicker(ta);
319
+ if (!pickerEl) return;
320
+ state.open = true;
321
+ state.trigger = { start: trigger.start, end: trigger.end, query: trigger.query };
322
+ state.zoneEnd = trigger.end;
323
+ state.query = trigger.query || "";
324
+ state.selected = new Set();
325
+ state.selectedOrder = [];
326
+ state.activeIdx = 0;
327
+ applyFilter();
328
+ pickerEl.hidden = false;
329
+ render();
330
+ }
331
+
332
+ function close() {
333
+ if (!state.open) return;
334
+ // Persist any inserted handles for the next send. consumePendingMentions
335
+ // will dedupe against the textarea body so backspaced-out ones drop.
336
+ for (const id of state.selectedOrder) {
337
+ const d = directorById(id);
338
+ if (d) state.pending.push({ id: d.id, handle: d.handle });
339
+ }
340
+ state.open = false;
341
+ state.trigger = null;
342
+ state.zoneEnd = -1;
343
+ state.query = "";
344
+ state.selected = new Set();
345
+ state.selectedOrder = [];
346
+ state.activeIdx = -1;
347
+ if (pickerEl) pickerEl.hidden = true;
348
+ }
349
+
350
+ function toggleSelected(id) {
351
+ if (!state.open) return;
352
+ if (state.selected.has(id)) {
353
+ state.selected.delete(id);
354
+ state.selectedOrder = state.selectedOrder.filter((x) => x !== id);
355
+ } else {
356
+ state.selected.add(id);
357
+ state.selectedOrder.push(id);
358
+ }
359
+ rebuildZone();
360
+ render();
361
+ }
362
+
363
+ function moveActive(delta) {
364
+ if (!state.filtered.length) return;
365
+ const n = state.filtered.length;
366
+ const next = (state.activeIdx + delta + n) % n;
367
+ state.activeIdx = next;
368
+ render();
369
+ const row = pickerEl && pickerEl.querySelector(`.mention-row[data-idx="${next}"]`);
370
+ if (row && row.scrollIntoView) row.scrollIntoView({ block: "nearest" });
371
+ }
372
+
373
+ function toggleActive() {
374
+ if (state.activeIdx < 0 || state.activeIdx >= state.filtered.length) return;
375
+ const d = state.filtered[state.activeIdx];
376
+ if (d) toggleSelected(d.id);
377
+ }
378
+
379
+ /* ── Event wiring ────────────────────────────────────────── */
380
+ // Input · detect new @ trigger, OR refine query while picker open
381
+ // (query refinement only allowed before first selection).
382
+ document.addEventListener("input", (e) => {
383
+ const ta = e.target;
384
+ if (!ta || !ta.matches || !ta.matches(".ib-textarea[data-send-input]")) return;
385
+ if (state.open) {
386
+ // Picker owns the input-bar region. Query refinement is only
387
+ // meaningful before any selection · once the user has checked
388
+ // anyone, the zone holds `@h1 @h2 ` (not `@<query>`) and free
389
+ // typing would corrupt the layout. Lock to selection-empty.
390
+ if (state.selectedOrder.length === 0) {
391
+ const trigger = findActiveMention(ta);
392
+ if (trigger && trigger.start === state.trigger.start) {
393
+ state.trigger = { start: trigger.start, end: trigger.end, query: trigger.query };
394
+ state.zoneEnd = trigger.end;
395
+ state.query = trigger.query || "";
396
+ applyFilter();
397
+ if (state.activeIdx >= state.filtered.length) state.activeIdx = Math.max(0, state.filtered.length - 1);
398
+ render();
399
+ } else {
400
+ close();
401
+ }
402
+ }
403
+ return;
404
+ }
405
+ const trigger = findActiveMention(ta);
406
+ if (trigger) open(ta, trigger);
407
+ });
408
+
409
+ // Keydown · arrow nav / Space toggle / Enter close / Esc close.
410
+ // Capture phase so we intercept BEFORE the global Enter→submit
411
+ // handler in app.js.
412
+ document.addEventListener("keydown", (e) => {
413
+ if (!state.open) return;
414
+ const ta = e.target;
415
+ if (!ta || !ta.matches || !ta.matches(".ib-textarea[data-send-input]")) return;
416
+ if (e.isComposing) return;
417
+ if (e.key === "Escape") {
418
+ e.preventDefault();
419
+ e.stopPropagation();
420
+ close();
421
+ return;
422
+ }
423
+ if (e.key === "ArrowDown") {
424
+ e.preventDefault();
425
+ moveActive(1);
426
+ return;
427
+ }
428
+ if (e.key === "ArrowUp") {
429
+ e.preventDefault();
430
+ moveActive(-1);
431
+ return;
432
+ }
433
+ if (e.key === "Enter" && !e.shiftKey) {
434
+ // Enter always closes the picker · the host's submit handler
435
+ // gets a clean keystroke right after (we don't preventDefault
436
+ // when there's no selection, so a plain @-then-Enter sends
437
+ // the message as expected).
438
+ if (state.selectedOrder.length > 0) {
439
+ e.preventDefault();
440
+ e.stopPropagation();
441
+ close();
442
+ } else {
443
+ close();
444
+ }
445
+ return;
446
+ }
447
+ if (e.key === " " || e.code === "Space") {
448
+ // Space toggles the active row · keeps multi-pick ergonomic
449
+ // without breaking out of the picker.
450
+ e.preventDefault();
451
+ e.stopPropagation();
452
+ toggleActive();
453
+ return;
454
+ }
455
+ }, true);
456
+
457
+ // Click row · toggle that director.
458
+ document.addEventListener("click", (e) => {
459
+ const row = e.target.closest && e.target.closest(".mention-row[data-agent-id]");
460
+ if (!row) return;
461
+ e.preventDefault();
462
+ e.stopPropagation();
463
+ const id = row.getAttribute("data-agent-id");
464
+ if (id) toggleSelected(id);
465
+ });
466
+
467
+ // Click outside menu · dismiss. Any click landing outside the
468
+ // picker's own DOM closes it · including clicks back into the
469
+ // textarea (user spec: blur from menu area = auto-dismiss). The
470
+ // setTimeout lets row-click handlers run to completion before the
471
+ // picker hides itself.
472
+ document.addEventListener("mousedown", (e) => {
473
+ if (!state.open) return;
474
+ if (_suppressClose) return;
475
+ if (e.target.closest && e.target.closest("[data-mention-picker]")) return;
476
+ setTimeout(close, HIDE_DEBOUNCE_MS);
477
+ });
478
+
479
+ // Keyboard blur · Tab / programmatic focus-shift off the textarea
480
+ // also dismisses the picker. focusout bubbles, so a single document
481
+ // listener works. Defer with a microtask so a row-click's refocus
482
+ // path (toggleSelected → rebuildZone → textarea.focus) wins the
483
+ // race when the click happens to flicker focus.
484
+ document.addEventListener("focusout", (e) => {
485
+ if (!state.open) return;
486
+ if (!textarea || e.target !== textarea) return;
487
+ setTimeout(() => {
488
+ if (!state.open) return;
489
+ // Focus returned to the textarea (likely a row click that ran
490
+ // textarea.focus()) · don't close.
491
+ if (document.activeElement === textarea) return;
492
+ // Focus moved into the picker itself (shouldn't happen since
493
+ // rows aren't focusable, but defensive) · don't close.
494
+ if (document.activeElement && document.activeElement.closest && document.activeElement.closest("[data-mention-picker]")) return;
495
+ close();
496
+ }, HIDE_DEBOUNCE_MS);
497
+ });
498
+
499
+ // Send button · drain selection before host submits.
500
+ document.addEventListener("click", (e) => {
501
+ const btn = e.target.closest && e.target.closest("[data-send-button]");
502
+ if (!btn) return;
503
+ if (state.open) close();
504
+ });
505
+
506
+ /* ── Public API ──────────────────────────────────────────── */
507
+ window.MentionPicker = {
508
+ consumePendingMentions(text) {
509
+ // If the picker is still open (user hit Enter to submit
510
+ // directly), flush its selection into pending first.
511
+ if (state.open) close();
512
+ if (!state.pending.length) return [];
513
+ const body = String(text || "");
514
+ const seen = new Set();
515
+ const out = [];
516
+ for (const p of state.pending) {
517
+ if (seen.has(p.id)) continue;
518
+ if (!body.includes(`@${p.handle}`)) continue;
519
+ seen.add(p.id);
520
+ out.push(p.id);
521
+ }
522
+ state.pending = [];
523
+ return out;
524
+ },
525
+ close() { close(); },
526
+ };
527
+ })();
@@ -10,7 +10,6 @@
10
10
  { provider: "anthropic", models: [
11
11
  { v: "sonnet-4-6", name: "Sonnet 4.6", deck: "balanced · default" },
12
12
  { v: "opus-4-7", name: "Opus 4.7", deck: "deep reasoning" },
13
- { v: "opus-4-6", name: "Opus 4.6", deck: "prior-gen flagship" },
14
13
  { v: "opus-4-6-fast", name: "Opus 4.6 Fast", deck: "faster 4.6 · same intelligence" },
15
14
  { v: "haiku-4-5", name: "Haiku 4.5", deck: "fast · low-cost" }
16
15
  ]},
@@ -24,13 +23,19 @@
24
23
  { v: "gemini-3-flash", name: "Gemini 3 Flash", deck: "frontier flash · 1M ctx" },
25
24
  { v: "gemini-3-1-flash", name: "Gemini 3.1 Flash Lite", deck: "fast · 1M ctx" }
26
25
  ]},
27
- { provider: "xai", models: [
28
- { v: "grok-4-3", name: "Grok 4.3", deck: "flagship · 1M ctx" },
29
- { v: "grok-4-1-fast", name: "Grok 4.1 Fast", deck: "fast · 256k ctx" }
30
- ]},
31
26
  { provider: "deepseek", models: [
32
27
  { v: "deepseek-v4-pro", name: "DeepSeek V4 Pro", deck: "reasoning · open weights" },
33
28
  { v: "deepseek-v4-flash", name: "DeepSeek Lite", deck: "V4 Flash · fast · 1M ctx" }
29
+ ]},
30
+ { provider: "zhipu", models: [
31
+ { v: "glm-5-1", name: "GLM 5.1", deck: "Zhipu flagship · 200k ctx" }
32
+ ]},
33
+ { provider: "moonshot", models: [
34
+ { v: "kimi-k2-6", name: "Kimi K2.6", deck: "long-context" }
35
+ ]},
36
+ { provider: "minimax", models: [
37
+ { v: "minimax-m2-7", name: "MiniMax M2.7", deck: "flagship · long-context" },
38
+ { v: "minimax-m2-5", name: "MiniMax M2.5", deck: "prior · long-context" }
34
39
  ]}
35
40
  ];
36
41
  const ALL_MODELS = MODEL_GROUPS.flatMap((g) => g.models);