privateboard 0.1.0 → 0.1.2

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/public/app.js CHANGED
@@ -8,9 +8,9 @@
8
8
  ─ SSE per room: /api/rooms/:id/stream
9
9
  ─ actions: createRoom · sendMessage · adjournRoom
10
10
 
11
- Designed in vanilla JS (no framework) to match the rest of the prototype.
11
+ Designed in vanilla JS (no framework) to match the rest of the frontend.
12
12
  Renders into named DOM containers; non-list parts (chrome / overlays) keep
13
- their existing prototype handlers.
13
+ their existing handlers.
14
14
  */
15
15
  (function () {
16
16
  /** Display labels for the registry's modelV ids · used to print
@@ -68,10 +68,12 @@
68
68
  "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.",
69
69
  constructive:
70
70
  "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.",
71
+ research:
72
+ "Collaborative inquiry. The room mines the materials in front of it (your brief, web-search results, prior turns) for what's actually there. Each turn must cite a specific source piece, label it OBSERVATION / INFERENCE / SPECULATION, then extract the insight your lens makes salient. Defaults web search ON when a Brave key is configured.",
71
73
  debate:
72
74
  "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.",
73
- "no-mercy":
74
- "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.",
75
+ critique:
76
+ "Review board. The room audits a finished deliverable systematically each turn names the dimension being audited (logic / evidence / scope / risk / etc.), surfaces 2–3 specific flaws labelled BLOCKER · MAJOR · MINOR, points at the load-bearing piece, and indicates the direction a fix would lie. At least one BLOCKER or MAJOR per turn is mandatory.",
75
77
  };
76
78
 
77
79
  const app = {
@@ -147,10 +149,86 @@
147
149
  this.renderSidebarRooms();
148
150
  this.renderSidebarAgents();
149
151
  this.renderUserBlock();
152
+ // Show a friendly "storage upgraded" banner if migrations have
153
+ // been applied since the user last opened the app. Fire-and-forget
154
+ // so a slow / failed call doesn't block the dashboard rendering.
155
+ void this.checkMigrationNotice();
150
156
  window.addEventListener("hashchange", () => this.handleRoute());
151
157
  this.handleRoute();
152
158
  },
153
159
 
160
+ /** Surface a one-line "storage was upgraded" notice when the user
161
+ * opens a build that ran new schema migrations against their
162
+ * existing DB. Compares the latest applied migration in the DB
163
+ * against the last-acknowledged name in localStorage; a fresh-
164
+ * install user sees nothing (no last-seen → first visit → write
165
+ * current latest, no banner). Dismiss writes the latest name so
166
+ * the banner doesn't re-show until truly-new migrations land. */
167
+ async checkMigrationNotice() {
168
+ const banner = document.querySelector("[data-sys-notice]");
169
+ if (!banner) return;
170
+ const textEl = banner.querySelector("[data-sys-notice-text]");
171
+ const closeBtn = banner.querySelector("[data-sys-notice-close]");
172
+ if (!textEl || !closeBtn) return;
173
+
174
+ let migrations = [];
175
+ try {
176
+ const r = await fetch("/api/system/migrations");
177
+ if (!r.ok) return;
178
+ const j = await r.json();
179
+ migrations = Array.isArray(j.migrations) ? j.migrations : [];
180
+ } catch { return; }
181
+ if (migrations.length === 0) return;
182
+
183
+ const latest = migrations[migrations.length - 1].name;
184
+ const KEY = "boardroom.lastSeenMigration";
185
+ let lastSeen = null;
186
+ try { lastSeen = localStorage.getItem(KEY); } catch { /* */ }
187
+
188
+ // Fresh-install (no last-seen recorded) · seed quietly with the
189
+ // current latest, no banner. The user hasn't been here before;
190
+ // showing "storage upgraded" makes no sense on first launch.
191
+ if (!lastSeen) {
192
+ try { localStorage.setItem(KEY, latest); } catch { /* */ }
193
+ return;
194
+ }
195
+ if (lastSeen === latest) return;
196
+
197
+ // Find migrations newer than lastSeen — by index in the list,
198
+ // since order is applied_at ASC.
199
+ const lastIdx = migrations.findIndex((m) => m.name === lastSeen);
200
+ const fresh = lastIdx >= 0 ? migrations.slice(lastIdx + 1) : migrations;
201
+ if (fresh.length === 0) {
202
+ try { localStorage.setItem(KEY, latest); } catch { /* */ }
203
+ return;
204
+ }
205
+
206
+ const lang = (this.composerLanguage && this.composerLanguage()) || "en";
207
+ const count = fresh.length;
208
+ const names = fresh.map((m) => m.name).join(", ");
209
+ const copy = lang === "zh"
210
+ ? {
211
+ head: `存储结构已升级`,
212
+ body: `已应用 ${count} 个新迁移 · 你已有的房间、董事、报告、设置都已保留。`,
213
+ tooltip: names,
214
+ }
215
+ : {
216
+ head: `Storage upgraded`,
217
+ body: `${count} new migration${count > 1 ? "s" : ""} applied · your existing rooms, agents, briefs, and settings were preserved.`,
218
+ tooltip: names,
219
+ };
220
+ textEl.innerHTML =
221
+ `<span class="sys-notice-strong">${this.escape(copy.head)}</span> · ${this.escape(copy.body)}`;
222
+ banner.title = copy.tooltip;
223
+ banner.removeAttribute("hidden");
224
+
225
+ const dismiss = () => {
226
+ try { localStorage.setItem(KEY, latest); } catch { /* */ }
227
+ banner.setAttribute("hidden", "");
228
+ };
229
+ closeBtn.addEventListener("click", dismiss, { once: true });
230
+ },
231
+
154
232
  /** Refetch /api/keys and update the local cache. Called by
155
233
  * user-settings on close so the requireModelKey gate sees the
156
234
  * user's just-configured keys without a full page reload. */
@@ -562,6 +640,10 @@
562
640
  document.documentElement.setAttribute("data-status", "live");
563
641
  this.renderHeader();
564
642
  syncSidebar({ status: "live", pausedAt: null });
643
+ // Drop any paused-supplement overlay · the supplement endpoint
644
+ // 409s once the room is live, so leaving the modal up is just
645
+ // a confusing no-op for the user.
646
+ this.closePausedSupplementOverlay?.();
565
647
  } else if (kind === "room-adjourned") {
566
648
  const ts = payload.adjournedAt || Date.now();
567
649
  if (this.currentRoom) {
@@ -572,6 +654,7 @@
572
654
  this.renderHeader();
573
655
  syncSidebar({ status: "adjourned", adjournedAt: ts });
574
656
  } else if (kind === "brief-started") {
657
+ this.markBriefEvent();
575
658
  this.currentBrief = {
576
659
  id: payload.briefId,
577
660
  title: "Generating…",
@@ -582,6 +665,7 @@
582
665
  // language is inferred server-side from the room subject.
583
666
  chairName: payload.chairName || (this.currentChair?.name) || "Chair",
584
667
  language: payload.language === "zh" ? "zh" : "en",
668
+ pipelineStartedAt: Date.now(),
585
669
  // Stage checklist · seeded with all three stages in pending
586
670
  // state. brief-stage events flip them active → done as the
587
671
  // pipeline progresses. startedAt is captured when each
@@ -597,10 +681,16 @@
597
681
  this.renderBrief();
598
682
  // Start the per-second tick driving elapsed/substage animation.
599
683
  this.ensureBriefStageTick();
684
+ // Heartbeat watcher — surfaces Retry on stall / timeout.
685
+ this.ensureBriefStallWatch();
600
686
  // Surface the View Report button + hide the no-brief CTA.
601
687
  this.renderHeader();
602
688
  this.renderChat();
689
+ // Pull the user's eye onto the freshly-mounted card so the
690
+ // click that triggered generation has visible feedback.
691
+ this.scrollToBriefCard();
603
692
  } else if (kind === "brief-stage") {
693
+ this.markBriefEvent();
604
694
  if (this.currentBrief) {
605
695
  const st = this.currentBrief.stages || (this.currentBrief.stages = {
606
696
  extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
@@ -615,6 +705,14 @@
615
705
  if (newStatus === "active" && st[key].status !== "active") {
616
706
  st[key].startedAt = Date.now();
617
707
  }
708
+ // Capture finish time when the stage transitions to done
709
+ // so the displayed elapsed freezes at completion. Without
710
+ // this, the per-stage timer kept ticking off Date.now()
711
+ // forever — the user couldn't read each stage's actual
712
+ // duration at a glance.
713
+ if (newStatus === "done" && st[key].status !== "done" && !st[key].finishedAt) {
714
+ st[key].finishedAt = Date.now();
715
+ }
618
716
  st[key].status = newStatus;
619
717
  st[key].detail = payload.detail || "";
620
718
  st[key].progress = payload.progress || null;
@@ -632,6 +730,7 @@
632
730
  this.ensureBriefStageTick();
633
731
  }
634
732
  } else if (kind === "brief-token") {
733
+ this.markBriefEvent();
635
734
  // Accumulate the body. Throttle the re-render to once per ~250ms
636
735
  // so the writing-stage word count animates without thrashing on
637
736
  // every chunk.
@@ -643,10 +742,12 @@
643
742
  this.renderBrief();
644
743
  }
645
744
  } else if (kind === "brief-final") {
745
+ this.markBriefEvent();
646
746
  if (this.currentBrief) {
647
747
  this.currentBrief.title = payload.title || this.currentBrief.title;
648
748
  }
649
749
  this.stopBriefStageTick();
750
+ this.stopBriefStallWatch();
650
751
  this.renderBrief();
651
752
  this.renderHeader();
652
753
  // Refresh the FULL brief list so the tab strip picks up the
@@ -670,8 +771,10 @@
670
771
  .catch(() => {});
671
772
  }
672
773
  } else if (kind === "brief-error") {
774
+ this.markBriefEvent();
673
775
  if (this.currentBrief) this.currentBrief.error = payload.message;
674
776
  this.stopBriefStageTick();
777
+ this.stopBriefStallWatch();
675
778
  this.renderBrief();
676
779
  } else if (kind === "settings-changed") {
677
780
  const ch = payload.changes || {};
@@ -820,6 +923,9 @@
820
923
  try { this.sse.close(); } catch (e) { /* */ }
821
924
  this.sse = null;
822
925
  }
926
+ // Drop the brief watcher · the brief belongs to a room and the
927
+ // watcher would otherwise keep ticking against a stale id.
928
+ this.stopBriefStallWatch();
823
929
  },
824
930
 
825
931
  // ── Actions ───────────────────────────────────────────────
@@ -1346,6 +1452,146 @@
1346
1452
  }
1347
1453
  },
