privateboard 0.1.0
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/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/cli.js +10502 -0
- package/dist/cli.js.map +1 -0
- package/package.json +63 -0
- package/public/adjourn-overlay.css +253 -0
- package/public/agent-overlay.css +444 -0
- package/public/agent-overlay.js +604 -0
- package/public/agent-profile.css +3230 -0
- package/public/agent-profile.js +3329 -0
- package/public/app.js +6629 -0
- package/public/auto-hide-scroll.js +90 -0
- package/public/avatar-skill.js +793 -0
- package/public/avatars/chair.svg +98 -0
- package/public/avatars/first-principles.svg +122 -0
- package/public/avatars/long-horizon.svg +147 -0
- package/public/avatars/open_ai.png +0 -0
- package/public/avatars/phenomenologist.svg +130 -0
- package/public/avatars/socrates.svg +187 -0
- package/public/avatars/user-empathy.svg +117 -0
- package/public/avatars/value-investor.svg +117 -0
- package/public/favicon.svg +10 -0
- package/public/fonts/agent-Italic.woff2 +0 -0
- package/public/fonts/human-sans.woff2 +0 -0
- package/public/icons.css +103 -0
- package/public/models-cache.js +57 -0
- package/public/new-agent.css +1359 -0
- package/public/new-agent.js +675 -0
- package/public/onboarding.css +628 -0
- package/public/onboarding.js +782 -0
- package/public/prototype-dashboard.html +7596 -0
- package/public/report/spines/a16z-thesis.css +1055 -0
- package/public/report/spines/anthropic-essay.css +556 -0
- package/public/report/spines/boardroom-dark.css +1082 -0
- package/public/report/spines/gartner-note.css +538 -0
- package/public/report/spines/mckinsey-deck.css +523 -0
- package/public/report/spines/openai-paper.css +516 -0
- package/public/report.html +1417 -0
- package/public/room-settings.css +895 -0
- package/public/room-settings.js +1039 -0
- package/public/themes.css +338 -0
- package/public/user-settings.css +1236 -0
- package/public/user-settings.js +1291 -0
|
@@ -0,0 +1,1039 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════
|
|
2
|
+
ROOM SETTINGS OVERLAY
|
|
3
|
+
═══════════════════════════════════════════
|
|
4
|
+
- Members section shows EVERY available director inline so the user
|
|
5
|
+
can scroll the list and toggle add/remove in place. The old
|
|
6
|
+
agent-picker sub-overlay was removed at the user's request.
|
|
7
|
+
- Two-click confirm on member removal.
|
|
8
|
+
- Every config change is logged to ROOM_STATE.history, surfaced in
|
|
9
|
+
a "History" section inside the modal, AND emitted as a divider
|
|
10
|
+
strip in the chat transcript.
|
|
11
|
+
*/
|
|
12
|
+
(function () {
|
|
13
|
+
/** Tone tooltips · keep in lockstep with app.js. */
|
|
14
|
+
const TONE_TIPS = {
|
|
15
|
+
brainstorm:
|
|
16
|
+
"Co-creator. Directors stand with you and push the idea outward — yes-and a contribution, name a concrete adjacent variant (\"what if we instead…\"), borrow pieces from another director's turn into new combinations. May end with one curious question, never a defense-demanding one.",
|
|
17
|
+
constructive:
|
|
18
|
+
"Sympathetic interrogator. They want you to win, but only via the strongest version. Each turn picks ONE load-bearing assumption and proposes the candidate stronger version that would stand. Disagreement is allowed, but every objection comes packaged with a forward path.",
|
|
19
|
+
debate:
|
|
20
|
+
"Peer reviewer. Each turn opens by steelmanning your strongest claim (\"the strongest read of your point is…\") and only then attacks THAT version — naming a specific risk, demanding evidence, exposing the trade-off you're hiding. Sharp but professional. Skipping the steelman is a protocol violation.",
|
|
21
|
+
"no-mercy":
|
|
22
|
+
"Hostile reviewer. Default: you're wrong until proved otherwise. Points at vague terms / hand-waved mechanisms, says \"this is wrong because X\" flat — no hedge. Refuses undefined terms. Attacks the argument as half-baked / wrong, never the person. Forbidden hedge words: perhaps / maybe / could be / might.",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Intensity tooltips · what each pick does to the directors' default
|
|
26
|
+
* speaking register. Surfaced via the per-chip info icon. */
|
|
27
|
+
const INTENSITY_TIPS = {
|
|
28
|
+
calm:
|
|
29
|
+
"Long-form thinking aloud. Directors take the room slowly — pause to think, surface caveats, sit with ambiguity rather than rushing to resolve. Best for novel / ambiguous problems where premature conclusions cost more than slow ones.",
|
|
30
|
+
sharp:
|
|
31
|
+
"No hedging. Directors land each turn on a load-bearing claim and back it with the load-bearing reason. They cut the qualifying language (\"perhaps,\" \"could be,\" \"in some cases\") in favour of clear, falsifiable statements. Default for most rooms.",
|
|
32
|
+
brutal:
|
|
33
|
+
"No prisoners. Directors say what they actually think with zero softening — including \"this is wrong\" / \"this won't work because\" without preamble. Trades politeness for signal. Use when you're tired of being agreed with and want the strongest disagreements surfaced fast.",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Generic info popover · single floating element, hover-driven.
|
|
37
|
+
* Reads the tip text from the trigger's `data-info-body` attribute
|
|
38
|
+
* and the title from `data-info-title`. Dismissed on mouseleave
|
|
39
|
+
* (with a small grace window) or Esc. Replaces the tone-only
|
|
40
|
+
* popover with a kind-agnostic one used by both tone + intensity. */
|
|
41
|
+
let infoPopHideTimer = null;
|
|
42
|
+
function openInfoPopover(triggerEl) {
|
|
43
|
+
if (infoPopHideTimer) { clearTimeout(infoPopHideTimer); infoPopHideTimer = null; }
|
|
44
|
+
const title = triggerEl.getAttribute("data-info-title") || "";
|
|
45
|
+
const body = triggerEl.getAttribute("data-info-body") || "";
|
|
46
|
+
if (!body) return;
|
|
47
|
+
let pop = document.getElementById("rs-info-popover");
|
|
48
|
+
if (!pop) {
|
|
49
|
+
pop = document.createElement("div");
|
|
50
|
+
pop.id = "rs-info-popover";
|
|
51
|
+
pop.className = "rs-info-popover";
|
|
52
|
+
document.body.appendChild(pop);
|
|
53
|
+
// Stay open while the cursor is on the popover itself.
|
|
54
|
+
pop.addEventListener("mouseenter", () => {
|
|
55
|
+
if (infoPopHideTimer) { clearTimeout(infoPopHideTimer); infoPopHideTimer = null; }
|
|
56
|
+
});
|
|
57
|
+
pop.addEventListener("mouseleave", () => scheduleClosePopover());
|
|
58
|
+
}
|
|
59
|
+
pop.innerHTML = `
|
|
60
|
+
${title ? `<div class="rs-info-popover-head">${escape(title)}</div>` : ""}
|
|
61
|
+
<div class="rs-info-popover-body">${escape(body)}</div>
|
|
62
|
+
`;
|
|
63
|
+
const r = triggerEl.getBoundingClientRect();
|
|
64
|
+
const popH = pop.offsetHeight;
|
|
65
|
+
const popW = pop.offsetWidth;
|
|
66
|
+
let top = r.bottom + 6;
|
|
67
|
+
if (top + popH > window.innerHeight - 12) top = r.top - popH - 6;
|
|
68
|
+
let left = r.left + r.width / 2 - popW / 2;
|
|
69
|
+
if (left + popW > window.innerWidth - 12) left = window.innerWidth - popW - 12;
|
|
70
|
+
if (left < 12) left = 12;
|
|
71
|
+
pop.style.top = `${Math.round(top)}px`;
|
|
72
|
+
pop.style.left = `${Math.round(left)}px`;
|
|
73
|
+
}
|
|
74
|
+
function scheduleClosePopover() {
|
|
75
|
+
if (infoPopHideTimer) clearTimeout(infoPopHideTimer);
|
|
76
|
+
infoPopHideTimer = setTimeout(closeInfoPopover, 80);
|
|
77
|
+
}
|
|
78
|
+
function closeInfoPopover() {
|
|
79
|
+
const el = document.getElementById("rs-info-popover");
|
|
80
|
+
if (el) el.remove();
|
|
81
|
+
if (infoPopHideTimer) { clearTimeout(infoPopHideTimer); infoPopHideTimer = null; }
|
|
82
|
+
}
|
|
83
|
+
document.addEventListener("keydown", (e) => {
|
|
84
|
+
if (e.key === "Escape") closeInfoPopover();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Standalone-preview fallback · used only when window.app.agents
|
|
88
|
+
// isn't available (e.g. opening this file directly in the browser
|
|
89
|
+
// without the boardroom server). Live data takes precedence.
|
|
90
|
+
const FALLBACK_DIRECTORS = [
|
|
91
|
+
{ slug: "socrates", name: "Socrates", role: "Skeptic" },
|
|
92
|
+
{ slug: "first-principles", name: "First Principles", role: "Causal Reasoning" },
|
|
93
|
+
{ slug: "value-investor", name: "Value Investor", role: "Pattern Recognition" },
|
|
94
|
+
{ slug: "user-empathy", name: "User-Empathy", role: "Empathy Lens" },
|
|
95
|
+
{ slug: "long-horizon", name: "Long Horizon", role: "Historical Lens" },
|
|
96
|
+
{ slug: "phenomenologist", name: "Phenomenologist", role: "Intern · trial" }
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/** Return the full director catalog — live agents from window.app
|
|
100
|
+
* when present, otherwise the standalone fallback. Each entry has
|
|
101
|
+
* { slug, name, role, avatar } where avatar is the agent's stored
|
|
102
|
+
* avatarPath (data: URL for custom agents, /avatars/*.svg for seeds). */
|
|
103
|
+
function getAvailableAgents() {
|
|
104
|
+
const live = (window.app && Array.isArray(window.app.agents)) ? window.app.agents : null;
|
|
105
|
+
if (live && live.length > 0) {
|
|
106
|
+
return live
|
|
107
|
+
.filter((a) => a.roleKind === "director")
|
|
108
|
+
.map((a) => ({
|
|
109
|
+
slug: a.id,
|
|
110
|
+
name: a.name,
|
|
111
|
+
role: a.roleTag || "Director",
|
|
112
|
+
avatar: a.avatarPath || `avatars/${a.id}.svg`,
|
|
113
|
+
modelV: a.modelV || "",
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
return FALLBACK_DIRECTORS.map((d) => ({ ...d, avatar: `avatars/${d.slug}.svg`, modelV: "" }));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Friendly labels for the modelV strings · same shape as the
|
|
120
|
+
* composer-pick row's model badge. Falls back to the raw modelV
|
|
121
|
+
* string when the agent's model isn't in the table (e.g. a brand-
|
|
122
|
+
* new model the catalog doesn't know about yet). */
|
|
123
|
+
const MODEL_LABELS = {
|
|
124
|
+
"sonnet-4-6": "Sonnet 4.6",
|
|
125
|
+
"opus-4-7": "Opus 4.7",
|
|
126
|
+
"haiku-4-5": "Haiku 4.5",
|
|
127
|
+
"gpt-5-5": "GPT-5.5",
|
|
128
|
+
"gpt-5-4": "GPT-5.4",
|
|
129
|
+
"gpt-5-4-mini": "GPT-5.4 Mini",
|
|
130
|
+
"gpt-5-5-pro": "GPT-5.5 Pro",
|
|
131
|
+
"codex-5-4": "ChatGPT Codex 5.4",
|
|
132
|
+
"gemini-3-1": "Gemini 3.1 Pro",
|
|
133
|
+
"gemini-3-flash": "Gemini 3 Flash",
|
|
134
|
+
"gemini-3-1-flash": "Gemini 3.1 Flash Lite",
|
|
135
|
+
"grok-4-3": "Grok 4.3",
|
|
136
|
+
"grok-4-1-fast": "Grok 4.1 Fast",
|
|
137
|
+
"grok-4-3": "Grok 4.3",
|
|
138
|
+
"grok-4-20": "Grok 4.20",
|
|
139
|
+
"deepseek-v4-pro": "DeepSeek V4 Pro",
|
|
140
|
+
};
|
|
141
|
+
function modelLabelFor(v) {
|
|
142
|
+
if (!v) return "";
|
|
143
|
+
return MODEL_LABELS[v] || v;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const NAMES = {};
|
|
147
|
+
|
|
148
|
+
// Baseline state — synced from window.app.currentRoom each time the
|
|
149
|
+
// overlay opens. The fallback values keep the prototype usable in
|
|
150
|
+
// standalone preview (where window.app is absent).
|
|
151
|
+
const ROOM_STATE = {
|
|
152
|
+
title: "the minimum viable structure of a data flywheel",
|
|
153
|
+
topic: "I want to build an AI assistant for enterprise HR teams — automated resume screening + interview guides. Does this idea hold up under three-director scrutiny?",
|
|
154
|
+
number: 47,
|
|
155
|
+
status: "live",
|
|
156
|
+
turns: 5,
|
|
157
|
+
elapsed: "04:32",
|
|
158
|
+
opened: "Apr 28",
|
|
159
|
+
members: ["socrates", "first-principles", "value-investor"],
|
|
160
|
+
mode: "constructive",
|
|
161
|
+
intensity: "sharp",
|
|
162
|
+
style: "auto",
|
|
163
|
+
incognito: false,
|
|
164
|
+
history: [
|
|
165
|
+
{ ts: "Apr 28 · 21:08", who: "system", kind: "open", label: "room opened" }
|
|
166
|
+
]
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Staged changes layered on top of ROOM_STATE — committed only when
|
|
170
|
+
// the user clicks Confirm. The shape mirrors the room config keys.
|
|
171
|
+
let STAGED = { mode: null, intensity: null, incognito: null };
|
|
172
|
+
// Snapshot of ROOM_STATE.members at overlay-open time. The members
|
|
173
|
+
// array itself is mutated optimistically by add/removeMember; this
|
|
174
|
+
// baseline lets us detect "dirty" by diffing and lets us roll back
|
|
175
|
+
// on Cancel.
|
|
176
|
+
let MEMBERS_BASELINE = [];
|
|
177
|
+
|
|
178
|
+
function effective(field) {
|
|
179
|
+
return STAGED[field] !== null ? STAGED[field] : ROOM_STATE[field];
|
|
180
|
+
}
|
|
181
|
+
function membersDirty() {
|
|
182
|
+
if (ROOM_STATE.members.length !== MEMBERS_BASELINE.length) return true;
|
|
183
|
+
const baseSet = new Set(MEMBERS_BASELINE);
|
|
184
|
+
for (const m of ROOM_STATE.members) {
|
|
185
|
+
if (!baseSet.has(m)) return true;
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
function isDirty() {
|
|
190
|
+
return (
|
|
191
|
+
STAGED.mode !== null ||
|
|
192
|
+
STAGED.intensity !== null ||
|
|
193
|
+
STAGED.incognito !== null ||
|
|
194
|
+
membersDirty()
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
function resetStaged() {
|
|
198
|
+
STAGED = { mode: null, intensity: null, incognito: null };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const MODES = [
|
|
202
|
+
{ v: "brainstorm", label: "Brainstorm", desc: "yes-and" },
|
|
203
|
+
{ v: "constructive", label: "Constructive", desc: "push & sharpen" },
|
|
204
|
+
{ v: "debate", label: "Debate", desc: "find holes" },
|
|
205
|
+
{ v: "no-mercy", label: "No Mercy", desc: "tear apart" }
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
function escape(s) {
|
|
210
|
+
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
211
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
212
|
+
}[c]));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function nowStamp() {
|
|
216
|
+
const d = new Date();
|
|
217
|
+
const month = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][d.getMonth()];
|
|
218
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
219
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
220
|
+
return `${month} ${d.getDate()} · ${hh}:${mm}`;
|
|
221
|
+
}
|
|
222
|
+
function clockOnly() {
|
|
223
|
+
const d = new Date();
|
|
224
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
225
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
226
|
+
return `${hh}:${mm}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ─── Markup builders ─── */
|
|
230
|
+
|
|
231
|
+
/** Flat row mirroring the new-room composer's `.composer-pick-row` ·
|
|
232
|
+
* checkbox · avatar · name + role · model badge · info button. The
|
|
233
|
+
* whole row is clickable; toggles staged membership. Avatar + info
|
|
234
|
+
* click separately to open the agent profile. The footer's
|
|
235
|
+
* [Confirm] / [Cancel] commits the staged changes — no per-row
|
|
236
|
+
* two-click confirm any more. */
|
|
237
|
+
function memberRowHTML(d, isActive) {
|
|
238
|
+
const modelLabel = modelLabelFor(d.modelV);
|
|
239
|
+
const modelHtml = modelLabel
|
|
240
|
+
? `<span class="rs-member-model" title="${escape(modelLabel)}">${escape(modelLabel)}</span>`
|
|
241
|
+
: "";
|
|
242
|
+
return `
|
|
243
|
+
<div class="rs-member${isActive ? " on" : ""}" data-slug="${d.slug}" data-rs-toggle role="button" tabindex="0">
|
|
244
|
+
<input type="checkbox" class="rs-member-check" data-slug="${d.slug}"${isActive ? " checked" : ""}>
|
|
245
|
+
<div class="rs-member-img" data-agent-link="${d.slug}">
|
|
246
|
+
<img class="rs-member-av" src="${escape(d.avatar)}" alt="${escape(d.name)}" data-agent-link="${d.slug}">
|
|
247
|
+
</div>
|
|
248
|
+
<div class="rs-member-main">
|
|
249
|
+
<span class="rs-member-name">${escape(d.name)}</span>
|
|
250
|
+
<span class="rs-member-tag">${escape(d.role)}</span>
|
|
251
|
+
</div>
|
|
252
|
+
${modelHtml}
|
|
253
|
+
<button type="button" class="rs-member-info" data-agent-link="${d.slug}" aria-label="Open ${escape(d.name)} profile">i</button>
|
|
254
|
+
</div>
|
|
255
|
+
`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function historyRowHTML(h) {
|
|
259
|
+
let icon = "·";
|
|
260
|
+
let cls = "h-misc";
|
|
261
|
+
if (h.kind === "member-add") { icon = "+"; cls = "h-add"; }
|
|
262
|
+
if (h.kind === "member-remove") { icon = "−"; cls = "h-remove"; }
|
|
263
|
+
if (h.kind === "mode") { icon = "↔"; cls = "h-config"; }
|
|
264
|
+
if (h.kind === "style") { icon = "↔"; cls = "h-config"; }
|
|
265
|
+
if (h.kind === "intensity") { icon = "↔"; cls = "h-config"; }
|
|
266
|
+
if (h.kind === "open") { icon = "▸"; cls = "h-system"; }
|
|
267
|
+
return `
|
|
268
|
+
<li class="rs-history-row ${cls}">
|
|
269
|
+
<span class="h-time">${escape(h.ts)}</span>
|
|
270
|
+
<span class="h-icon">${icon}</span>
|
|
271
|
+
<span class="h-label">${escape(h.label)}</span>
|
|
272
|
+
<span class="h-who">${escape(h.who)}</span>
|
|
273
|
+
</li>
|
|
274
|
+
`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function modalHTML() {
|
|
278
|
+
return `
|
|
279
|
+
<div class="room-settings-overlay" id="room-settings-overlay" role="dialog" aria-modal="true" aria-hidden="true">
|
|
280
|
+
<div class="room-settings-modal" role="document">
|
|
281
|
+
|
|
282
|
+
<div class="rs-classification">
|
|
283
|
+
<span><span class="dot">●</span> room · settings</span>
|
|
284
|
+
<span class="right">// private</span>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<header class="rs-head">
|
|
288
|
+
<div>
|
|
289
|
+
<div class="meta">// room #<span class="rs-number">${ROOM_STATE.number}</span> · <span class="live">${ROOM_STATE.status}</span> · <span class="rs-turns">${ROOM_STATE.turns}</span> turns</div>
|
|
290
|
+
<div class="title rs-title">${escape(ROOM_STATE.title)}</div>
|
|
291
|
+
</div>
|
|
292
|
+
<button type="button" class="close-btn" aria-label="Close">✕</button>
|
|
293
|
+
</header>
|
|
294
|
+
|
|
295
|
+
<!-- Single-page spec sheet · four compact rows. Members
|
|
296
|
+
opens a director picker popover (mirrors the new-room
|
|
297
|
+
composer's pattern); tone / intensity are inline chip
|
|
298
|
+
rows; memory is a single toggle. -->
|
|
299
|
+
<div class="rs-body">
|
|
300
|
+
<div class="rs-config-list">
|
|
301
|
+
|
|
302
|
+
<div class="rs-config-row">
|
|
303
|
+
<div class="rs-config-row-label">
|
|
304
|
+
<span class="rs-config-row-name">Directors</span>
|
|
305
|
+
<span class="rs-config-row-hint">at this table</span>
|
|
306
|
+
</div>
|
|
307
|
+
<div class="rs-cast-wrap">
|
|
308
|
+
<button type="button" class="rs-cast-btn" data-rs-cast-trigger>
|
|
309
|
+
<span class="rs-cast-stack" data-rs-cast-stack></span>
|
|
310
|
+
<span class="rs-cast-count" data-rs-cast-count>—</span>
|
|
311
|
+
<span class="rs-cast-chevron">▾</span>
|
|
312
|
+
</button>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<div class="rs-config-row">
|
|
317
|
+
<div class="rs-config-row-label">
|
|
318
|
+
<span class="rs-config-row-name">Tone</span>
|
|
319
|
+
<span class="rs-config-row-hint">how hard they push</span>
|
|
320
|
+
</div>
|
|
321
|
+
<div class="rs-mode-grid rs-mode-row"></div>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
<div class="rs-config-row">
|
|
325
|
+
<div class="rs-config-row-label">
|
|
326
|
+
<span class="rs-config-row-name">Intensity</span>
|
|
327
|
+
<span class="rs-config-row-hint" data-rs-intensity-hint>currently: sharp</span>
|
|
328
|
+
</div>
|
|
329
|
+
<div class="rs-intensity-chips">
|
|
330
|
+
<button type="button" class="rs-chip rs-chip-mini" data-rs-intensity-pick="calm">
|
|
331
|
+
<span class="rs-chip-label">Calm</span>
|
|
332
|
+
<span class="rs-chip-info rs-info-trigger" data-info-title="Calm" data-info-body="${escape(INTENSITY_TIPS.calm)}" tabindex="-1" aria-label="What 'Calm' means">i</span>
|
|
333
|
+
</button>
|
|
334
|
+
<button type="button" class="rs-chip rs-chip-mini" data-rs-intensity-pick="sharp">
|
|
335
|
+
<span class="rs-chip-label">Sharp</span>
|
|
336
|
+
<span class="rs-chip-info rs-info-trigger" data-info-title="Sharp" data-info-body="${escape(INTENSITY_TIPS.sharp)}" tabindex="-1" aria-label="What 'Sharp' means">i</span>
|
|
337
|
+
</button>
|
|
338
|
+
<button type="button" class="rs-chip rs-chip-mini" data-rs-intensity-pick="brutal">
|
|
339
|
+
<span class="rs-chip-label">Brutal</span>
|
|
340
|
+
<span class="rs-chip-info rs-info-trigger" data-info-title="Brutal" data-info-body="${escape(INTENSITY_TIPS.brutal)}" tabindex="-1" aria-label="What 'Brutal' means">i</span>
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<div class="rs-config-row">
|
|
346
|
+
<div class="rs-config-row-label">
|
|
347
|
+
<span class="rs-config-row-name">Memory</span>
|
|
348
|
+
<span class="rs-config-row-hint">long-term learning about you</span>
|
|
349
|
+
</div>
|
|
350
|
+
<label class="rs-toggle-row" data-rs-incognito-label>
|
|
351
|
+
<input type="checkbox" class="rs-incognito-check" data-rs-incognito-check>
|
|
352
|
+
<span class="rs-toggle-label">Incognito — don't extract memory from this room</span>
|
|
353
|
+
</label>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<footer class="rs-foot">
|
|
360
|
+
<div class="saved" data-rs-status>no pending changes</div>
|
|
361
|
+
<div style="display: flex; gap: 6px;">
|
|
362
|
+
<button type="button" class="rs-action rs-cancel" data-rs-cancel>[ Cancel ]</button>
|
|
363
|
+
<button type="button" class="rs-action rs-done" data-rs-confirm>[ Confirm ]</button>
|
|
364
|
+
</div>
|
|
365
|
+
</footer>
|
|
366
|
+
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let overlay, modal;
|
|
373
|
+
let confirmTimers = new Map(); // slug → timeout id (for two-click confirm reset)
|
|
374
|
+
|
|
375
|
+
/* ─── Renderers ─── */
|
|
376
|
+
|
|
377
|
+
/** Refresh the agent-name cache (used by history labels / shortHandle). */
|
|
378
|
+
function refreshAgentCache() {
|
|
379
|
+
for (const d of getAvailableAgents()) NAMES[d.slug] = d.name;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Compact "Directors" trigger button · stacks the first ~5 active
|
|
383
|
+
* avatars with a +N overflow chip and the count. Mirrors the new-
|
|
384
|
+
* room composer's `.cmp-cast-btn`. Click opens the picker popover. */
|
|
385
|
+
function renderMembers() {
|
|
386
|
+
refreshAgentCache();
|
|
387
|
+
const active = ROOM_STATE.members;
|
|
388
|
+
const all = getAvailableAgents();
|
|
389
|
+
const activeAgents = active.map((slug) => all.find((a) => a.slug === slug)).filter(Boolean);
|
|
390
|
+
|
|
391
|
+
const stack = modal.querySelector("[data-rs-cast-stack]");
|
|
392
|
+
const countEl = modal.querySelector("[data-rs-cast-count]");
|
|
393
|
+
if (!stack || !countEl) return;
|
|
394
|
+
|
|
395
|
+
const SHOW = 5;
|
|
396
|
+
const shown = activeAgents.slice(0, SHOW);
|
|
397
|
+
const overflow = Math.max(0, activeAgents.length - SHOW);
|
|
398
|
+
stack.innerHTML = shown.map((a) =>
|
|
399
|
+
`<img class="rs-cast-av" src="${escape(a.avatar)}" alt="${escape(a.name)}" title="${escape(a.name)}">`
|
|
400
|
+
).join("") + (overflow > 0 ? `<span class="rs-cast-more">+${overflow}</span>` : "");
|
|
401
|
+
countEl.textContent = `${activeAgents.length} ${activeAgents.length === 1 ? "director" : "directors"}`;
|
|
402
|
+
|
|
403
|
+
// Picker popover · re-render rows when membership changes so the
|
|
404
|
+
// open popover (if any) reflects the staged state immediately.
|
|
405
|
+
renderCastPickerRows();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function renderCastPickerRows() {
|
|
409
|
+
const pop = document.getElementById("rs-cast-pop");
|
|
410
|
+
if (!pop) return;
|
|
411
|
+
const list = pop.querySelector("[data-rs-cast-list]");
|
|
412
|
+
if (!list) return;
|
|
413
|
+
const active = ROOM_STATE.members;
|
|
414
|
+
const all = getAvailableAgents();
|
|
415
|
+
if (all.length === 0) {
|
|
416
|
+
list.innerHTML = `<div class="rs-cast-empty">No directors yet — create one in the Agents tab.</div>`;
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const activeRows = all.filter((d) => active.includes(d.slug));
|
|
420
|
+
const inactiveRows = all.filter((d) => !active.includes(d.slug));
|
|
421
|
+
list.innerHTML =
|
|
422
|
+
activeRows.map((d) => memberRowHTML(d, true)).join("") +
|
|
423
|
+
inactiveRows.map((d) => memberRowHTML(d, false)).join("");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Open the directors picker popover anchored under the trigger
|
|
427
|
+
* button. Mirrors the new-room composer's `.composer-pick-pop`
|
|
428
|
+
* pattern. Idempotent — calling toggle while open closes it. */
|
|
429
|
+
function toggleCastPicker(triggerBtn) {
|
|
430
|
+
const existing = document.getElementById("rs-cast-pop");
|
|
431
|
+
if (existing) {
|
|
432
|
+
closeCastPicker();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const pop = document.createElement("div");
|
|
436
|
+
pop.id = "rs-cast-pop";
|
|
437
|
+
pop.className = "rs-cast-pop";
|
|
438
|
+
pop.innerHTML = `
|
|
439
|
+
<div class="rs-cast-head">
|
|
440
|
+
<span class="rs-cast-title">// directors at this table</span>
|
|
441
|
+
<span class="rs-cast-hint">click a row to toggle</span>
|
|
442
|
+
</div>
|
|
443
|
+
<div class="rs-cast-list" data-rs-cast-list></div>
|
|
444
|
+
`;
|
|
445
|
+
document.body.appendChild(pop);
|
|
446
|
+
renderCastPickerRows();
|
|
447
|
+
// Row click → toggle staged membership (or open agent profile when
|
|
448
|
+
// the avatar / info button is the click target). The popover lives
|
|
449
|
+
// outside .room-settings-modal so the modal-level click handler
|
|
450
|
+
// doesn't catch these — wire them directly here.
|
|
451
|
+
pop.addEventListener("click", (ev) => {
|
|
452
|
+
const profileLink = ev.target.closest("[data-agent-link]");
|
|
453
|
+
if (profileLink && (ev.target.closest(".rs-member-img") || ev.target.closest(".rs-member-info"))) {
|
|
454
|
+
ev.preventDefault();
|
|
455
|
+
ev.stopPropagation();
|
|
456
|
+
const slug = profileLink.getAttribute("data-agent-link");
|
|
457
|
+
if (typeof window.openAgentOverlay === "function" && slug) window.openAgentOverlay(slug);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const row = ev.target.closest("[data-rs-toggle]");
|
|
461
|
+
if (!row) return;
|
|
462
|
+
ev.preventDefault();
|
|
463
|
+
ev.stopPropagation();
|
|
464
|
+
const slug = row.getAttribute("data-slug");
|
|
465
|
+
if (!slug) return;
|
|
466
|
+
if (ROOM_STATE.members.includes(slug)) removeMember(slug);
|
|
467
|
+
else addMember(slug);
|
|
468
|
+
});
|
|
469
|
+
// Position under the trigger, right-aligned to it for breathing
|
|
470
|
+
// room from the modal's left edge.
|
|
471
|
+
const r = triggerBtn.getBoundingClientRect();
|
|
472
|
+
const popW = 360;
|
|
473
|
+
let left = r.right - popW;
|
|
474
|
+
if (left < 12) left = Math.max(12, r.left);
|
|
475
|
+
pop.style.left = left + "px";
|
|
476
|
+
pop.style.top = (r.bottom + 6) + "px";
|
|
477
|
+
pop.style.width = popW + "px";
|
|
478
|
+
triggerBtn.classList.add("on");
|
|
479
|
+
|
|
480
|
+
// Outside-click + Esc to close. Stored on closures so closeCastPicker
|
|
481
|
+
// can detach them. Click is exempt when:
|
|
482
|
+
// · inside the picker popover itself
|
|
483
|
+
// · on the trigger button (handled by its own toggle)
|
|
484
|
+
// · anywhere inside the agent intro overlay — opening + closing
|
|
485
|
+
// an agent profile from inside the picker should leave the
|
|
486
|
+
// picker open so the user can keep browsing directors
|
|
487
|
+
castPickerOutside = (ev) => {
|
|
488
|
+
if (
|
|
489
|
+
!pop.contains(ev.target)
|
|
490
|
+
&& !ev.target.closest("[data-rs-cast-trigger]")
|
|
491
|
+
&& !ev.target.closest(".agent-overlay")
|
|
492
|
+
) {
|
|
493
|
+
closeCastPicker();
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
castPickerEsc = (ev) => {
|
|
497
|
+
if (ev.key === "Escape") closeCastPicker();
|
|
498
|
+
};
|
|
499
|
+
setTimeout(() => {
|
|
500
|
+
document.addEventListener("click", castPickerOutside, true);
|
|
501
|
+
document.addEventListener("keydown", castPickerEsc, true);
|
|
502
|
+
}, 0);
|
|
503
|
+
}
|
|
504
|
+
let castPickerOutside = null;
|
|
505
|
+
let castPickerEsc = null;
|
|
506
|
+
function closeCastPicker() {
|
|
507
|
+
const pop = document.getElementById("rs-cast-pop");
|
|
508
|
+
if (pop) pop.remove();
|
|
509
|
+
document.querySelectorAll("[data-rs-cast-trigger].on").forEach((b) => b.classList.remove("on"));
|
|
510
|
+
if (castPickerOutside) document.removeEventListener("click", castPickerOutside, true);
|
|
511
|
+
if (castPickerEsc) document.removeEventListener("keydown", castPickerEsc, true);
|
|
512
|
+
castPickerOutside = null;
|
|
513
|
+
castPickerEsc = null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function renderModes() {
|
|
517
|
+
const grid = modal.querySelector(".rs-mode-row");
|
|
518
|
+
const cur = effective("mode");
|
|
519
|
+
// Each chip pairs a tight label + a small `i` icon · hovering the
|
|
520
|
+
// icon opens a description popover (TONE_TIPS lookup). Sharp-edge,
|
|
521
|
+
// no border-radius, no second line.
|
|
522
|
+
grid.innerHTML = MODES.map((m) => {
|
|
523
|
+
const tip = TONE_TIPS[m.v] || m.desc || "";
|
|
524
|
+
return `<button type="button" class="rs-chip rs-chip-mini${m.v === cur ? " active" : ""}" data-mode="${m.v}">
|
|
525
|
+
<span class="rs-chip-label">${escape(m.label)}</span>
|
|
526
|
+
<span class="rs-chip-info rs-info-trigger" data-info-title="${escape(m.label)}" data-info-body="${escape(tip)}" tabindex="-1" aria-label="What '${escape(m.label)}' means">i</span>
|
|
527
|
+
</button>`;
|
|
528
|
+
}).join("");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function renderIncognito() {
|
|
532
|
+
const checkbox = modal.querySelector("[data-rs-incognito-check]");
|
|
533
|
+
if (!checkbox) return;
|
|
534
|
+
const effective = STAGED.incognito !== null ? STAGED.incognito : ROOM_STATE.incognito;
|
|
535
|
+
checkbox.checked = !!effective;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function renderIntensity() {
|
|
539
|
+
// Intensity is now a 3-chip row (Calm / Sharp / Brutal) instead of
|
|
540
|
+
// a slider · highlight the active chip. The hint line above shows
|
|
541
|
+
// "currently: <value>" so the picked state stays self-evident.
|
|
542
|
+
const cur = effective("intensity");
|
|
543
|
+
modal.querySelectorAll("[data-rs-intensity-pick]").forEach((el) => {
|
|
544
|
+
el.classList.toggle("active", el.dataset.rsIntensityPick === cur);
|
|
545
|
+
});
|
|
546
|
+
const intensityHint = modal.querySelector('[data-rs-intensity-hint]');
|
|
547
|
+
if (intensityHint) intensityHint.textContent = `currently: ${cur}`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function renderConfirmState() {
|
|
551
|
+
const status = modal.querySelector("[data-rs-status]");
|
|
552
|
+
const btn = modal.querySelector("[data-rs-confirm]");
|
|
553
|
+
if (!status || !btn) return;
|
|
554
|
+
status.classList.remove("error");
|
|
555
|
+
if (isDirty()) {
|
|
556
|
+
const parts = [];
|
|
557
|
+
if (STAGED.mode !== null) parts.push("tone");
|
|
558
|
+
if (STAGED.intensity !== null) parts.push("intensity");
|
|
559
|
+
if (membersDirty()) parts.push("members");
|
|
560
|
+
status.textContent = `pending: ${parts.join(", ")} — click Confirm to apply`;
|
|
561
|
+
status.classList.add("pending");
|
|
562
|
+
btn.classList.add("dirty");
|
|
563
|
+
btn.disabled = false;
|
|
564
|
+
} else {
|
|
565
|
+
status.textContent = "no pending changes";
|
|
566
|
+
status.classList.remove("pending");
|
|
567
|
+
btn.classList.remove("dirty");
|
|
568
|
+
btn.disabled = false;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function renderHistory() {
|
|
573
|
+
// History pane removed from the overlay UI · keep the function as
|
|
574
|
+
// a no-op so callers (init / logEvent) don't need to coordinate.
|
|
575
|
+
// ROOM_STATE.history is still populated for backend correlation.
|
|
576
|
+
const list = modal.querySelector(".rs-history-list");
|
|
577
|
+
if (!list) return;
|
|
578
|
+
const recent = ROOM_STATE.history.slice().reverse();
|
|
579
|
+
list.innerHTML = recent.map(historyRowHTML).join("");
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/* ─── Open/close ─── */
|
|
583
|
+
|
|
584
|
+
/** Pull baseline state from the live app (or fall back to demo state). */
|
|
585
|
+
function syncBaseline() {
|
|
586
|
+
const app = window.app;
|
|
587
|
+
const room = app?.currentRoom;
|
|
588
|
+
if (!room) return;
|
|
589
|
+
ROOM_STATE.title = room.subject || ROOM_STATE.title;
|
|
590
|
+
ROOM_STATE.topic = room.subject || ROOM_STATE.topic;
|
|
591
|
+
ROOM_STATE.number = typeof room.number === "number" ? room.number : ROOM_STATE.number;
|
|
592
|
+
ROOM_STATE.status = room.status || ROOM_STATE.status;
|
|
593
|
+
ROOM_STATE.mode = room.mode || "constructive";
|
|
594
|
+
ROOM_STATE.intensity = room.intensity || "sharp";
|
|
595
|
+
ROOM_STATE.incognito = room.incognito === true;
|
|
596
|
+
if (Array.isArray(app.currentMembers) && app.currentMembers.length) {
|
|
597
|
+
ROOM_STATE.members = app.currentMembers.map((a) => a.id);
|
|
598
|
+
}
|
|
599
|
+
// Snapshot baseline AFTER syncing so dirty-detection compares against
|
|
600
|
+
// the server's authoritative member set, not the previous open's.
|
|
601
|
+
MEMBERS_BASELINE = ROOM_STATE.members.slice();
|
|
602
|
+
// Refresh title surfaces inside the modal that snapshot strings.
|
|
603
|
+
const num = modal.querySelector(".rs-number");
|
|
604
|
+
if (num) num.textContent = ROOM_STATE.number;
|
|
605
|
+
const ttl = modal.querySelector(".rs-title");
|
|
606
|
+
if (ttl) ttl.textContent = ROOM_STATE.title;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function open() {
|
|
610
|
+
if (!overlay) return;
|
|
611
|
+
resetStaged();
|
|
612
|
+
syncBaseline();
|
|
613
|
+
// Re-render the members list every open · window.app.agents may have
|
|
614
|
+
// grown (a new custom agent was just created in the Agents tab) since
|
|
615
|
+
// the modal was last mounted, and the inactive-member catalog needs
|
|
616
|
+
// to reflect that without requiring a page reload.
|
|
617
|
+
renderMembers();
|
|
618
|
+
renderModes();
|
|
619
|
+
renderIntensity();
|
|
620
|
+
renderIncognito();
|
|
621
|
+
renderConfirmState();
|
|
622
|
+
closeCastPicker();
|
|
623
|
+
overlay.classList.add("open");
|
|
624
|
+
overlay.setAttribute("aria-hidden", "false");
|
|
625
|
+
document.body.style.overflow = "hidden";
|
|
626
|
+
}
|
|
627
|
+
function close() {
|
|
628
|
+
if (!overlay) return;
|
|
629
|
+
overlay.classList.remove("open");
|
|
630
|
+
overlay.setAttribute("aria-hidden", "true");
|
|
631
|
+
document.body.style.overflow = "";
|
|
632
|
+
// Reset any pending confirms (member removal two-step)
|
|
633
|
+
confirmTimers.forEach((id) => clearTimeout(id));
|
|
634
|
+
confirmTimers.clear();
|
|
635
|
+
closeCastPicker();
|
|
636
|
+
// Drop any unconfirmed config changes — they were never persisted.
|
|
637
|
+
resetStaged();
|
|
638
|
+
// Roll back optimistic member edits to the baseline taken on open.
|
|
639
|
+
ROOM_STATE.members = MEMBERS_BASELINE.slice();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/* ─── Mutations + history logging ─── */
|
|
643
|
+
|
|
644
|
+
function logEvent(evt) {
|
|
645
|
+
// History pane removed from the overlay UI · keep the in-memory
|
|
646
|
+
// log for backend-event correlation but no DOM render. The chair
|
|
647
|
+
// settings-change announcement still surfaces inline in chat.
|
|
648
|
+
const stamped = Object.assign({ ts: nowStamp(), who: "you" }, evt);
|
|
649
|
+
ROOM_STATE.history.push(stamped);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function addMember(slug) {
|
|
653
|
+
if (ROOM_STATE.members.includes(slug)) return;
|
|
654
|
+
ROOM_STATE.members.push(slug);
|
|
655
|
+
renderMembers();
|
|
656
|
+
logEvent({
|
|
657
|
+
kind: "member-add",
|
|
658
|
+
payload: slug,
|
|
659
|
+
label: `added @${shortHandle(slug)}`
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function removeMember(slug) {
|
|
664
|
+
const idx = ROOM_STATE.members.indexOf(slug);
|
|
665
|
+
if (idx < 0) return;
|
|
666
|
+
if (ROOM_STATE.members.length <= 1) return; // floor at 1
|
|
667
|
+
ROOM_STATE.members.splice(idx, 1);
|
|
668
|
+
renderMembers();
|
|
669
|
+
logEvent({
|
|
670
|
+
kind: "member-remove",
|
|
671
|
+
payload: slug,
|
|
672
|
+
label: `removed @${shortHandle(slug)}`
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function shortHandle(slug) {
|
|
677
|
+
const map = {
|
|
678
|
+
"socrates": "socrates",
|
|
679
|
+
"first-principles": "first_p",
|
|
680
|
+
"value-investor": "value_inv",
|
|
681
|
+
"user-empathy": "user_emp",
|
|
682
|
+
"long-horizon": "long_h",
|
|
683
|
+
"phenomenologist": "phen"
|
|
684
|
+
};
|
|
685
|
+
return map[slug] || slug;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Staging — chip clicks only set STAGED.* and re-render the chip rows.
|
|
689
|
+
// The change is committed when the user clicks Confirm (see commit()).
|
|
690
|
+
function stageMode(next) {
|
|
691
|
+
STAGED.mode = next === ROOM_STATE.mode ? null : next;
|
|
692
|
+
renderModes();
|
|
693
|
+
renderConfirmState();
|
|
694
|
+
}
|
|
695
|
+
function stageIntensity(next) {
|
|
696
|
+
if (!["calm", "sharp", "brutal"].includes(next)) return;
|
|
697
|
+
STAGED.intensity = next === ROOM_STATE.intensity ? null : next;
|
|
698
|
+
renderIntensity();
|
|
699
|
+
renderConfirmState();
|
|
700
|
+
}
|
|
701
|
+
function stageIncognito(next) {
|
|
702
|
+
STAGED.incognito = !!next === ROOM_STATE.incognito ? null : !!next;
|
|
703
|
+
renderIncognito();
|
|
704
|
+
renderConfirmState();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Push staged config + member changes to the backend. */
|
|
708
|
+
async function commit() {
|
|
709
|
+
if (!isDirty()) { close(); return; }
|
|
710
|
+
|
|
711
|
+
// Build the settings patch (mode / intensity / incognito).
|
|
712
|
+
const patch = {};
|
|
713
|
+
if (STAGED.mode !== null) patch.mode = STAGED.mode;
|
|
714
|
+
if (STAGED.intensity !== null) patch.intensity = STAGED.intensity;
|
|
715
|
+
if (STAGED.incognito !== null) patch.incognito = STAGED.incognito;
|
|
716
|
+
|
|
717
|
+
const btn = modal.querySelector("[data-rs-confirm]");
|
|
718
|
+
const status = modal.querySelector("[data-rs-status]");
|
|
719
|
+
if (btn) { btn.disabled = true; btn.textContent = "[ Applying… ]"; }
|
|
720
|
+
|
|
721
|
+
try {
|
|
722
|
+
if (Object.keys(patch).length > 0 && window.app && typeof window.app.updateRoomSettings === "function") {
|
|
723
|
+
await window.app.updateRoomSettings(patch);
|
|
724
|
+
}
|
|
725
|
+
// Members PATCH · only fire when the membership actually changed.
|
|
726
|
+
// Server diffs against current and runs the chair announcement +
|
|
727
|
+
// queue injection. We don't await SSE — the chair message and
|
|
728
|
+
// speaker turns flow in via the existing room stream.
|
|
729
|
+
if (membersDirty() && window.app?.currentRoomId) {
|
|
730
|
+
const r = await fetch(
|
|
731
|
+
"/api/rooms/" + encodeURIComponent(window.app.currentRoomId) + "/members",
|
|
732
|
+
{
|
|
733
|
+
method: "PATCH",
|
|
734
|
+
headers: { "content-type": "application/json" },
|
|
735
|
+
body: JSON.stringify({ agentIds: ROOM_STATE.members }),
|
|
736
|
+
},
|
|
737
|
+
);
|
|
738
|
+
if (!r.ok) {
|
|
739
|
+
const j = await r.json().catch(() => ({}));
|
|
740
|
+
throw new Error(j.error || ("members update failed: HTTP " + r.status));
|
|
741
|
+
}
|
|
742
|
+
const j = await r.json();
|
|
743
|
+
// Refresh the app's authoritative member list so sidebars / chips
|
|
744
|
+
// re-render against the new roster on the next paint.
|
|
745
|
+
if (Array.isArray(j.members) && window.app) {
|
|
746
|
+
// Resolve each member id to the full agent record we already have.
|
|
747
|
+
const byId = {};
|
|
748
|
+
for (const a of (window.app.agents || [])) byId[a.id] = a;
|
|
749
|
+
window.app.currentMembers = j.members
|
|
750
|
+
.map((m) => byId[m.agentId])
|
|
751
|
+
.filter(Boolean);
|
|
752
|
+
}
|
|
753
|
+
MEMBERS_BASELINE = ROOM_STATE.members.slice();
|
|
754
|
+
}
|
|
755
|
+
// Snapshot the previous values BEFORE we overwrite, so the chat
|
|
756
|
+
// markers can show "before → after" honestly.
|
|
757
|
+
const before = {
|
|
758
|
+
mode: ROOM_STATE.mode,
|
|
759
|
+
intensity: ROOM_STATE.intensity,
|
|
760
|
+
incognito: ROOM_STATE.incognito,
|
|
761
|
+
};
|
|
762
|
+
const after = {
|
|
763
|
+
mode: patch.mode ?? ROOM_STATE.mode,
|
|
764
|
+
intensity: patch.intensity ?? ROOM_STATE.intensity,
|
|
765
|
+
incognito: typeof patch.incognito === "boolean" ? patch.incognito : ROOM_STATE.incognito,
|
|
766
|
+
};
|
|
767
|
+
Object.assign(ROOM_STATE, after);
|
|
768
|
+
if (STAGED.mode !== null) {
|
|
769
|
+
logEvent({ kind: "mode", before: before.mode, after: after.mode,
|
|
770
|
+
label: `tone: ${before.mode} → ${after.mode}` });
|
|
771
|
+
}
|
|
772
|
+
if (STAGED.intensity !== null) {
|
|
773
|
+
logEvent({ kind: "intensity", before: before.intensity, after: after.intensity,
|
|
774
|
+
label: `intensity: ${before.intensity} → ${after.intensity}` });
|
|
775
|
+
}
|
|
776
|
+
if (STAGED.incognito !== null) {
|
|
777
|
+
logEvent({ kind: "incognito", before: before.incognito, after: after.incognito,
|
|
778
|
+
label: `memory: ${before.incognito ? "incognito" : "default"} → ${after.incognito ? "incognito" : "default"}` });
|
|
779
|
+
}
|
|
780
|
+
resetStaged();
|
|
781
|
+
if (btn) { btn.disabled = false; btn.textContent = "[ Confirm ]"; }
|
|
782
|
+
close();
|
|
783
|
+
} catch (e) {
|
|
784
|
+
if (btn) { btn.disabled = false; btn.textContent = "[ Confirm ]"; }
|
|
785
|
+
if (status) {
|
|
786
|
+
status.textContent = `failed: ${e && e.message ? e.message : e}`;
|
|
787
|
+
status.classList.add("error");
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/* ─── Chat marker injection ─── */
|
|
793
|
+
|
|
794
|
+
function injectChatMarker(evt) {
|
|
795
|
+
const chat = document.querySelector(".chat");
|
|
796
|
+
if (!chat) return;
|
|
797
|
+
|
|
798
|
+
const wrap = document.createElement("div");
|
|
799
|
+
wrap.className = "config-marker";
|
|
800
|
+
wrap.dataset.kind = evt.kind;
|
|
801
|
+
|
|
802
|
+
let symbol = "·";
|
|
803
|
+
if (evt.kind === "member-add") symbol = "+";
|
|
804
|
+
if (evt.kind === "member-remove") symbol = "−";
|
|
805
|
+
if (evt.kind === "mode" || evt.kind === "style" || evt.kind === "intensity") symbol = "↔";
|
|
806
|
+
|
|
807
|
+
wrap.innerHTML = `
|
|
808
|
+
<span class="cm-line"></span>
|
|
809
|
+
<span class="cm-body">
|
|
810
|
+
<span class="cm-time">${escape(clockOnly())}</span>
|
|
811
|
+
<span class="cm-icon">${symbol}</span>
|
|
812
|
+
<span class="cm-label">${escape(evt.label)}</span>
|
|
813
|
+
<span class="cm-who">${escape(evt.who || "you")}</span>
|
|
814
|
+
</span>
|
|
815
|
+
<span class="cm-line"></span>
|
|
816
|
+
`;
|
|
817
|
+
|
|
818
|
+
// Insert at the end of the chat (latest activity)
|
|
819
|
+
chat.appendChild(wrap);
|
|
820
|
+
// Auto-scroll if user is near bottom
|
|
821
|
+
if (chat.scrollHeight - chat.scrollTop - chat.clientHeight < 200) {
|
|
822
|
+
chat.scrollTop = chat.scrollHeight;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/* ─── Click handler with two-step confirm ─── */
|
|
827
|
+
|
|
828
|
+
function handleRemoveClick(button) {
|
|
829
|
+
const slug = button.dataset.slug;
|
|
830
|
+
const state = button.dataset.state || "idle";
|
|
831
|
+
|
|
832
|
+
if (state === "idle") {
|
|
833
|
+
// First click: arm the confirm state
|
|
834
|
+
button.dataset.state = "confirm";
|
|
835
|
+
const t = setTimeout(() => {
|
|
836
|
+
if (button.dataset.state === "confirm") button.dataset.state = "idle";
|
|
837
|
+
confirmTimers.delete(slug);
|
|
838
|
+
}, 4000);
|
|
839
|
+
// Cancel any prior timer for this slug
|
|
840
|
+
if (confirmTimers.has(slug)) clearTimeout(confirmTimers.get(slug));
|
|
841
|
+
confirmTimers.set(slug, t);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (state === "confirm") {
|
|
846
|
+
// Second click within window: actually remove
|
|
847
|
+
if (confirmTimers.has(slug)) {
|
|
848
|
+
clearTimeout(confirmTimers.get(slug));
|
|
849
|
+
confirmTimers.delete(slug);
|
|
850
|
+
}
|
|
851
|
+
removeMember(slug);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/* ─── Init ─── */
|
|
856
|
+
|
|
857
|
+
function init() {
|
|
858
|
+
if (document.getElementById("room-settings-overlay")) return;
|
|
859
|
+
const wrap = document.createElement("div");
|
|
860
|
+
wrap.innerHTML = modalHTML().trim();
|
|
861
|
+
document.body.appendChild(wrap.firstChild);
|
|
862
|
+
|
|
863
|
+
overlay = document.getElementById("room-settings-overlay");
|
|
864
|
+
modal = overlay.querySelector(".room-settings-modal");
|
|
865
|
+
|
|
866
|
+
renderMembers();
|
|
867
|
+
renderModes();
|
|
868
|
+
renderIntensity();
|
|
869
|
+
renderIncognito();
|
|
870
|
+
renderHistory();
|
|
871
|
+
renderConfirmState();
|
|
872
|
+
|
|
873
|
+
// Close (X) — discards staged changes via close().
|
|
874
|
+
overlay.querySelector(".close-btn").addEventListener("click", close);
|
|
875
|
+
// Confirm — push staged changes to the backend then close.
|
|
876
|
+
modal.querySelector("[data-rs-confirm]").addEventListener("click", (e) => {
|
|
877
|
+
e.preventDefault();
|
|
878
|
+
void commit();
|
|
879
|
+
});
|
|
880
|
+
// Cancel — discards staged changes (close() resets) and closes the modal.
|
|
881
|
+
const cancelBtn = modal.querySelector("[data-rs-cancel]");
|
|
882
|
+
if (cancelBtn) {
|
|
883
|
+
cancelBtn.addEventListener("click", (e) => { e.preventDefault(); close(); });
|
|
884
|
+
}
|
|
885
|
+
overlay.addEventListener("click", (e) => {
|
|
886
|
+
if (e.target === overlay) close();
|
|
887
|
+
});
|
|
888
|
+
document.addEventListener("keydown", (e) => {
|
|
889
|
+
if (e.key === "Escape" && overlay.classList.contains("open")) {
|
|
890
|
+
e.stopImmediatePropagation();
|
|
891
|
+
close();
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
// Triggers anywhere
|
|
896
|
+
document.addEventListener("click", (e) => {
|
|
897
|
+
if (e.target.closest("[data-room-settings-trigger]")) {
|
|
898
|
+
e.preventDefault();
|
|
899
|
+
open();
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// Hover the info icon → open the description popover · close on
|
|
904
|
+
// mouseleave with a small grace window (handled inside
|
|
905
|
+
// openInfoPopover / scheduleClosePopover).
|
|
906
|
+
modal.addEventListener("mouseover", (e) => {
|
|
907
|
+
const info = e.target.closest(".rs-info-trigger");
|
|
908
|
+
if (info) openInfoPopover(info);
|
|
909
|
+
});
|
|
910
|
+
modal.addEventListener("mouseout", (e) => {
|
|
911
|
+
if (e.target.closest(".rs-info-trigger")) scheduleClosePopover();
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
// Capture-phase modal interactions
|
|
915
|
+
modal.addEventListener("click", (e) => {
|
|
916
|
+
// ─── Directors trigger · open / toggle the picker popover ──
|
|
917
|
+
const castTrigger = e.target.closest("[data-rs-cast-trigger]");
|
|
918
|
+
if (castTrigger) {
|
|
919
|
+
e.preventDefault();
|
|
920
|
+
e.stopPropagation();
|
|
921
|
+
toggleCastPicker(castTrigger);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
// Avatar / info button → open the agent's profile overlay.
|
|
925
|
+
const profileLink = e.target.closest("[data-agent-link]");
|
|
926
|
+
if (profileLink && (e.target.closest(".rs-member-av") || e.target.closest(".rs-member-img") || e.target.closest(".rs-member-info"))) {
|
|
927
|
+
e.preventDefault();
|
|
928
|
+
e.stopPropagation();
|
|
929
|
+
const slug = profileLink.getAttribute("data-agent-link");
|
|
930
|
+
if (typeof window.openAgentOverlay === "function" && slug) {
|
|
931
|
+
window.openAgentOverlay(slug);
|
|
932
|
+
}
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
// Row click anywhere else → toggle staged membership. The footer's
|
|
936
|
+
// [Confirm] / [Cancel] commits / discards the change.
|
|
937
|
+
const memberRow = e.target.closest("[data-rs-toggle]");
|
|
938
|
+
if (memberRow) {
|
|
939
|
+
e.preventDefault();
|
|
940
|
+
e.stopPropagation();
|
|
941
|
+
const slug = memberRow.getAttribute("data-slug");
|
|
942
|
+
if (!slug) return;
|
|
943
|
+
if (ROOM_STATE.members.includes(slug)) {
|
|
944
|
+
removeMember(slug);
|
|
945
|
+
} else {
|
|
946
|
+
addMember(slug);
|
|
947
|
+
}
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
// The `i` glyph on a chip is hover-driven (see mouseover wiring
|
|
951
|
+
// below) — clicking it should swallow the event so it doesn't
|
|
952
|
+
// also stage the chip's tone/intensity.
|
|
953
|
+
if (e.target.closest(".rs-info-trigger")) {
|
|
954
|
+
e.preventDefault();
|
|
955
|
+
e.stopPropagation();
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
// Mode chip — stage only, commit on Confirm. Picks up clicks on
|
|
959
|
+
// the .rs-chip-label too via .closest.
|
|
960
|
+
const mc = e.target.closest("[data-mode]");
|
|
961
|
+
if (mc) {
|
|
962
|
+
e.preventDefault();
|
|
963
|
+
e.stopPropagation();
|
|
964
|
+
stageMode(mc.dataset.mode);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
// Intensity slider · click handled by pointer events (below) so drag
|
|
968
|
+
// can begin on the same gesture. Capture-phase clicks are no-ops.
|
|
969
|
+
if (e.target.closest(".rs-temp-bar")) {
|
|
970
|
+
e.preventDefault();
|
|
971
|
+
e.stopPropagation();
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const ip = e.target.closest("[data-rs-intensity-pick]");
|
|
975
|
+
if (ip) {
|
|
976
|
+
e.preventDefault();
|
|
977
|
+
e.stopPropagation();
|
|
978
|
+
stageIntensity(ip.dataset.rsIntensityPick);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
// Incognito · checkbox toggles the room's per-room memory
|
|
982
|
+
// opt-out. Native click on the input fires this — capture-phase
|
|
983
|
+
// is fine because we don't preventDefault (let the checkbox
|
|
984
|
+
// visually flip), we just stage the new value.
|
|
985
|
+
const incBox = e.target.closest("[data-rs-incognito-check]");
|
|
986
|
+
if (incBox) {
|
|
987
|
+
// Don't stop propagation · the native checkbox click event
|
|
988
|
+
// continues, then we read its checked state on next tick.
|
|
989
|
+
setTimeout(() => stageIncognito(incBox.checked), 0);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
}, true);
|
|
993
|
+
|
|
994
|
+
// Pointer-driven drag for the intensity slider (mouse + touch + stylus).
|
|
995
|
+
const bar = modal.querySelector(".rs-temp-bar");
|
|
996
|
+
if (bar) {
|
|
997
|
+
const valueAt = (clientX) => {
|
|
998
|
+
const rect = bar.getBoundingClientRect();
|
|
999
|
+
if (rect.width <= 0) return "sharp";
|
|
1000
|
+
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
1001
|
+
return ratio < 0.33 ? "calm" : ratio < 0.67 ? "sharp" : "brutal";
|
|
1002
|
+
};
|
|
1003
|
+
bar.addEventListener("pointerdown", (e) => {
|
|
1004
|
+
e.preventDefault();
|
|
1005
|
+
bar.setPointerCapture(e.pointerId);
|
|
1006
|
+
bar.dataset.dragging = "1";
|
|
1007
|
+
stageIntensity(valueAt(e.clientX));
|
|
1008
|
+
});
|
|
1009
|
+
bar.addEventListener("pointermove", (e) => {
|
|
1010
|
+
if (bar.dataset.dragging !== "1") return;
|
|
1011
|
+
stageIntensity(valueAt(e.clientX));
|
|
1012
|
+
});
|
|
1013
|
+
const end = (e) => {
|
|
1014
|
+
if (bar.dataset.dragging !== "1") return;
|
|
1015
|
+
bar.dataset.dragging = "0";
|
|
1016
|
+
try { bar.releasePointerCapture(e.pointerId); } catch (_) {}
|
|
1017
|
+
};
|
|
1018
|
+
bar.addEventListener("pointerup", end);
|
|
1019
|
+
bar.addEventListener("pointercancel", end);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Public API
|
|
1024
|
+
window.openRoomSettings = function () { if (!overlay) init(); open(); };
|
|
1025
|
+
window.closeRoomSettings = close;
|
|
1026
|
+
// Expose mutations so the picker can hand off (it imports addMember).
|
|
1027
|
+
window.RoomSettings = {
|
|
1028
|
+
addMember: addMember,
|
|
1029
|
+
removeMember: removeMember,
|
|
1030
|
+
getMembers: () => ROOM_STATE.members.slice(),
|
|
1031
|
+
getState: () => ROOM_STATE
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
if (document.readyState === "loading") {
|
|
1035
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
1036
|
+
} else {
|
|
1037
|
+
init();
|
|
1038
|
+
}
|
|
1039
|
+
})();
|