1348
1454
 
1455
+ /** Paused-supplement overlay · lets the user drop in an extra
1456
+ * thought while the room is paused. The text is posted as a
1457
+ * user message immediately (lands in the chat as the freshest
1458
+ * user input) but the saved director queue is left untouched —
1459
+ * so when they click Resume, the previously-paused director
1460
+ * takes over with the supplement already in their context.
1461
+ * Effectively the supplement plays "first" in the resumed
1462
+ * flow, and the rest of the queue continues in order. Reuses
1463
+ * the existing .supplement-* CSS classes for visual parity. */
1464
+ openPausedSupplementOverlay() {
1465
+ if (!this.currentRoomId || !this.currentRoom) return;
1466
+ if (this.currentRoom.status !== "paused") return;
1467
+ this.closePausedSupplementOverlay();
1468
+ const lang = this.composerLanguage();
1469
+ const t = lang === "zh"
1470
+ ? {
1471
+ classify: "room · 暂停时补充",
1472
+ classifyRight: "// queued first",
1473
+ title: "补充一个观点",
1474
+ metaPrefix: "// 当前房间",
1475
+ placeholder: "想补一个观点 · 一个想再追问的细节 · 一个让董事们重新考虑的角度。\n\n会立即作为你的发言进入对话;点击 [ Resume ] 后,董事们会先看到这条再继续。",
1476
+ hint: "暂停期间的补充会以你的身份立即出现在对话里,原本的发言队列不变;恢复后队首董事将带着这条补充开口。",
1477
+ cancel: "[ Cancel ]",
1478
+ confirm: "[ Add to chat ]",
1479
+ confirmBusy: "[ Posting… ]",
1480
+ }
1481
+ : {
1482
+ classify: "room · paused supplement",
1483
+ classifyRight: "// queued first",
1484
+ title: "Add a supplemental input",
1485
+ metaPrefix: "// Current room",
1486
+ placeholder: "Drop in an extra thought, a follow-up question, or an angle you'd like the board to take into account.\n\nIt lands in the chat as your message right now; when you hit [ Resume ], the next director picks up with this in front of them.",
1487
+ hint: "Posted while paused, the supplement lands as your message immediately; the saved speaker queue is untouched. After resume, the next director responds with the supplement first.",
1488
+ cancel: "[ Cancel ]",
1489
+ confirm: "[ Add to chat ]",
1490
+ confirmBusy: "[ Posting… ]",
1491
+ };
1492
+ const subject = (this.currentRoom.subject || "").trim() || (lang === "zh" ? "(无主题)" : "(no subject)");
1493
+ const html = `
1494
+ <div class="supplement-overlay" id="paused-supplement-overlay" role="dialog" aria-modal="true">
1495
+ <div class="supplement-backdrop" data-paused-supplement-close></div>
1496
+ <div class="supplement-modal" role="document">
1497
+ <div class="supplement-classification">
1498
+ <span><span class="dot">●</span> ${this.escape(t.classify)}</span>
1499
+ <span class="right">${this.escape(t.classifyRight)}</span>
1500
+ </div>
1501
+ <header class="supplement-head">
1502
+ <div>
1503
+ <div class="meta">${this.escape(t.metaPrefix)} · <span>${this.escape(subject)}</span></div>
1504
+ <div class="title">${this.escape(t.title)}</div>
1505
+ </div>
1506
+ <button type="button" class="supplement-close" data-paused-supplement-close aria-label="Close">✕</button>
1507
+ </header>
1508
+ <div class="supplement-body">
1509
+ <textarea class="supplement-input" data-paused-supplement-input rows="6" placeholder="${this.escape(t.placeholder)}"></textarea>
1510
+ <p class="supplement-hint">${this.escape(t.hint)}</p>
1511
+ </div>
1512
+ <footer class="supplement-foot">
1513
+ <button type="button" class="supplement-cancel" data-paused-supplement-close>${this.escape(t.cancel)}</button>
1514
+ <button type="button" class="supplement-confirm" data-paused-supplement-confirm data-busy-label="${this.escape(t.confirmBusy)}">${this.escape(t.confirm)}</button>
1515
+ </footer>
1516
+ </div>
1517
+ </div>
1518
+ `;
1519
+ const wrap = document.createElement("div");
1520
+ wrap.innerHTML = html.trim();
1521
+ document.body.appendChild(wrap.firstChild);
1522
+ document.body.style.overflow = "hidden";
1523
+ this._pausedSupplementEsc = (ev) => {
1524
+ if (ev.key === "Escape") {
1525
+ ev.stopImmediatePropagation();
1526
+ this.closePausedSupplementOverlay();
1527
+ }
1528
+ };
1529
+ document.addEventListener("keydown", this._pausedSupplementEsc, true);
1530
+ // Cmd/Ctrl-Enter submits — long-form textarea convention.
1531
+ this._pausedSupplementSubmit = (ev) => {
1532
+ if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") {
1533
+ const overlay = document.getElementById("paused-supplement-overlay");
1534
+ if (!overlay) return;
1535
+ ev.preventDefault();
1536
+ this.submitPausedSupplement();
1537
+ }
1538
+ };
1539
+ document.addEventListener("keydown", this._pausedSupplementSubmit, true);
1540
+ setTimeout(() => {
1541
+ const input = document.querySelector("[data-paused-supplement-input]");
1542
+ if (input) input.focus();
1543
+ }, 30);
1544
+ },
1545
+
1546
+ closePausedSupplementOverlay() {
1547
+ const el = document.getElementById("paused-supplement-overlay");
1548
+ if (el) el.remove();
1549
+ document.body.style.overflow = "";
1550
+ if (this._pausedSupplementEsc) {
1551
+ document.removeEventListener("keydown", this._pausedSupplementEsc, true);
1552
+ this._pausedSupplementEsc = null;
1553
+ }
1554
+ if (this._pausedSupplementSubmit) {
1555
+ document.removeEventListener("keydown", this._pausedSupplementSubmit, true);
1556
+ this._pausedSupplementSubmit = null;
1557
+ }
1558
+ },
1559
+
1560
+ async submitPausedSupplement() {
1561
+ const overlay = document.getElementById("paused-supplement-overlay");
1562
+ if (!overlay) return;
1563
+ const input = overlay.querySelector("[data-paused-supplement-input]");
1564
+ const btn = overlay.querySelector("[data-paused-supplement-confirm]");
1565
+ const text = input ? (input.value || "").trim() : "";
1566
+ if (!text) {
1567
+ if (input) input.focus();
1568
+ return;
1569
+ }
1570
+ if (!this.currentRoomId) return;
1571
+ const origLabel = btn ? btn.textContent : "";
1572
+ const busyLabel = btn ? btn.getAttribute("data-busy-label") || origLabel : "";
1573
+ if (btn) { btn.disabled = true; btn.textContent = busyLabel; }
1574
+ try {
1575
+ const r = await fetch(
1576
+ "/api/rooms/" + encodeURIComponent(this.currentRoomId) + "/paused-input",
1577
+ {
1578
+ method: "POST",
1579
+ headers: { "content-type": "application/json" },
1580
+ body: JSON.stringify({ body: text }),
1581
+ },
1582
+ );
1583
+ if (!r.ok) {
1584
+ const e = await r.json().catch(() => ({}));
1585
+ throw new Error(e.error || ("HTTP " + r.status));
1586
+ }
1587
+ // SSE will push the message-appended event; chat updates itself.
1588
+ this.closePausedSupplementOverlay();
1589
+ } catch (e) {
1590
+ if (btn) { btn.disabled = false; btn.textContent = origLabel; }
1591
+ alert("Add input failed: " + (e && e.message ? e.message : e));
1592
+ }
1593
+ },
1594
+
1349
1595
  /** Confirm-handler · grabs the textarea, posts to the brief endpoint,
1350
1596
  * closes the overlay. Server emits brief-started + brief-* SSE
1351
1597
  * events as for a normal generate; the existing handlers replace
@@ -1433,6 +1679,7 @@
1433
1679
  // Orphan. Flip into the error UI which now carries a retry button.
1434
1680
  brief.error = "interrupted";
1435
1681
  brief.interrupted = true;
1682
+ this.stopBriefStallWatch();
1436
1683
  this.renderBrief();
1437
1684
  return;
1438
1685
  }
@@ -1440,6 +1687,7 @@
1440
1687
  this.hydrateBriefStagesFromState(brief, j.state);
1441
1688
  this.renderBrief();
1442
1689
  this.ensureBriefStageTick();
1690
+ this.ensureBriefStallWatch();
1443
1691
  }
1444
1692
  } catch { /* ignore — leave the loading state */ }
1445
1693
  },
@@ -1544,10 +1792,15 @@
1544
1792
  if (this.currentBrief) {
1545
1793
  this.currentBrief.error = null;
1546
1794
  this.currentBrief.interrupted = false;
1795
+ this.currentBrief.timedOut = false;
1547
1796
  this.currentBrief.bodyMd = "";
1548
1797
  this.currentBrief.title = "Generating…";
1798
+ this.currentBrief.pipelineStartedAt = Date.now();
1549
1799
  }
1800
+ this._lastBriefEventAt = Date.now();
1801
+ this._lastBriefHealthPollAt = 0;
1550
1802
  this.renderBrief();
1803
+ this.scrollToBriefCard();
1551
1804
  } catch (e) {
1552
1805
  alert("Regenerate failed: " + (e && e.message ? e.message : e));
1553
1806
  }
@@ -2126,6 +2379,18 @@
2126
2379
  out.push(`<ul>${items.join("")}</ul>`);
2127
2380
  continue;
2128
2381
  }
2382
+ // Markdown blockquote · every non-empty line starts with `&gt; `
2383
+ // (escaped from `> `). The whole block becomes one <blockquote>;
2384
+ // styling lives in CSS (.msg-bubble blockquote · designed
2385
+ // quote-card with mono kicker + italic body, no left border).
2386
+ if (lines.every((l) => /^&gt;\s?/.test(l) || l.trim() === "")) {
2387
+ const inner = lines
2388
+ .filter((l) => l.trim())
2389
+ .map((l) => this.inline(l.replace(/^&gt;\s?/, "")))
2390
+ .join("<br>");
2391
+ out.push(`<blockquote class="msg-quote">${inner}</blockquote>`);
2392
+ continue;
2393
+ }
2129
2394
  // Otherwise: paragraph (preserve single newlines as <br>).
2130
2395
  out.push(`<p>${this.inline(lines.join("<br>"))}</p>`);
2131
2396
  }
@@ -2528,8 +2793,6 @@
2528
2793
  if (nm) nm.textContent = name;
2529
2794
  const mt = document.querySelector("[data-user-meta]");
2530
2795
  if (mt) mt.textContent = meta;
2531
- const menuName = document.querySelector("[data-user-menu-name]");
2532
- if (menuName) menuName.textContent = name;
2533
2796
  },
2534
2797
 
2535
2798
  renderSidebarCounts() {
@@ -2569,6 +2832,10 @@
2569
2832
  ? this.escape(nextSpeaker.handle.replace(/^\//, ""))
2570
2833
  : "—";
2571
2834
 
2835
+ const lang = this.composerLanguage();
2836
+ const addInputLabel = lang === "zh" ? "[ + 补充观点 ]" : "[ + Add input ]";
2837
+ const adjournLabel = lang === "zh" ? "[ ▸ 结束并存档 ]" : "[ ▸ Adjourn & File Brief ]";
2838
+ const resumeLabel = lang === "zh" ? "[ ▶ 恢复讨论 ]" : "[ ▶ Resume Discussion ]";
2572
2839
  bar.innerHTML = `
2573
2840
  <div class="paused-bar-text">
2574
2841
  <strong>// discussion paused.</strong>
@@ -2576,8 +2843,9 @@
2576
2843
  next turn · <span class="lime">${nextHandle}</span>.
2577
2844
  </div>
2578
2845
  <div class="paused-bar-actions">
2579
- <a href="#" class="ghost-btn" data-adjourn>[ ▸ Adjourn &amp; File Brief ]</a>
2580
- <a href="#" class="resume-btn-lg" data-resume>[ ▶ Resume Discussion ]</a>
2846
+ <a href="#" class="ghost-btn" data-paused-supplement>${this.escape(addInputLabel)}</a>
2847
+ <a href="#" class="ghost-btn" data-adjourn>${this.escape(adjournLabel)}</a>
2848
+ <a href="#" class="resume-btn-lg" data-resume>${this.escape(resumeLabel)}</a>
2581
2849
  </div>
2582
2850
  `;
2583
2851
  },
@@ -2603,7 +2871,7 @@
2603
2871
  this.composerState = {
2604
2872
  ...this.DEFAULT_COMPOSER,
2605
2873
  ...(saved || {}),
2606
- // Keep these fresh-each-render: subject is intentionally not persisted.
2874
+ subject: (saved && typeof saved.subject === "string") ? saved.subject : "",
2607
2875
  };
2608
2876
  return this.composerState;
2609
2877
  },
@@ -2611,14 +2879,33 @@
2611
2879
  saveComposerState() {
2612
2880
  if (!this.composerState) return;
2613
2881
  try {
2614
- const { directorIds, mode, intensity, autoPickDirectors } = this.composerState;
2882
+ const { directorIds, mode, intensity, autoPickDirectors, subject } = this.composerState;
2615
2883
  localStorage.setItem(
2616
2884
  "boardroom.composer",
2617
- JSON.stringify({ directorIds, mode, intensity, autoPickDirectors }),
2885
+ JSON.stringify({ directorIds, mode, intensity, autoPickDirectors, subject }),
2618
2886
  );
2619
2887
  } catch { /* ignore */ }
2620
2888
  },
2621
2889
 
2890
+ /** Agent composer draft · the description textarea on "+ New Agent".
2891
+ * Persisted independently of composerState so the two screens don't
2892
+ * share fields (composerState is room-shaped). Survives view
2893
+ * switches and full app reloads; cleared after a successful save. */
2894
+ loadAgentComposerDraft() {
2895
+ try {
2896
+ const raw = localStorage.getItem("boardroom.agent-composer.draft");
2897
+ return typeof raw === "string" ? raw : "";
2898
+ } catch { return ""; }
2899
+ },
2900
+ saveAgentComposerDraft(text) {
2901
+ try { localStorage.setItem("boardroom.agent-composer.draft", String(text || "")); }
2902
+ catch { /* ignore */ }
2903
+ },
2904
+ clearAgentComposerDraft() {
2905
+ try { localStorage.removeItem("boardroom.agent-composer.draft"); }
2906
+ catch { /* ignore */ }
2907
+ },
2908
+
2622
2909
  /** Whether the composer is in auto-pick mode for the cast · default
2623
2910
  * is true (chair picks 3 directors based on subject when the user
2624
2911
  * hits Convene). Flips to false the moment the user manually
@@ -3167,7 +3454,7 @@
3167
3454
  </header>
3168
3455
 
3169
3456
  <div class="cmp-input-frame">
3170
- <textarea class="cmp-input" data-composer-subject rows="1" placeholder="${this.escape(t.placeholder)}"></textarea>
3457
+ <textarea class="cmp-input" data-composer-subject rows="1" placeholder="${this.escape(t.placeholder)}">${this.escape(state.subject || "")}</textarea>
3171
3458
 
3172
3459
  <div class="cmp-toolbar">
3173
3460
  <button type="button" class="cmp-cast-btn${isAutoPick ? " cmp-cast-btn-auto" : ""}" data-composer-dir-pick title="${this.escape(t.pickerLabel)}">
@@ -3560,7 +3847,7 @@
3560
3847
  { tag: "user-empathy", text: "A product hand who reasons from the user's moment of friction. Refuses any argument that doesn't name what the user is doing right then." },
3561
3848
  { tag: "first-principles", text: "A physicist who strips problems to observables and causal chains. Refuses to import assumptions from analogy." },
3562
3849
  { tag: "value-investor", text: "A long-pattern reader who tests every novel idea against thirty years of category history before believing it." },
3563
- { tag: "no-mercy-reviewer", text: "A hostile reviewerdefault that the claim is wrong until proved otherwise. Will not hedge, will not be polite." },
3850
+ { tag: "critique-reviewer", text: "A senior critic who audits any deliverable systematically labels each flaw blocker / major / minor, points at the load-bearing piece, names the mechanism. Won't praise without finding at least one major issue." },
3564
3851
  { tag: "phenomenologist", text: "An observer who notices what the room ISN'T saying. Tracks tone, what got skipped, who agreed too fast." },
3565
3852
  ],
3566
3853
  AGENT_STARTERS_ZH: [
@@ -3568,7 +3855,7 @@
3568
3855
  { tag: "user-empathy", text: "一位从用户摩擦时刻反推的产品老兵,反对任何不说清『用户那一刻在干嘛』的论点。" },
3569
3856
  { tag: "first-principles", text: "一位把问题拆到可观测、因果链上的物理学家,拒绝从类比里搬假设。" },
3570
3857
  { tag: "value-investor", text: "一位用三十年品类史做底的长周期读者,新点子要先和三个老案例对照才相信。" },
3571
- { tag: "no-mercy-reviewer", text: "一位敌意审稿人,默认你错——除非你证明给我看。不留情、不修饰、不绕弯。" },
3858
+ { tag: "critique-reviewer", text: "一位资深评审,对任何交付物做系统性审稿——每个瑕疵打 blocker / major / minor 严重度,指向具体段落、说出失败机制。不挑出至少一条 major 不会放过。" },
3572
3859
  { tag: "phenomenologist", text: "一位观察者,捕捉房间里没说出来的东西:语气、被跳过的话题、太快达成的一致。" },
3573
3860
  ],
3574
3861
 
@@ -3622,7 +3909,7 @@
3622
3909
  </header>
3623
3910
 
3624
3911
  <div class="cmp-input-frame ${generating ? "is-generating" : ""}">
3625
- <textarea class="cmp-input" data-agent-composer-desc rows="1" placeholder="${this.escape(t.placeholder)}" ${generating ? "disabled" : ""}></textarea>
3912
+ <textarea class="cmp-input" data-agent-composer-desc rows="1" placeholder="${this.escape(t.placeholder)}" ${generating ? "disabled" : ""}>${this.escape(this.loadAgentComposerDraft())}</textarea>
3626
3913
 
3627
3914
  <div class="cmp-toolbar">
3628
3915
  <button type="button" class="cmp-dd" data-cmp-dropdown="agent-model" title="${this.escape(t.modelLabel)}">
@@ -3984,6 +4271,9 @@
3984
4271
  await this.refreshAgents?.();
3985
4272
  this.agentSpec = null;
3986
4273
  this.agentSpecAvatarSeed = null;
4274
+ // Clear the saved description draft now that the agent exists —
4275
+ // a future visit to "+ New Agent" should land on a fresh textarea.
4276
+ this.clearAgentComposerDraft();
3987
4277
  this.composerMode = "room";
3988
4278
  // POST /api/agents returns the agent record directly (not wrapped).
3989
4279
  const newId = j && (j.id || (j.agent && j.agent.id));
@@ -4199,14 +4489,16 @@
4199
4489
  ? [
4200
4490
  { v: "brainstorm", label: "Brainstorm", hint: "共同发散" },
4201
4491
  { v: "constructive", label: "Constructive", hint: "推一把" },
4492
+ { v: "research", label: "Research", hint: "梳理材料找洞察" },
4202
4493
  { v: "debate", label: "Debate", hint: "找漏洞" },
4203
- { v: "no-mercy", label: "No Mercy", hint: "硬怼到底" },
4494
+ { v: "critique", label: "Critique", hint: "系统性挑毛病" },
4204
4495
  ]
4205
4496
  : [
4206
4497
  { v: "brainstorm", label: "Brainstorm", hint: "yes-and" },
4207
4498
  { v: "constructive", label: "Constructive", hint: "push & sharpen" },
4499
+ { v: "research", label: "Research", hint: "mine the material" },
4208
4500
  { v: "debate", label: "Debate", hint: "find the holes" },
4209
- { v: "no-mercy", label: "No Mercy", hint: "tear apart" },
4501
+ { v: "critique", label: "Critique", hint: "audit the deliverable" },
4210
4502
  ];
4211
4503
  current = state.mode;
4212
4504
  } else if (kind === "intensity") {
@@ -4371,6 +4663,11 @@
4371
4663
  intensity: state.intensity,
4372
4664
  autoPick: useAutoPick,
4373
4665
  });
4666
+ // Clear the saved draft now that the room is convened — next
4667
+ // visit to "+ New Room" should land on a fresh textarea, not
4668
+ // re-show the just-submitted subject.
4669
+ state.subject = "";
4670
+ this.saveComposerState();
4374
4671
  } catch (e) {
4375
4672
  if (btn) btn.classList.remove("busy");
4376
4673
  alert("Couldn't convene: " + (e && e.message ? e.message : e));
@@ -4391,13 +4688,15 @@
4391
4688
  if (want.length) state.directorIds = want;
4392
4689
  if (q.tone) state.mode = q.tone;
4393
4690
  if (q.intensity) state.intensity = q.intensity;
4691
+ // Write the starter text into the persisted draft so it survives
4692
+ // a navigation away and back, just like manual typing does.
4693
+ state.subject = q.text || "";
4394
4694
  this.saveComposerState();
4395
4695
  // Re-render to reflect the new selections; keep autofocus at end of subject.
4396
4696
  this.renderEmptyState();
4397
4697
  setTimeout(() => {
4398
4698
  const ta = document.querySelector("[data-composer-subject]");
4399
4699
  if (ta) {
4400
- ta.value = q.text || "";
4401
4700
  ta.focus();
4402
4701
  ta.setSelectionRange(ta.value.length, ta.value.length);
4403
4702
  this.autosizeComposerTextarea();
@@ -5380,8 +5679,14 @@
5380
5679
  ? `<div class="msg-chair-pick" title="Chair picked ${this.escape(name)} for this turn">▸ chair · ${this.escape(chairPick)}</div>`
5381
5680
  : "";
5382
5681
 
5682
+ // data-author-id is only attached for director messages (not user
5683
+ // / not chair) — read by quote-cta.js to credit the director when
5684
+ // the user probes / seconds a passage from this bubble.
5685
+ const authorIdAttr = (!isUser && !isChair && author?.id)
5686
+ ? ` data-author-id="${this.escape(author.id)}"`
5687
+ : "";
5383
5688
  return `
5384
- <article class="msg ${baseCls}${stateCls.length ? " " + stateCls.join(" ") : ""}" data-message-id="${this.escape(m.id)}" data-meta-kind="${this.escape(metaKind || "")}">
5689
+ <article class="msg ${baseCls}${stateCls.length ? " " + stateCls.join(" ") : ""}" data-message-id="${this.escape(m.id)}" data-meta-kind="${this.escape(metaKind || "")}"${authorIdAttr}>
5385
5690
  ${avatarHtml}
5386
5691
  <div class="msg-content">
5387
5692
  ${chairPickKicker}
@@ -5529,7 +5834,7 @@
5529
5834
  }
5530
5835
 
5531
5836
  // Update the collapsed summary alongside the expanded list so the
5532
- // collapsed strip never shows stale prototype text.
5837
+ // collapsed strip never shows stale text.
5533
5838
  this.renderQueueCollapsed(renderItems);
5534
5839
 
5535
5840
  if (renderItems.length === 0) {
@@ -5611,14 +5916,32 @@
5611
5916
  card.classList.add("ending-block");
5612
5917
  const b = this.currentBrief;
5613
5918
 
5614
- // Error path: a compact error card with a retry button. Two
5919
+ // Error path: a compact error card with a retry button. Three
5615
5920
  // sub-cases:
5921
+ // · timedOut (no completion after 5 min wall-clock) → "took
5922
+ // too long" copy with the elapsed-time reason inline
5616
5923
  // · interrupted (zombie placeholder from a refresh / restart) →
5617
5924
  // specific copy + Regenerate CTA
5618
5925
  // · generic LLM failure → original "needs an API key" hint
5619
5926
  if (b.error) {
5620
5927
  const lang = (b.language === "zh" || (this.currentRoom?.subject && /[一-鿿]/.test(this.currentRoom.subject))) ? "zh" : "en";
5621
- const copy = b.interrupted
5928
+ const copy = b.timedOut
5929
+ ? (lang === "zh"
5930
+ ? {
5931
+ stamp: "timed out",
5932
+ kicker: "// 报告生成超时",
5933
+ detail: "已超过 5 分钟仍未收到完成信号 · 可能是模型回应过慢、网络中断,或后端流水线卡住了。点击下方按钮重试,或检查 LLM key 与网络后再试。",
5934
+ hint: "",
5935
+ cta: "重试",
5936
+ }
5937
+ : {
5938
+ stamp: "timed out",
5939
+ kicker: "// generation timed out",
5940
+ detail: "No completion signal after 5 minutes — the model may be slow, the connection dropped, or the pipeline stalled. Click below to start a fresh run.",
5941
+ hint: "",
5942
+ cta: "Retry",
5943
+ })
5944
+ : b.interrupted
5622
5945
  ? (lang === "zh"
5623
5946
  ? {
5624
5947
  stamp: "interrupted",
@@ -5658,7 +5981,7 @@
5658
5981
  <div class="brief-body brief-body-error">
5659
5982
  <div class="brief-kicker" style="color: var(--red);">${this.escape(copy.kicker)}</div>
5660
5983
  <div class="brief-meta-line" style="color: var(--text-soft); text-transform: none; letter-spacing: 0;">
5661
- ${b.interrupted ? this.escape(copy.detail) : copy.detail}
5984
+ ${(b.interrupted || b.timedOut) ? this.escape(copy.detail) : copy.detail}
5662
5985
  </div>
5663
5986
  ${copy.hint ? `<div class="brief-meta-line" style="margin-top: 14px; text-transform: none; letter-spacing: 0;">${copy.hint}</div>` : ""}
5664
5987
  <div class="brief-error-actions">
@@ -5726,7 +6049,7 @@
5726
6049
  ` : "";
5727
6050
 
5728
6051
  // Ceremonial wrapper · the deliverable hits the table inside an
5729
- // ending-block frame, mirroring the prototype.
6052
+ // ending-block frame.
5730
6053
  card.innerHTML = `
5731
6054
  <header class="ending-block-head">
5732
6055
  <span class="ending-block-line"></span>
@@ -5888,6 +6211,84 @@
5888
6211
  }
5889
6212
  },
5890
6213
 
6214
+ /* ─── Brief stall watcher ─────────────────────────────────────
6215
+ Surfaces the Retry CTA promptly when generation stalls or
6216
+ times out — the user no longer has to leave + re-enter the
6217
+ room to discover a dead pipeline. Two safety nets:
6218
+
6219
+ · Stall poll · if no brief-* SSE event arrives for
6220
+ BRIEF_STALL_POLL_MS, ask /api/briefs/<id>/status. The
6221
+ server flips to !generating + !hasBody when the pipeline
6222
+ crashed mid-flight; checkBriefHealth (re-used) renders
6223
+ that as the existing "interrupted" error.
6224
+
6225
+ · Hard timeout · after BRIEF_HARD_TIMEOUT_MS of total
6226
+ wall-clock with no brief-final, force a `timedOut` error
6227
+ locally so Retry appears regardless of server-side state
6228
+ (covers SSE drops + LLM black-holes alike). */
6229
+ BRIEF_STALL_POLL_MS: 60_000,
6230
+ BRIEF_HARD_TIMEOUT_MS: 5 * 60_000,
6231
+ BRIEF_WATCH_INTERVAL_MS: 10_000,
6232
+
6233
+ markBriefEvent() {
6234
+ this._lastBriefEventAt = Date.now();
6235
+ },
6236
+
6237
+ ensureBriefStallWatch() {
6238
+ if (this._briefStallWatchTimer) return;
6239
+ const b = this.currentBrief;
6240
+ if (!b || !b.id || b.error) return;
6241
+ const generating = !b.bodyMd || b.title === "Generating…";
6242
+ if (!generating) return;
6243
+ if (!this._lastBriefEventAt) this._lastBriefEventAt = Date.now();
6244
+ this._lastBriefHealthPollAt = 0;
6245
+ this._briefStallWatchTimer = setInterval(
6246
+ () => this.tickBriefStallWatch(),
6247
+ this.BRIEF_WATCH_INTERVAL_MS,
6248
+ );
6249
+ },
6250
+
6251
+ stopBriefStallWatch() {
6252
+ if (this._briefStallWatchTimer) {
6253
+ clearInterval(this._briefStallWatchTimer);
6254
+ this._briefStallWatchTimer = null;
6255
+ }
6256
+ },
6257
+
6258
+ async tickBriefStallWatch() {
6259
+ const b = this.currentBrief;
6260
+ if (!b || b.error) { this.stopBriefStallWatch(); return; }
6261
+ const generating = !b.bodyMd || b.title === "Generating…";
6262
+ if (!generating) { this.stopBriefStallWatch(); return; }
6263
+
6264
+ const now = Date.now();
6265
+ const startedAt = b.pipelineStartedAt || this._lastBriefEventAt || now;
6266
+
6267
+ // Hard ceiling · regardless of server state, flip the card to
6268
+ // a timed-out error so the user always has a way out.
6269
+ if (now - startedAt > this.BRIEF_HARD_TIMEOUT_MS) {
6270
+ b.error = b.language === "zh"
6271
+ ? "报告生成超时(超过 5 分钟仍未完成)。"
6272
+ : "Brief generation timed out (no completion after 5 minutes).";
6273
+ b.timedOut = true;
6274
+ this.stopBriefStageTick();
6275
+ this.stopBriefStallWatch();
6276
+ this.renderBrief();
6277
+ return;
6278
+ }
6279
+
6280
+ // Soft stall · poll the server at most once per STALL_POLL_MS
6281
+ // while we're not hearing anything. checkBriefHealth flips the
6282
+ // card to "interrupted" if the server has already given up.
6283
+ const lastEvt = this._lastBriefEventAt || startedAt;
6284
+ const elapsedSinceEvt = now - lastEvt;
6285
+ const pollGap = now - (this._lastBriefHealthPollAt || 0);
6286
+ if (elapsedSinceEvt > this.BRIEF_STALL_POLL_MS && pollGap > this.BRIEF_STALL_POLL_MS) {
6287
+ this._lastBriefHealthPollAt = now;
6288
+ await this.checkBriefHealth(b);
6289
+ }
6290
+ },
6291
+
5891
6292
  /** Render the 3-stage checklist shown while the brief is generating.
5892
6293
  * Each row pulses while active, gets a check when done. The active
5893
6294
  * row also shows:
@@ -5948,7 +6349,12 @@
5948
6349
  : null;
5949
6350
  const eta = serverEta || meta[key]?.eta;
5950
6351
  const startedAt = st.startedAt;
5951
- const elapsedSec = startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0;
6352
+ // Done stages freeze at finishedAt so the displayed duration
6353
+ // is the actual time the stage took, not "current time minus
6354
+ // when it started" (which would keep ticking after completion).
6355
+ // Active stages still use Date.now() so the counter animates.
6356
+ const endRef = (status === "done" && st.finishedAt) ? st.finishedAt : Date.now();
6357
+ const elapsedSec = startedAt ? Math.max(0, Math.floor((endRef - startedAt) / 1000)) : 0;
5952
6358
 
5953
6359
  // Detail line · numeric progress (extract counter, write word
5954
6360
  // count) takes priority. ETA / elapsed shown in a separate slot.
@@ -6105,6 +6511,38 @@
6105
6511
  });
6106
6512
  });
6107
6513
  },
6514
+
6515
+ /** Bring the brief card into view at the top of the chat panel.
6516
+ * Called whenever the user has just triggered a generation
6517
+ * (Adjourn → file brief, Regenerate, Retry, post-hoc generate)
6518
+ * so they see the "Generating…" state appear immediately —
6519
+ * without this, a user who scrolled up to re-read history sees
6520
+ * no visible response to their click. Smooth-scrolls the .chat
6521
+ * container ONLY (not the page), aligning the card's top a bit
6522
+ * below the chat's top so the stage tracker is fully visible. */
6523
+ scrollToBriefCard() {
6524
+ // Two rAFs · let renderBrief paint + layout settle before measuring.
6525
+ requestAnimationFrame(() => {
6526
+ requestAnimationFrame(() => {
6527
+ const chat = document.querySelector(".chat");
6528
+ const card = document.querySelector("[data-brief-card]");
6529
+ if (!chat || !card) return;
6530
+ // Skip the scroll if the card is already comfortably on
6531
+ // screen — no need to nudge a user who's looking right at it.
6532
+ const cardRect = card.getBoundingClientRect();
6533
+ const chatRect = chat.getBoundingClientRect();
6534
+ const alreadyVisible =
6535
+ cardRect.top >= chatRect.top &&
6536
+ cardRect.top <= chatRect.top + chat.clientHeight * 0.5;
6537
+ if (alreadyVisible) return;
6538
+ const offset = card.offsetTop - chat.offsetTop - 16;
6539
+ chat.scrollTo({ top: Math.max(0, offset), behavior: "smooth" });
6540
+ // Reading the latest content again counts as "following the
6541
+ // feed" for subsequent token-stream auto-scroll decisions.
6542
+ this.chatStuckToBottom = true;
6543
+ });
6544
+ });
6545
+ },
6108
6546
  };
6109
6547
 
6110
6548
  // ── DOM-level wiring (delegated; survives re-renders) ──────
@@ -6155,6 +6593,14 @@
6155
6593
  app.resumeRoom().catch((err) => alert("Resume failed: " + err.message));
6156
6594
  return;
6157
6595
  }
6596
+ // Export · adjourned-bar action. Browser handles the download
6597
+ // natively from the route's Content-Disposition header.
6598
+ if (e.target.closest("[data-room-export]")) {
6599
+ e.preventDefault();
6600
+ if (!app.currentRoomId) return;
6601
+ window.location.href = "/api/rooms/" + encodeURIComponent(app.currentRoomId) + "/export.md";
6602
+ return;
6603
+ }
6158
6604
  // Generate report (post-hoc) — fires from the no-brief card CTA
6159
6605
  // when the user originally skipped the brief but now wants one.
6160
6606
  // Reuses the adjourn overlay's gallery in "generate-brief" mode.
@@ -6240,6 +6686,24 @@
6240
6686
  app.submitSupplement();
6241
6687
  return;
6242
6688
  }
6689
+ // Paused-bar · open the supplement overlay (add a thought while paused).
6690
+ if (e.target.closest("[data-paused-supplement]")) {
6691
+ e.preventDefault();
6692
+ app.openPausedSupplementOverlay();
6693
+ return;
6694
+ }
6695
+ // Paused-supplement overlay · close / cancel / backdrop.
6696
+ if (e.target.closest("[data-paused-supplement-close]")) {
6697
+ e.preventDefault();
6698
+ app.closePausedSupplementOverlay();
6699
+ return;
6700
+ }
6701
+ // Paused-supplement overlay · confirm.
6702
+ if (e.target.closest("[data-paused-supplement-confirm]")) {
6703
+ e.preventDefault();
6704
+ app.submitPausedSupplement();
6705
+ return;
6706
+ }
6243
6707
  // Continue · resume the directors after a chair-driven round-end.
6244
6708
  if (e.target.closest("[data-continue]")) {
6245
6709
  e.preventDefault();
@@ -6507,11 +6971,19 @@
6507
6971
  e.preventDefault();
6508
6972
  app.submitFromComposer(target);
6509
6973
  });
6510
- // Autosize the composer textarea as the user types.
6974
+ // Autosize the composer textarea as the user types · also persist
6975
+ // the in-progress draft so switching to another view and coming back
6976
+ // restores the user's text instead of wiping it (each renderEmptyState
6977
+ // rebuilds the textarea node, so the DOM-level value vanishes; the
6978
+ // saved-state path is what survives the re-render).
6511
6979
  document.addEventListener("input", (e) => {
6512
6980
  if (e.target && e.target.matches && e.target.matches("[data-composer-subject]")) {
6981
+ const state = app.loadComposerState();
6982
+ state.subject = e.target.value;
6983
+ app.saveComposerState();
6513
6984
  app.autosizeComposerTextarea();
6514
6985
  } else if (e.target && e.target.matches && e.target.matches("[data-agent-composer-desc]")) {
6986
+ app.saveAgentComposerDraft(e.target.value);
6515
6987
  app.autosizeAgentComposerTextarea();
6516
6988
  }
6517
6989
  });
@@ -6619,6 +7091,21 @@
6619
7091
  }
6620
7092
  });
6621
7093
 
7094
+ // When the tab becomes visible again, immediately probe a stalled
7095
+ // brief — the user may have switched away during a long generation
7096
+ // and the throttling sleeps held the watch back. The watcher itself
7097
+ // also keeps ticking on its 10s interval as a backstop.
7098
+ document.addEventListener("visibilitychange", () => {
7099
+ if (document.hidden) return;
7100
+ if (app && app.currentBrief && !app.currentBrief.error) {
7101
+ const generating = !app.currentBrief.bodyMd || app.currentBrief.title === "Generating…";
7102
+ if (generating) {
7103
+ app.ensureBriefStallWatch();
7104
+ app.tickBriefStallWatch();
7105
+ }
7106
+ }
7107
+ });
7108
+
6622
7109
  window.app = app;
6623
7110
 
6624
7111
  if (document.readyState === "loading") {