privateboard 0.1.6 → 0.1.8

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
@@ -292,20 +292,14 @@
292
292
  return;
293
293
  }
294
294
 
295
- const lang = (this.composerLanguage && this.composerLanguage()) || "en";
296
295
  const count = fresh.length;
297
296
  const names = fresh.map((m) => m.name).join(", ");
298
- const copy = lang === "zh"
299
- ? {
300
- head: `存储结构已升级`,
301
- body: `已应用 ${count} 个新迁移 · 你已有的房间、董事、报告、设置都已保留。`,
302
- tooltip: names,
303
- }
304
- : {
305
- head: `Storage upgraded`,
306
- body: `${count} new migration${count > 1 ? "s" : ""} applied · your existing rooms, agents, briefs, and settings were preserved.`,
307
- tooltip: names,
308
- };
297
+ // System UI · always English (storage-migration banner).
298
+ const copy = {
299
+ head: `Storage upgraded`,
300
+ body: `${count} new migration${count > 1 ? "s" : ""} applied · your existing rooms, agents, briefs, and settings were preserved.`,
301
+ tooltip: names,
302
+ };
309
303
  textEl.innerHTML =
310
304
  `<span class="sys-notice-strong">${this.escape(copy.head)}</span> · ${this.escape(copy.body)}`;
311
305
  banner.title = copy.tooltip;
@@ -783,6 +777,10 @@
783
777
  msg.body += data.delta;
784
778
  this.updateMessageBodyDom(data.messageId, msg.body, true);
785
779
  this.scrollChatToBottom();
780
+ // Typing-SFX presence cue · the module throttles internally,
781
+ // mutes when the tab is backgrounded, and respects the user's
782
+ // toggle in Preference → User. Safe to call on every chunk.
783
+ window.boardroomTypingSfx && window.boardroomTypingSfx.tick();
786
784
  });
787
785
 
788
786
  this.sse.addEventListener("message-final", (e) => {
@@ -914,11 +912,37 @@
914
912
  }
915
913
  } else if (kind === "brief-started") {
916
914
  this.markBriefEvent();
915
+ // Mode determines BOTH the seeded stage map and which pip
916
+ // rail renderBriefStages picks. Bento, magazine, and
917
+ // newspaper modes each emit only 2 stage events (extract
918
+ // + write), so seeding the 7-stage layout for a
919
+ // structured-mode brief leaves the 5 middle pips forever
920
+ // pending — the rail then renders with a row of dead
921
+ // black pips between extract and write. Read mode from
922
+ // the payload (server includes it on brief-started for
923
+ // exactly this reason) and seed accordingly.
924
+ const isStructured = this.isStructuredBriefMode(payload.mode);
925
+ const briefMode = isStructured ? payload.mode : "research-note";
926
+ const seededStages = isStructured
927
+ ? {
928
+ extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
929
+ write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
930
+ }
931
+ : {
932
+ extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
933
+ compose: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
934
+ "scaffold-anchor": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
935
+ "scaffold-findings": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
936
+ "scaffold-cluster": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
937
+ "scaffold-actions": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
938
+ write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
939
+ };
917
940
  const newBrief = {
918
941
  id: payload.briefId,
919
942
  title: "Generating…",
920
943
  bodyMd: "",
921
944
  style: payload.style || "mckinsey",
945
+ mode: briefMode,
922
946
  // Carry the supplement so the tab strip can render the
923
947
  // in-progress brief with its supplement label ("xxx 视角")
924
948
  // immediately, instead of waiting for brief-final to
@@ -933,21 +957,13 @@
933
957
  language: payload.language === "zh" ? "zh" : "en",
934
958
  pipelineStartedAt: Date.now(),
935
959
  createdAt: Date.now(),
936
- // Stage checklist · seeded with all seven stages in pending
937
- // state (extract / compose / 4 scaffold sub-stages / write).
938
- // brief-stage events flip them active → done as the pipeline
939
- // progresses. startedAt is captured when each stage first
940
- // becomes active so the UI can display elapsed time
941
- // alongside the ETA range.
942
- stages: {
943
- extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
944
- compose: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
945
- "scaffold-anchor": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
946
- "scaffold-findings": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
947
- "scaffold-cluster": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
948
- "scaffold-actions": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
949
- write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
950
- },
960
+ // Stage checklist · seeded according to the brief mode (see
961
+ // briefMode resolution above). brief-stage events flip
962
+ // them active → done as the pipeline progresses.
963
+ // startedAt is captured when each stage first becomes
964
+ // active so the UI can display elapsed time alongside the
965
+ // ETA range.
966
+ stages: seededStages,
951
967
  };
952
968
  this.currentBrief = newBrief;
953
969
  // Insert the in-progress brief into currentBriefs so the tab
@@ -1078,6 +1094,14 @@
1078
1094
  }
1079
1095
  }
1080
1096
  }
1097
+ // No typing-SFX during the brief writer's Stage 3 stream.
1098
+ // The write phase is a long, off-screen streaming pass (the
1099
+ // user is usually reading prior content or doing something
1100
+ // else while the report builds) — a continuous click track
1101
+ // for tens of seconds reads as background noise, not a
1102
+ // presence cue. Director / chair turns still tick because
1103
+ // those land in the active conversation the user is
1104
+ // following.
1081
1105
  } else if (kind === "brief-final") {
1082
1106
  this.markBriefEvent();
1083
1107
  const target = this._briefById(payload.briefId);
@@ -1482,6 +1506,192 @@
1482
1506
  return false;
1483
1507
  },
1484
1508
 
1509
+ /** Has the brief got its body content yet? Mode-aware check ·
1510
+ * research-note briefs land their content in `bodyMd`; bento
1511
+ * briefs land theirs in `bodyJson` and leave bodyMd empty. The
1512
+ * card-rendering / placeholder-detection / generating-check
1513
+ * logic uses this everywhere so a bento brief doesn't stay
1514
+ * stuck in "generating…" state after the BentoScaffold has
1515
+ * actually been persisted.
1516
+ *
1517
+ * Without this helper, a successful bento generation followed
1518
+ * by a page refresh hid the View Report button forever (the
1519
+ * card thought it was still generating because bodyMd was empty
1520
+ * — which is the steady-state shape for bento, not an
1521
+ * in-flight signal). */
1522
+ /** True for any structured-output mode — bento, magazine,
1523
+ * newspaper — that persists to body_json instead of body_md and
1524
+ * runs the single-pass chair-LLM pipeline (extract + write only,
1525
+ * no Stage 2/3 scaffold/write). Centralised so future modes
1526
+ * touch one place. */
1527
+ isStructuredBriefMode(mode) {
1528
+ return mode === "bento" || mode === "magazine" || mode === "newspaper";
1529
+ },
1530
+
1531
+ briefHasBody(b) {
1532
+ if (!b) return false;
1533
+ if (this.isStructuredBriefMode(b.mode)) {
1534
+ // Bento + magazine populate bodyJson with the BentoScaffold.
1535
+ // An empty object isn't a body; check for at least a title
1536
+ // field.
1537
+ const j = b.bodyJson;
1538
+ return !!(j && typeof j === "object" && (j.title || j.milestones));
1539
+ }
1540
+ // Default research-note · body lives in markdown.
1541
+ return !!(b.bodyMd && b.bodyMd.trim());
1542
+ },
1543
+
1544
+ /** Build the right viewer URL for a brief based on its mode.
1545
+ * · 'research-note' (default · or unknown) → `/report.html?r=R&b=B`
1546
+ * · 'bento' → `/bento.html?b=B`
1547
+ * · 'magazine' → `/magazine.html?b=B`
1548
+ * · 'newspaper' → `/newspaper.html?b=B`
1549
+ *
1550
+ * Structured renderers don't need the room context · the brief
1551
+ * is self-contained. Used by the View Report button, the
1552
+ * brief-picker popover, the open-report link in the brief card,
1553
+ * and the All Reports page — one helper so a future renderer
1554
+ * route change touches one place. */
1555
+ briefViewerHref(b, roomId) {
1556
+ if (!b || !b.id) return null;
1557
+ const id = encodeURIComponent(b.id);
1558
+ if (b.mode === "bento") return `/bento.html?b=${id}`;
1559
+ if (b.mode === "magazine") return `/magazine.html?b=${id}`;
1560
+ if (b.mode === "newspaper") return `/newspaper.html?b=${id}`;
1561
+ const r = roomId ? encodeURIComponent(roomId) : "";
1562
+ return r ? `/report.html?r=${r}&b=${id}` : `/report.html?b=${id}`;
1563
+ },
1564
+
1565
+ /** Human-readable label for a brief's mode · used in the brief-card
1566
+ * banner so the user sees which renderer the report will use
1567
+ * before they open it.
1568
+ *
1569
+ * IMPORTANT · the user-facing label for `research-note` is
1570
+ * "Report", NOT "Research Note". The internal mode key stays as
1571
+ * `research-note` (storage column, API value, picker option id),
1572
+ * but every user-facing surface — the mode picker (`renderBrief-
1573
+ * ModePicker` line ~1662 in this file), the report viewer's
1574
+ * header, etc. — calls it "Report". Keep this map in sync with
1575
+ * the picker labels rather than inventing a fresh user-facing
1576
+ * vocabulary.
1577
+ *
1578
+ * Legacy rows without an explicit `mode` default to "Report" by
1579
+ * the same backfill the storage layer applies. */
1580
+ briefModeLabel(b) {
1581
+ const mode = (b && b.mode) || "research-note";
1582
+ switch (mode) {
1583
+ case "bento": return "Bento";
1584
+ case "magazine": return "Magazine";
1585
+ case "newspaper": return "Newspaper";
1586
+ default: return "Report";
1587
+ }
1588
+ },
1589
+
1590
+ /** Render the report-mode picker · four icon-tile cards that let
1591
+ * the user pick between research-note, bento, magazine, and
1592
+ * newspaper. Each tile shows a custom monoline SVG that
1593
+ * visually mirrors that mode's layout — research-note as a
1594
+ * memo, bento as a 4-cell grid, magazine as a masthead +
1595
+ * numeral block + small cards, newspaper as a banner + 3-column
1596
+ * text grid. The native radio is visually hidden (kept in the
1597
+ * DOM for a11y / keyboard nav).
1598
+ *
1599
+ * Used by both the adjourn overlay (filing the report at
1600
+ * adjourn-time / generate-brief flow) AND the supplement
1601
+ * overlay (regenerating with an extra perspective). The picker
1602
+ * reads its selected option via `input[name="brief-mode"]:checked`
1603
+ * from inside whatever overlay it lives in · the click-to-toggle
1604
+ * handler scopes to the `.adjourn-mode-options` container so it
1605
+ * works for any overlay embedding the picker. */
1606
+ renderBriefModePicker(defaultMode) {
1607
+ const safe = this.isStructuredBriefMode(defaultMode) ? defaultMode : "research-note";
1608
+ // Icon SVGs · `currentColor` lets each tile pick up its hover /
1609
+ // selected accent from the parent `.adjourn-mode-icon { color }`
1610
+ // rule. Stroke hierarchy is deliberate · main outlines at 1.0,
1611
+ // a single emphasis rule at 1.2 (newspaper headline + magazine
1612
+ // masthead bar), body details at 0.8. Filled blocks soften to
1613
+ // currentColor at 0.5 opacity so the lime selected-state reads
1614
+ // as a tint rather than a slab. The 24-unit viewBox renders at
1615
+ // 36px on screen → 1.5× scale, so stroke-1.0 is a clean 1.5px.
1616
+ const ICONS = {
1617
+ "research-note": `
1618
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1619
+ <path d="M6 3h9l4 4v14H6z"/>
1620
+ <path d="M15 3v4h4"/>
1621
+ <line x1="9" y1="12" x2="16" y2="12"/>
1622
+ <line x1="9" y1="15" x2="16" y2="15"/>
1623
+ <line x1="9" y1="18" x2="14" y2="18"/>
1624
+ </svg>`,
1625
+ "bento": `
1626
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1627
+ <rect x="3" y="3" width="9" height="13" rx="1.5"/>
1628
+ <rect x="13" y="3" width="8" height="6" rx="1.5"/>
1629
+ <rect x="13" y="10" width="8" height="6" rx="1.5"/>
1630
+ <rect x="3" y="17" width="18" height="4" rx="1.5"/>
1631
+ </svg>`,
1632
+ "magazine": `
1633
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1634
+ <line x1="3" y1="4" x2="21" y2="4" stroke-width="1.2"/>
1635
+ <rect x="3" y="7" width="6" height="9" rx="1.2"/>
1636
+ <rect x="11" y="7" width="4.5" height="4" rx="1.2"/>
1637
+ <rect x="16.5" y="7" width="4.5" height="4" rx="1.2"/>
1638
+ <rect x="11" y="12" width="4.5" height="4" rx="1.2"/>
1639
+ <rect x="16.5" y="12" width="4.5" height="4" rx="1.2"/>
1640
+ <rect x="3" y="18" width="18" height="3" rx="1" fill="currentColor" fill-opacity="0.45" stroke="none"/>
1641
+ </svg>`,
1642
+ "newspaper": `
1643
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1644
+ <rect x="3" y="3" width="18" height="2.5" fill="currentColor" fill-opacity="0.85" stroke="none"/>
1645
+ <line x1="7" y1="8" x2="17" y2="8" stroke-width="1.2"/>
1646
+ <line x1="4" y1="11.5" x2="8" y2="11.5" stroke-width="0.8"/>
1647
+ <line x1="4" y1="13.5" x2="8" y2="13.5" stroke-width="0.8"/>
1648
+ <line x1="4" y1="15.5" x2="6.5" y2="15.5" stroke-width="0.8"/>
1649
+ <line x1="10" y1="11.5" x2="14" y2="11.5" stroke-width="0.8"/>
1650
+ <line x1="10" y1="13.5" x2="14" y2="13.5" stroke-width="0.8"/>
1651
+ <line x1="10" y1="15.5" x2="12.5" y2="15.5" stroke-width="0.8"/>
1652
+ <line x1="16" y1="11.5" x2="20" y2="11.5" stroke-width="0.8"/>
1653
+ <line x1="16" y1="13.5" x2="20" y2="13.5" stroke-width="0.8"/>
1654
+ <line x1="16" y1="15.5" x2="18.5" y2="15.5" stroke-width="0.8"/>
1655
+ <line x1="3" y1="18.5" x2="21" y2="18.5" stroke-width="0.9"/>
1656
+ </svg>`,
1657
+ };
1658
+ const opt = (value, title, deck, primary) => `
1659
+ <label class="adjourn-mode-option${primary ? " adjourn-mode-option-primary" : ""}${safe === value ? " on" : ""}">
1660
+ <input type="radio" name="brief-mode" value="${value}"${safe === value ? " checked" : ""}>
1661
+ <div class="adjourn-mode-icon">${ICONS[value] || ""}</div>
1662
+ <div class="adjourn-mode-body">
1663
+ <div class="adjourn-mode-title">${title}</div>
1664
+ <div class="adjourn-mode-deck">${deck}</div>
1665
+ </div>
1666
+ </label>`;
1667
+ return `
1668
+ <div class="adjourn-mode-picker" data-mode-picker>
1669
+ <div class="adjourn-mode-label">// report format</div>
1670
+ <div class="adjourn-mode-options adjourn-mode-options-4">
1671
+ ${opt("research-note", "Report", "Long-form markdown · bottom line, findings, recommendations.", true)}
1672
+ ${opt("bento", "Bento", "Single-page poster · 3 milestones + sidebar cards.")}
1673
+ ${opt("magazine", "Magazine", "Editorial spread · cover line, 5 cards, dark closer.")}
1674
+ ${opt("newspaper", "Newspaper", "Broadsheet · banner masthead, 3-column editorial.")}
1675
+ </div>
1676
+ </div>`;
1677
+ },
1678
+
1679
+ /** Read the user's last-picked report mode from localStorage so the
1680
+ * picker defaults to their previous choice. Falls back to
1681
+ * 'research-note' on first run / private mode. */
1682
+ lastBriefMode() {
1683
+ try {
1684
+ const v = localStorage.getItem("pb.briefMode");
1685
+ return this.isStructuredBriefMode(v) ? v : "research-note";
1686
+ } catch { return "research-note"; }
1687
+ },
1688
+ saveLastBriefMode(mode) {
1689
+ try {
1690
+ const safe = this.isStructuredBriefMode(mode) ? mode : "research-note";
1691
+ localStorage.setItem("pb.briefMode", safe);
1692
+ } catch { /* private-mode etc. — silently ignore */ }
1693
+ },
1694
+
1485
1695
  /** Pure cache read · returns true when the local app.keys map
1486
1696
  * reports any model provider as configured. Used by the gate
1487
1697
  * and (via wrappers) anywhere downstream UI needs the answer
@@ -1498,24 +1708,15 @@
1498
1708
  * visual treatment stays consistent. */
1499
1709
  openNoKeyModal() {
1500
1710
  this.closeNoKeyModal();
1501
- const lang = (this.prefs && /[一-鿿]/.test(this.prefs.name || this.prefs.intro || "")) ? "zh" : "en";
1502
- const t = lang === "zh"
1503
- ? {
1504
- title: "需要配置模型 API key",
1505
- deck: "Boardroom 的董事和主席都依赖大模型。请先配置一个模型供应商的 API key(Anthropic / OpenAI / Google / xAI / DeepSeek / OpenRouter),任意一个即可。",
1506
- primary: "[ 打开设置 ]",
1507
- dismiss: "[ 取消 ]",
1508
- classification: " ai · 缺少模型 key",
1509
- tag: "▸ 配置 API key",
1510
- }
1511
- : {
1512
- title: "Configure a model API key",
1513
- deck: "The chair and directors run on a large language model. Configure at least one provider key (Anthropic / OpenAI / Google / xAI / DeepSeek / OpenRouter) before AI features will work.",
1514
- primary: "[ Open settings ▸ ]",
1515
- dismiss: "[ Dismiss ]",
1516
- classification: "● ai · no model key",
1517
- tag: "▸ Configure API key",
1518
- };
1711
+ // System UI · always English. No-key modal is app chrome.
1712
+ const t = {
1713
+ title: "Configure a model API key",
1714
+ deck: "The chair and directors run on a large language model. Configure at least one provider key (Anthropic / OpenAI / Google / xAI / DeepSeek / OpenRouter) before AI features will work.",
1715
+ primary: "[ Open settings ]",
1716
+ dismiss: "[ Dismiss ]",
1717
+ classification: " ai · no model key",
1718
+ tag: " Configure API key",
1719
+ };
1519
1720
  const html = `
1520
1721
  <div id="no-key-overlay" class="pc-overlay" data-no-key-overlay>
1521
1722
  <div class="pc-modal">
@@ -1531,7 +1732,7 @@
1531
1732
  <div class="pc-body">
1532
1733
  <button type="button" class="pc-choice primary" data-no-key-open-settings>
1533
1734
  <div class="pc-choice-mark">${this.escape(t.primary)}</div>
1534
- <div class="pc-choice-deck">${lang === "zh" ? "进入 Preferences → API Key 配置任意一个模型供应商。" : "Jump to Preferences → API Key. Paste any one provider's key to unlock the room."}</div>
1735
+ <div class="pc-choice-deck">Jump to Preferences → API Key. Paste any one provider's key to unlock the room.</div>
1535
1736
  </button>
1536
1737
  <button type="button" class="pc-choice ghost" data-no-key-dismiss>
1537
1738
  <div class="pc-choice-mark">${this.escape(t.dismiss)}</div>
@@ -1654,6 +1855,9 @@
1654
1855
  async adjournRoom(opts) {
1655
1856
  if (!this.currentRoomId) return;
1656
1857
  const skipBrief = !!(opts && opts.skipBrief);
1858
+ const mode = opts && this.isStructuredBriefMode(opts.mode)
1859
+ ? opts.mode
1860
+ : "research-note";
1657
1861
  // Pre-flight · adjourn-with-brief triggers the brief writer
1658
1862
  // pipeline (3 LLM stages). When skipBrief=true the chair just
1659
1863
  // posts the no-brief marker without LLM calls, so we let it
@@ -1664,7 +1868,7 @@
1664
1868
  {
1665
1869
  method: "POST",
1666
1870
  headers: { "content-type": "application/json" },
1667
- body: JSON.stringify(skipBrief ? { skipBrief: true } : {}),
1871
+ body: JSON.stringify(skipBrief ? { skipBrief: true } : { mode }),
1668
1872
  },
1669
1873
  );
1670
1874
  if (!r.ok) {
@@ -1698,6 +1902,10 @@
1698
1902
  const confirmTxt = isGen ? "[ Generate ]" : "[ Adjourn & file ]";
1699
1903
  const subjectTxt = room.subject || room.name || "—";
1700
1904
  const memberCount = (this.currentMembers || []).length;
1905
+ // Default the mode picker to whatever the user picked last. Bento
1906
+ // is opt-in · 'research-note' is the safe initial default for new
1907
+ // users / private-mode browsers (where localStorage is empty).
1908
+ const defaultMode = this.lastBriefMode();
1701
1909
  const html = `
1702
1910
  <div class="adjourn-overlay" id="adjourn-overlay" role="dialog" aria-modal="true" data-adjourn-mode="${this.escape(mode)}">
1703
1911
  <div class="adjourn-backdrop" data-adjourn-close></div>
@@ -1718,9 +1926,12 @@
1718
1926
 
1719
1927
  <div class="adjourn-body">
1720
1928
  <div class="adjourn-summary">
1721
- <div class="adjourn-summary-row">
1929
+ <div class="adjourn-summary-row adjourn-summary-row-subject">
1722
1930
  <span class="adjourn-summary-key">// subject</span>
1723
- <span class="adjourn-summary-val">${this.escape(subjectTxt)}</span>
1931
+ <div class="adjourn-summary-val adjourn-subject-wrap">
1932
+ <span class="adjourn-subject-text is-clamped" data-adjourn-subject>${this.escape(subjectTxt)}</span>
1933
+ <button type="button" class="adjourn-subject-toggle" data-adjourn-subject-toggle hidden>Show more</button>
1934
+ </div>
1724
1935
  </div>
1725
1936
  <div class="adjourn-summary-row">
1726
1937
  <span class="adjourn-summary-key">// authors</span>
@@ -1732,10 +1943,12 @@
1732
1943
  </div>
1733
1944
  </div>
1734
1945
  <p class="adjourn-summary-note">
1735
- The chair compiles a standard report from the room's transcript
1736
- situation, key findings, and implications. The room is marked
1737
- adjourned and the report is filed in the chat.
1946
+ The chair compiles a report from the room's transcript. Pick the
1947
+ format below the room is marked adjourned and the report is
1948
+ filed in the chat once it's ready.
1738
1949
  </p>
1950
+
1951
+ ${this.renderBriefModePicker(defaultMode)}
1739
1952
  </div>
1740
1953
 
1741
1954
  <footer class="adjourn-foot">
@@ -1756,6 +1969,19 @@
1756
1969
  wrap.innerHTML = html.trim();
1757
1970
  document.body.appendChild(wrap.firstChild);
1758
1971
  document.body.style.overflow = "hidden";
1972
+ // Subject clamp · the subject value is clamped to 3 lines by
1973
+ // default (a long room title shouldn't crowd the rest of the
1974
+ // overlay). After mount, measure whether the text actually
1975
+ // overflows the clamp — only then reveal the Show more / less
1976
+ // toggle. rAF lets the browser settle the initial layout so
1977
+ // scrollHeight is meaningful.
1978
+ requestAnimationFrame(() => {
1979
+ const subjEl = document.querySelector("[data-adjourn-subject]");
1980
+ const subjBtn = document.querySelector("[data-adjourn-subject-toggle]");
1981
+ if (subjEl && subjBtn && subjEl.scrollHeight > subjEl.clientHeight + 1) {
1982
+ subjBtn.hidden = false;
1983
+ }
1984
+ });
1759
1985
  // Esc closes the overlay. Listener auto-detaches on close so
1760
1986
  // we don't accumulate handlers across opens.
1761
1987
  this._adjournEsc = (ev) => {
@@ -1783,30 +2009,26 @@
1783
2009
  openSupplementOverlay() {
1784
2010
  if (!this.currentRoomId || !this.currentBrief) return;
1785
2011
  this.closeSupplementOverlay();
1786
- const lang = this.currentBrief?.language === "zh" ? "zh" : "en";
1787
- const t = lang === "zh"
1788
- ? {
1789
- classify: "report · 补充视角再生成",
1790
- classifyRight: "// regenerate",
1791
- title: "补充一个视角",
1792
- metaPrefix: "// 当前报告",
1793
- placeholder: "请描述这次想让 chair 额外考虑的角度。例如:\n· 加入一个商业可行性的视角\n· 重点关注女性用户的体验\n· 把时间窗口拉长到 5 年看",
1794
- hint: "这个视角会被织入现有的 Findings、Recommendations、New Questions 等段落,不会单独成节。",
1795
- cancel: "[ Cancel ]",
1796
- confirm: "[ Regenerate ]",
1797
- confirmBusy: "[ Regenerating… ]",
1798
- }
1799
- : {
1800
- classify: "report · regenerate with supplement",
1801
- classifyRight: "// regenerate",
1802
- title: "Add a perspective",
1803
- metaPrefix: "// Current report",
1804
- placeholder: "Describe an angle you want the chair to additionally consider. For example:\n· Bring in a commercial-viability lens\n· Center the experience of women users\n· Stretch the time window to 5 years",
1805
- hint: "The new perspective will be woven through the existing Findings, Recommendations, and New Questions sections — not added as a separate section.",
1806
- cancel: "[ Cancel ]",
1807
- confirm: "[ Regenerate ]",
1808
- confirmBusy: "[ Regenerating… ]",
1809
- };
2012
+ // System UI · always English. Supplement-overlay chrome.
2013
+ const t = {
2014
+ classify: "report · regenerate with supplement",
2015
+ classifyRight: "// regenerate",
2016
+ title: "Add a perspective",
2017
+ metaPrefix: "// Current report",
2018
+ placeholder: "Describe an angle you want the chair to additionally consider. For example:\n· Bring in a commercial-viability lens\n· Center the experience of women users\n· Stretch the time window to 5 years",
2019
+ hint: "The new perspective will be woven through the existing Findings, Recommendations, and New Questions sections — not added as a separate section.",
2020
+ cancel: "[ Cancel ]",
2021
+ confirm: "[ Regenerate ]",
2022
+ confirmBusy: "[ Regenerating… ]",
2023
+ };
2024
+ // Default the picker to the existing brief's mode · the user's
2025
+ // baseline assumption when regenerating with a supplement is "I
2026
+ // want the same kind of report, just with this extra angle." If
2027
+ // the parent brief's mode is missing for any reason, fall back
2028
+ // to the user's last picked mode (localStorage).
2029
+ const defaultMode = this.isStructuredBriefMode(this.currentBrief?.mode)
2030
+ ? this.currentBrief.mode
2031
+ : this.lastBriefMode();
1810
2032
  const html = `
1811
2033
  <div class="supplement-overlay" id="supplement-overlay" role="dialog" aria-modal="true">
1812
2034
  <div class="supplement-backdrop" data-supplement-close></div>
@@ -1825,6 +2047,7 @@
1825
2047
  <div class="supplement-body">
1826
2048
  <textarea class="supplement-input" data-supplement-input rows="6" placeholder="${this.escape(t.placeholder)}"></textarea>
1827
2049
  <p class="supplement-hint">${this.escape(t.hint)}</p>
2050
+ ${this.renderBriefModePicker(defaultMode)}
1828
2051
  </div>
1829
2052
  <footer class="supplement-foot">
1830
2053
  <button type="button" class="supplement-cancel" data-supplement-close>${this.escape(t.cancel)}</button>
@@ -1875,31 +2098,19 @@
1875
2098
  if (!this.currentRoomId || !this.currentRoom) return;
1876
2099
  if (this.currentRoom.status !== "paused") return;
1877
2100
  this.closePausedSupplementOverlay();
1878
- const lang = this.composerLanguage();
1879
- const t = lang === "zh"
1880
- ? {
1881
- classify: "room · 暂停时补充",
1882
- classifyRight: "// queued first",
1883
- title: "补充一个观点",
1884
- metaPrefix: "// 当前房间",
1885
- placeholder: "想补一个观点 · 一个想再追问的细节 · 一个让董事们重新考虑的角度。\n\n会立即作为你的发言进入对话;点击 [ Resume ] 后,董事们会先看到这条再继续。",
1886
- hint: "暂停期间的补充会以你的身份立即出现在对话里,原本的发言队列不变;恢复后队首董事将带着这条补充开口。",
1887
- cancel: "[ Cancel ]",
1888
- confirm: "[ Add to chat ]",
1889
- confirmBusy: "[ Posting… ]",
1890
- }
1891
- : {
1892
- classify: "room · paused supplement",
1893
- classifyRight: "// queued first",
1894
- title: "Add a supplemental input",
1895
- metaPrefix: "// Current room",
1896
- 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.",
1897
- 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.",
1898
- cancel: "[ Cancel ]",
1899
- confirm: "[ Add to chat ]",
1900
- confirmBusy: "[ Posting… ]",
1901
- };
1902
- const subject = (this.currentRoom.subject || "").trim() || (lang === "zh" ? "(无主题)" : "(no subject)");
2101
+ // System UI · always English. Paused-supplement overlay chrome.
2102
+ const t = {
2103
+ classify: "room · paused supplement",
2104
+ classifyRight: "// queued first",
2105
+ title: "Add a supplemental input",
2106
+ metaPrefix: "// Current room",
2107
+ 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.",
2108
+ 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.",
2109
+ cancel: "[ Cancel ]",
2110
+ confirm: "[ Add to chat ]",
2111
+ confirmBusy: "[ Posting… ]",
2112
+ };
2113
+ const subject = (this.currentRoom.subject || "").trim() || "(no subject)";
1903
2114
  const html = `
1904
2115
  <div class="supplement-overlay" id="paused-supplement-overlay" role="dialog" aria-modal="true">
1905
2116
  <div class="supplement-backdrop" data-paused-supplement-close></div>
@@ -2019,54 +2230,30 @@
2019
2230
  if (this.currentRoom.status !== "adjourned") return;
2020
2231
  this.closeFollowUpOverlay();
2021
2232
 
2022
- const lang = this.composerLanguage();
2023
- const t = lang === "zh"
2024
- ? {
2025
- classify: "follow-up · 跟进会议",
2026
- classifyRight: "// continuing",
2027
- title: "开一场跟进会议",
2028
- metaPrefix: "// following up",
2029
- placeholder: "在上一场判断之上,下一个要追问的问题是什么?",
2030
- contextNote: "上一场的议题、最终判断(brief)和每位 director 的关键观察会作为这场 follow-up 房间的上下文交给新一组 director —— 他们可以直接在已成型的判断上推进,不会从零开始。",
2031
- castLabel: "Directors",
2032
- castHint: "建议 2-4 ",
2033
- castSame: "沿用上一场的 cast",
2034
- pickerLabel: "选择董事",
2035
- autoLabel: "directors",
2036
- autoVal: "自动挑选",
2037
- countersDirectors: (n) => `${n} 位董事`,
2038
- toneLabel: "Tone",
2039
- intensityLabel: "Intensity",
2040
- cancel: "[ Cancel ]",
2041
- confirm: "[ Convene ]",
2042
- confirmBusy: "[ Convening… ]",
2043
- adjournedAtPrefix: "adjourned",
2044
- briefsCount: (n) => `${n} ${n === 1 ? "brief" : "briefs"} filed`,
2045
- noBrief: "no brief filed",
2046
- }
2047
- : {
2048
- classify: "follow-up · continuation room",
2049
- classifyRight: "// continuing",
2050
- title: "Convene a follow-up",
2051
- metaPrefix: "// following up",
2052
- placeholder: "What's the next question to chase, given what the prior session settled?",
2053
- contextNote: "The prior subject, the filed brief (room's settled judgement), and each director's load-bearing observations are bundled as context for this follow-up — the new cast picks up where the prior session left off rather than starting from scratch.",
2054
- castLabel: "Directors",
2055
- castHint: "2–4 recommended",
2056
- castSame: "Same cast as last session",
2057
- pickerLabel: "Pick directors",
2058
- autoLabel: "directors",
2059
- autoVal: "auto-pick",
2060
- countersDirectors: (n) => `${n} director${n === 1 ? "" : "s"}`,
2061
- toneLabel: "Tone",
2062
- intensityLabel: "Intensity",
2063
- cancel: "[ Cancel ]",
2064
- confirm: "[ Convene → ]",
2065
- confirmBusy: "[ Convening… ]",
2066
- adjournedAtPrefix: "adjourned",
2067
- briefsCount: (n) => `${n} ${n === 1 ? "brief" : "briefs"} filed`,
2068
- noBrief: "no brief filed",
2069
- };
2233
+ // System UI · always English. Follow-up overlay chrome.
2234
+ const t = {
2235
+ classify: "follow-up · continuation room",
2236
+ classifyRight: "// continuing",
2237
+ title: "Convene a follow-up",
2238
+ metaPrefix: "// following up",
2239
+ placeholder: "What's the next question to chase, given what the prior session settled?",
2240
+ contextNote: "The prior subject, the filed brief (room's settled judgement), and each director's load-bearing observations are bundled as context for this follow-up — the new cast picks up where the prior session left off rather than starting from scratch.",
2241
+ castLabel: "Directors",
2242
+ castHint: "2–4 recommended",
2243
+ castSame: "Same cast as last session",
2244
+ pickerLabel: "Pick directors",
2245
+ autoLabel: "directors",
2246
+ autoVal: "auto-pick",
2247
+ countersDirectors: (n) => `${n} director${n === 1 ? "" : "s"}`,
2248
+ toneLabel: "Tone",
2249
+ intensityLabel: "Intensity",
2250
+ cancel: "[ Cancel ]",
2251
+ confirm: "[ Convene ]",
2252
+ confirmBusy: "[ Convening… ]",
2253
+ adjournedAtPrefix: "adjourned",
2254
+ briefsCount: (n) => `${n} ${n === 1 ? "brief" : "briefs"} filed`,
2255
+ noBrief: "no brief filed",
2256
+ };
2070
2257
 
2071
2258
  const room = this.currentRoom;
2072
2259
  const briefCount = Array.isArray(this.currentBriefs) ? this.currentBriefs.length : 0;
@@ -2105,7 +2292,6 @@
2105
2292
  directorIds: [],
2106
2293
  autoPick: true,
2107
2294
  parentDirectorIds: parentDirectorIds.slice(),
2108
- lang,
2109
2295
  };
2110
2296
 
2111
2297
  const html = `
@@ -2269,7 +2455,6 @@
2269
2455
  const btn = document.querySelector("[data-followup-cast-btn]");
2270
2456
  if (!btn || !this._followupCastState) return;
2271
2457
  const state = this._followupCastState;
2272
- const lang = state.lang || "en";
2273
2458
 
2274
2459
  btn.classList.remove("cmp-cast-btn-auto");
2275
2460
  btn.removeAttribute("data-cast-mode");
@@ -2286,9 +2471,8 @@
2286
2471
  const avatars = visible.map((a) =>
2287
2472
  `<img class="cmp-cast-av" src="${this.escape(a.avatarPath || "")}" alt="${this.escape(a.name || "")}" title="${this.escape(a.name || "")}">`,
2288
2473
  ).join("");
2289
- const count = lang === "zh"
2290
- ? `${parents.length} · 沿用上一场`
2291
- : `${parents.length} · same as last`;
2474
+ // System UI · always English (cast button chrome).
2475
+ const count = `${parents.length} · same as last`;
2292
2476
  btn.innerHTML =
2293
2477
  `<span class="cmp-cast-stack">${avatars}${overflow > 0 ? `<span class="cmp-cast-more">+${overflow}</span>` : ""}</span>` +
2294
2478
  `<span class="cmp-cast-count">${this.escape(count)}</span>`;
@@ -2304,8 +2488,9 @@
2304
2488
  // Auto-pick · default
2305
2489
  btn.classList.add("cmp-cast-btn-auto");
2306
2490
  btn.setAttribute("data-cast-mode", "auto");
2307
- const autoKey = lang === "zh" ? "directors" : "directors";
2308
- const autoVal = lang === "zh" ? "自动挑选" : "auto-pick";
2491
+ // System UI · always English (cast button chrome).
2492
+ const autoKey = "directors";
2493
+ const autoVal = "auto-pick";
2309
2494
  btn.innerHTML =
2310
2495
  `<span class="cmp-cast-stack cmp-cast-stack-auto"><span class="cmp-cast-auto-mark">✦</span></span>` +
2311
2496
  `<span class="cmp-cast-count cmp-cast-auto-label">` +
@@ -2320,9 +2505,8 @@
2320
2505
  const avatars = visible.map((a) =>
2321
2506
  `<img class="cmp-cast-av" src="${this.escape(a.avatarPath || "")}" alt="${this.escape(a.name || "")}" title="${this.escape(a.name || "")}">`,
2322
2507
  ).join("");
2323
- const countText = lang === "zh"
2324
- ? `${picked.length} 位董事`
2325
- : `${picked.length} director${picked.length === 1 ? "" : "s"}`;
2508
+ // System UI · always English (cast button chrome count).
2509
+ const countText = `${picked.length} director${picked.length === 1 ? "" : "s"}`;
2326
2510
  btn.setAttribute("data-cast-mode", "manual");
2327
2511
  btn.innerHTML =
2328
2512
  `<span class="cmp-cast-stack">${avatars}${overflow > 0 ? `<span class="cmp-cast-more">+${overflow}</span>` : ""}<span class="cmp-cast-add" aria-hidden="true">+</span></span>` +
@@ -2338,14 +2522,12 @@
2338
2522
  if (!anchorBtn || !this._followupCastState) return;
2339
2523
  if (this._followupCastState.sameAsLast) return; // disabled when "same cast" is on
2340
2524
  const state = this._followupCastState;
2341
- const lang = state.lang || "en";
2342
2525
  const dirs = (this.agents || [])
2343
2526
  .filter((a) => a.roleKind !== "moderator")
2344
2527
  .slice()
2345
2528
  .sort((a, b) => (a.name || "").localeCompare(b.name || ""));
2346
- const t = lang === "zh"
2347
- ? { title: "选择董事", hint: "建议 2-4 ", info: "查看资料" }
2348
- : { title: "Pick directors", hint: "2-4 recommended", info: "View profile" };
2529
+ // System UI · always English (director picker chrome).
2530
+ const t = { title: "Pick directors", hint: "2-4 recommended", info: "View profile" };
2349
2531
  const rows = dirs.map((a) => {
2350
2532
  const checked = state.directorIds.includes(a.id);
2351
2533
  return `
@@ -2515,6 +2697,17 @@
2515
2697
  if (input) input.focus();
2516
2698
  return;
2517
2699
  }
2700
+ // Read the report-mode picker (research-note / bento). The
2701
+ // overlay defaults the picker to the existing brief's mode, so a
2702
+ // plain "regenerate with this perspective" keeps the same
2703
+ // format. Persisting the choice keeps the picker stable next
2704
+ // time. Server-side the same `/api/rooms/:id/brief` route reads
2705
+ // `mode` regardless of whether the call came from supplement or
2706
+ // generate-brief, so no backend change is needed.
2707
+ const briefModeInput = overlay.querySelector('input[name="brief-mode"]:checked');
2708
+ const v = briefModeInput && briefModeInput.value;
2709
+ const briefMode = this.isStructuredBriefMode(v) ? v : "research-note";
2710
+ this.saveLastBriefMode(briefMode);
2518
2711
  // Frontend in-flight guard · without this, a slow server roundtrip
2519
2712
  // gives the user time to click confirm twice (or to close+reopen
2520
2713
  // the overlay and click again). Each click was firing its own POST,
@@ -2533,7 +2726,7 @@
2533
2726
  {
2534
2727
  method: "POST",
2535
2728
  headers: { "content-type": "application/json" },
2536
- body: JSON.stringify({ supplement: text }),
2729
+ body: JSON.stringify({ supplement: text, mode: briefMode }),
2537
2730
  },
2538
2731
  );
2539
2732
  if (!r.ok) {
@@ -2564,7 +2757,7 @@
2564
2757
  * whether streaming is actually in flight. */
2565
2758
  isBriefPlaceholder(brief, room) {
2566
2759
  if (!brief) return false;
2567
- if (brief.bodyMd && brief.bodyMd.trim()) return false;
2760
+ if (this.briefHasBody(brief)) return false;
2568
2761
  if (!room) return true;
2569
2762
  // Title matches the seed (room subject) → never got an updated title.
2570
2763
  // Or title is the literal "Generating…" placeholder.
@@ -2679,23 +2872,14 @@
2679
2872
  async deleteBriefAt(briefId) {
2680
2873
  if (!briefId) return;
2681
2874
  const target = (this.currentBriefs || []).find((b) => b.id === briefId);
2682
- const lang = (this.currentBrief?.language === "zh" || (this.currentRoom?.subject && /[一-鿿]/.test(this.currentRoom.subject))) ? "zh" : "en";
2683
- // If the target brief is still being generated, the confirmation
2684
- // copy is sharper · the user is also cancelling an in-flight
2685
- // pipeline (LLM calls actively burning tokens), not just removing
2686
- // a finished row. Server aborts the upstream fetches when we DELETE.
2687
- const isStillGenerating = !!(target && (!target.bodyMd || !target.bodyMd.trim()));
2688
- const confirmText = lang === "zh"
2689
- ? (isStillGenerating
2690
- ? "这份报告还在生成中。删除会立即停止生成并删除这份报告,此操作不可恢复。"
2691
- : (target?.supplement
2692
- ? `删除这份"${target.supplement.trim().slice(0, 20)}${target.supplement.length > 20 ? "…" : ""}"补充视角的报告?此操作不可恢复。`
2693
- : "删除这份报告?此操作不可恢复。"))
2694
- : (isStillGenerating
2695
- ? "This report is still generating. Deleting will stop the generation and remove the report. This can't be undone."
2696
- : (target?.supplement
2697
- ? `Delete the "${target.supplement.trim().slice(0, 20)}${target.supplement.length > 20 ? "…" : ""}" version? This can't be undone.`
2698
- : "Delete this report? This can't be undone."));
2875
+ // System UI · always English. Confirm + alert dialogs are app
2876
+ // chrome and stay fixed-string regardless of brief language.
2877
+ const isStillGenerating = !!(target && !this.briefHasBody(target));
2878
+ const confirmText = isStillGenerating
2879
+ ? "This report is still generating. Deleting will stop the generation and remove the report. This can't be undone."
2880
+ : (target?.supplement
2881
+ ? `Delete the "${target.supplement.trim().slice(0, 20)}${target.supplement.length > 20 ? "" : ""}" version? This can't be undone.`
2882
+ : "Delete this report? This can't be undone.");
2699
2883
  if (!confirm(confirmText)) return;
2700
2884
  try {
2701
2885
  const r = await fetch("/api/briefs/" + encodeURIComponent(briefId), { method: "DELETE" });
@@ -2704,7 +2888,7 @@
2704
2888
  throw new Error(e.error || ("HTTP " + r.status));
2705
2889
  }
2706
2890
  } catch (e) {
2707
- alert((lang === "zh" ? "删除失败:" : "Delete failed: ") + (e && e.message ? e.message : e));
2891
+ alert("Delete failed: " + (e && e.message ? e.message : e));
2708
2892
  return;
2709
2893
  }
2710
2894
  // Patch local state · remove the deleted brief, refresh active.
@@ -2792,17 +2976,23 @@
2792
2976
  const mode = overlay.getAttribute("data-adjourn-mode") || "adjourn";
2793
2977
  const isGen = mode === "generate-brief";
2794
2978
  const skipPicked = overlay.querySelector(".adjourn-skip-btn.picked") !== null;
2979
+ // Read the report-mode picker (research-note / bento). Persist the
2980
+ // choice so the picker defaults to the same option next time.
2981
+ const briefModeInput = overlay.querySelector('input[name="brief-mode"]:checked');
2982
+ const v = briefModeInput && briefModeInput.value;
2983
+ const briefMode = this.isStructuredBriefMode(v) ? v : "research-note";
2984
+ this.saveLastBriefMode(briefMode);
2795
2985
  const btn = overlay.querySelector("[data-adjourn-confirm]");
2796
2986
  const origLabel = isGen ? "[ Generate ]" : "[ Adjourn & file ]";
2797
2987
  const busyLabel = isGen ? "[ Generating… ]" : "[ Adjourning… ]";
2798
2988
  if (btn) { btn.disabled = true; btn.textContent = busyLabel; }
2799
2989
  try {
2800
2990
  if (isGen) {
2801
- await this.generateBriefForAdjournedRoom();
2991
+ await this.generateBriefForAdjournedRoom(briefMode);
2802
2992
  } else if (skipPicked) {
2803
2993
  await this.adjournRoom({ skipBrief: true });
2804
2994
  } else {
2805
- await this.adjournRoom({});
2995
+ await this.adjournRoom({ mode: briefMode });
2806
2996
  }
2807
2997
  this.closeAdjournOverlay();
2808
2998
  } catch (e) {
@@ -2815,8 +3005,9 @@
2815
3005
  * whose user originally skipped the brief. Server emits the same
2816
3006
  * brief-started / brief-token / brief-final SSE events as a normal
2817
3007
  * adjourn, so the existing handlers in connectSSE handle the rest. */
2818
- async generateBriefForAdjournedRoom() {
3008
+ async generateBriefForAdjournedRoom(mode) {
2819
3009
  if (!this.currentRoomId) return;
3010
+ const briefMode = this.isStructuredBriefMode(mode) ? mode : "research-note";
2820
3011
  // Pre-flight · the brief writer is a 3-stage LLM pipeline (per-
2821
3012
  // director extract → composer → final write). All require a key.
2822
3013
  if (!(await this.requireModelKey())) return;
@@ -2825,7 +3016,7 @@
2825
3016
  {
2826
3017
  method: "POST",
2827
3018
  headers: { "content-type": "application/json" },
2828
- body: "{}",
3019
+ body: JSON.stringify({ mode: briefMode }),
2829
3020
  },
2830
3021
  );
2831
3022
  if (!r.ok) {
@@ -3047,7 +3238,7 @@
3047
3238
  /** Spin up the countdown if conditions are met; otherwise cancel. */
3048
3239
  maybeStartContinueCountdown() {
3049
3240
  if (!this.canAutoContinue()) {
3050
- this.cancelContinueCountdown("not-idle");
3241
+ this.cancelContinueCountdown();
3051
3242
  this.refreshContinueButton();
3052
3243
  return;
3053
3244
  }
@@ -3844,14 +4035,11 @@
3844
4035
  renderSidebarCounts() {
3845
4036
  const roomsCount = this.rooms.length;
3846
4037
  const agentsCount = this.agents.length;
3847
- const liveCount = this.rooms.filter((r) => r.status === "live").length;
3848
4038
 
3849
4039
  const r = document.querySelector('[data-sidebar-tab-count="rooms"]');
3850
4040
  if (r) r.textContent = String(roomsCount);
3851
4041
  const a = document.querySelector('[data-sidebar-tab-count="agents"]');
3852
4042
  if (a) a.textContent = String(agentsCount);
3853
- const sum = document.querySelector("[data-sidebar-summary]");
3854
- if (sum) sum.textContent = `${liveCount} LIVE / ${agentsCount} AGENTS`;
3855
4043
  },
3856
4044
 
3857
4045
  // ── Rendering · main view ─────────────────────────────────
@@ -3886,7 +4074,8 @@
3886
4074
  if (existingBanner) existingBanner.remove();
3887
4075
  const parent = this.currentParentRef;
3888
4076
  if (chat && parent && parent.id) {
3889
- const labelText = lang === "zh" ? "// 跟进自" : "// following up";
4077
+ // System UI · always English (banner chrome on the follow-up parent link).
4078
+ const labelText = "// following up";
3890
4079
  const subject = (parent.subject || "(no subject)").trim();
3891
4080
  const banner = document.createElement("a");
3892
4081
  banner.href = "#";
@@ -3906,9 +4095,8 @@
3906
4095
  if (existingChildren) existingChildren.remove();
3907
4096
  const kids = Array.isArray(this.currentFollowUps) ? this.currentFollowUps : [];
3908
4097
  if (briefCard && kids.length > 0) {
3909
- const headLabel = lang === "zh"
3910
- ? `跟进会议 · ${kids.length}`
3911
- : `Follow-up rooms · ${kids.length}`;
4098
+ // System UI · always English (sidebar / brief-card chrome head).
4099
+ const headLabel = `Follow-up rooms · ${kids.length}`;
3912
4100
  const block = document.createElement("div");
3913
4101
  block.className = "followup-children";
3914
4102
  block.innerHTML = [
@@ -4031,36 +4219,23 @@
4031
4219
  const briefCard = document.querySelector("[data-brief-card]");
4032
4220
  if (!briefCard) return;
4033
4221
 
4034
- const isZh = this.composerLanguage() === "zh";
4035
- const t = isZh
4036
- ? {
4037
- head: "// 会议数据",
4038
- stamp: "已结束",
4039
- tokens: "tokens",
4040
- messages: "条消息",
4041
- rounds: "轮讨论",
4042
- minutes: "分钟",
4043
- modelHead: "模型用量",
4044
- valueHead: "你认为有价值的",
4045
- valueEmpty: "本次没有 key point 投票,也没有用 probe / second。",
4046
- voted: "▲ 投票",
4047
- seconded: "附议",
4048
- probed: "追问",
4049
- }
4050
- : {
4051
- head: "// session analytics",
4052
- stamp: "closed",
4053
- tokens: "tokens",
4054
- messages: "msgs",
4055
- rounds: "rounds",
4056
- minutes: "min",
4057
- modelHead: "Model usage",
4058
- valueHead: "What you valued",
4059
- valueEmpty: "No ▲ key-point votes, probes, or seconds in this session.",
4060
- voted: "▲ voted",
4061
- seconded: "★ seconded",
4062
- probed: "✎ probed",
4063
- };
4222
+ // System UI · always English. Session-analytics tile chrome
4223
+ // (banner, metric labels, section heads, chip text) is part of
4224
+ // the app shell and doesn't follow the brief language.
4225
+ const t = {
4226
+ head: "// session analytics",
4227
+ stamp: "closed",
4228
+ tokens: "tokens",
4229
+ messages: "msgs",
4230
+ rounds: "rounds",
4231
+ minutes: "min",
4232
+ modelHead: "Model usage",
4233
+ valueHead: "What you valued",
4234
+ valueEmpty: "No key-point votes, probes, or seconds in this session.",
4235
+ voted: "▲ voted",
4236
+ seconded: "★ seconded",
4237
+ probed: "✎ probed",
4238
+ };
4064
4239
 
4065
4240
  const fmtTokens = (n) => {
4066
4241
  if (!Number.isFinite(n) || n <= 0) return "0";
@@ -4164,8 +4339,10 @@
4164
4339
  // analytics tile; capping keeps the section as a tight strip
4165
4340
  // and lets the user opt in.
4166
4341
  const VALUE_PREVIEW_CAP = 2;
4167
- const moreLabel = (n) => isZh ? `[ + 展开剩余 ${n} 条 ]` : `[ + show ${n} more ]`;
4168
- const lessLabel = isZh ? "[ 收起 ]" : "[ collapse ]";
4342
+ // System UI · always English (the surrounding tile content can
4343
+ // still localize via `t`, but toggle chrome is fixed-string).
4344
+ const moreLabel = (n) => `[ + show ${n} more ]`;
4345
+ const lessLabel = "[ collapse ]";
4169
4346
  const upvotedHtml = stats.upvotedPoints.length > 0
4170
4347
  ? (() => {
4171
4348
  const items = stats.upvotedPoints.map((p, i) => {
@@ -4281,12 +4458,13 @@
4281
4458
  ? this.escape(nextSpeaker.handle.replace(/^\//, ""))
4282
4459
  : "";
4283
4460
 
4284
- const lang = this.composerLanguage();
4285
- const addInputLabel = lang === "zh" ? "[ + 补充观点 ]" : "[ + Add input ]";
4286
- const adjournLabel = lang === "zh" ? "[ 结束并存档 ]" : "[ ▸ Adjourn & File Brief ]";
4287
- const resumeLabel = lang === "zh" ? "[ 恢复讨论 ]" : "[ Resume Discussion ]";
4288
- const pausedLabel = lang === "zh" ? "已暂停" : "paused";
4289
- const nextLabel = lang === "zh" ? "下一位" : "next";
4461
+ // System UI · always English. App chrome (paused bar, action
4462
+ // buttons, status labels) doesn't follow the brief language.
4463
+ const addInputLabel = "[ + Add input ]";
4464
+ const adjournLabel = "[ Adjourn & File Brief ]";
4465
+ const resumeLabel = "[ Resume Discussion ]";
4466
+ const pausedLabel = "paused";
4467
+ const nextLabel = "next";
4290
4468
  const nextChunk = nextHandle
4291
4469
  ? ` · ${nextLabel} → <span class="lime">${nextHandle}</span>`
4292
4470
  : "";
@@ -4445,6 +4623,12 @@
4445
4623
  try { this._reportsLoadObserver.disconnect(); } catch { /* noop */ }
4446
4624
  this._reportsLoadObserver = null;
4447
4625
  }
4626
+ // The floating sidebar-expand button is gated on `html.no-room`.
4627
+ // Without setting it here, a user who collapses the sidebar
4628
+ // while on All Reports loses access to the expand control —
4629
+ // they have to navigate back to a room view to recover. Same
4630
+ // reasoning for openAllNotes / openAgentProfile.
4631
+ document.documentElement.classList.add("no-room");
4448
4632
  // If we're inside a room or on the agent profile, leave them.
4449
4633
  if (this.currentRoomId) {
4450
4634
  this.disconnectSSE?.();
@@ -4650,32 +4834,14 @@
4650
4834
  `;
4651
4835
  };
4652
4836
 
4653
- // Group filtered items by date label (Today / Yesterday / This
4654
- // week / Earlier) so the list still has rhythm without splitting
4655
- // into multiple sections each with its own header chrome.
4656
- // Iterates the *visible* slice; the load-more sentinel below
4657
- // tops the list up by 20 each time it crosses the viewport.
4658
- const groups = [];
4659
- const yesterdayStart = todayStart - 86400_000;
4660
- let currentGroup = null;
4661
- const groupLabelFor = (ts) => {
4662
- if (ts >= todayStart) return "Today";
4663
- if (ts >= yesterdayStart) return "Yesterday";
4664
- if (ts >= weekStart) return "This week";
4665
- return "Earlier";
4666
- };
4667
- for (const b of visibleFiltered) {
4668
- const label = groupLabelFor(b.createdAt);
4669
- if (!currentGroup || currentGroup.label !== label) {
4670
- currentGroup = { label, items: [] };
4671
- groups.push(currentGroup);
4672
- }
4673
- currentGroup.items.push(b);
4674
- }
4675
-
4837
+ // Flat list under the filter chips · earlier iterations grouped
4838
+ // by Today / Yesterday / This week / Earlier, but the user
4839
+ // wanted a single divider followed by the items. The filter
4840
+ // chips above already disclose the recency dimension; redundant
4841
+ // group headers were just visual chrome.
4676
4842
  const filterLabels = { all: "the archive", today: "Today", week: "This week", earlier: "Earlier" };
4677
4843
  const filterCopyTitle = filterLabels[activeFilter] || "this window";
4678
- const groupsHtml = groups.length === 0
4844
+ const groupsHtml = visibleFiltered.length === 0
4679
4845
  ? `
4680
4846
  <div class="reports-list-empty">
4681
4847
  <!-- Notice text · explains the empty window. -->
@@ -4700,14 +4866,11 @@
4700
4866
  ` : ""}
4701
4867
  </div>
4702
4868
  `
4703
- : groups.map((g) => `
4704
- <div class="reports-group">
4705
- <div class="reports-group-label">${this.escape(g.label)}</div>
4706
- <ul class="reports-list">
4707
- ${g.items.map((b) => this.renderReportItemHtml(b)).join("")}
4708
- </ul>
4709
- </div>
4710
- `).join("");
4869
+ : `
4870
+ <ul class="reports-list">
4871
+ ${visibleFiltered.map((b) => this.renderReportItemHtml(b)).join("")}
4872
+ </ul>
4873
+ `;
4711
4874
 
4712
4875
  // Bottom sentinel · IntersectionObserver target. Renders only
4713
4876
  // when there's more to load. The "+ N more" hint doubles as a
@@ -4862,6 +5025,9 @@
4862
5025
  * Pulls the live list and renders three time-bucket sections
4863
5026
  * (Today / This Week / Earlier). */
4864
5027
  async openAllNotes() {
5028
+ // Set the no-room flag for the same reason openAllReports does
5029
+ // — the floating sidebar-expand button is gated on this class.
5030
+ document.documentElement.classList.add("no-room");
4865
5031
  // Same view-leaving routine as openAllReports.
4866
5032
  if (this.currentRoomId) {
4867
5033
  this.disconnectSSE?.();
@@ -4990,9 +5156,30 @@
4990
5156
  <div class="notes-empty-mark">○</div>
4991
5157
  <div class="notes-empty-title">no saved notes yet</div>
4992
5158
  <div class="notes-empty-deck">
4993
- While reading a director's reply, select an interesting passage and hit
4994
- <span class="kbd">S</span> or click <span class="kbd">⌖ Save</span>
4995
- on the floating bar to bookmark it here.
5159
+ Capture a director's line as a note:
5160
+ </div>
5161
+ <ol class="notes-empty-steps">
5162
+ <li>
5163
+ <span class="notes-empty-step-num">1</span>
5164
+ <span class="notes-empty-step-text">
5165
+ <strong>Open a room.</strong> Any director's reply can be saved.
5166
+ </span>
5167
+ </li>
5168
+ <li>
5169
+ <span class="notes-empty-step-num">2</span>
5170
+ <span class="notes-empty-step-text">
5171
+ <strong>Select a passage.</strong> Drag-highlight the sentence or paragraph you want to keep.
5172
+ </span>
5173
+ </li>
5174
+ <li>
5175
+ <span class="notes-empty-step-num">3</span>
5176
+ <span class="notes-empty-step-text">
5177
+ <strong>Click <span class="kbd">⌖ Save</span></strong> on the floating bar that appears, or press <span class="kbd">S</span>. The excerpt lands here, indexed across all rooms.
5178
+ </span>
5179
+ </li>
5180
+ </ol>
5181
+ <div class="notes-empty-foot">
5182
+ Each saved excerpt links back to the original room + speaker, so you can jump back any time.
4996
5183
  </div>
4997
5184
  </div>
4998
5185
  `;
@@ -5007,27 +5194,12 @@
5007
5194
  return true;
5008
5195
  });
5009
5196
 
5010
- // Group filtered items by date label so even an "All" view has
5011
- // visual rhythm. When a recency filter narrows to a single
5012
- // bucket, only that bucket's section renders no empty headers.
5013
- const groupLabelFor = (ts) => {
5014
- if (ts >= todayStart) return "Today";
5015
- if (ts >= weekStart) return "This week";
5016
- return "Earlier";
5017
- };
5018
- const groups = [];
5019
- let currentGroup = null;
5020
- for (const n of filtered) {
5021
- const label = groupLabelFor(n.createdAt || 0);
5022
- if (!currentGroup || currentGroup.label !== label) {
5023
- currentGroup = { label, items: [] };
5024
- groups.push(currentGroup);
5025
- }
5026
- currentGroup.items.push(n);
5027
- }
5028
-
5197
+ // Flat list under the filter chips. Earlier iterations grouped
5198
+ // by Today / This week / Earlier headers; the user wanted a
5199
+ // single divider followed by the items, since the chips above
5200
+ // already disclose the recency dimension.
5029
5201
  const filterLabels = { all: "the archive", today: "Today", week: "This week", earlier: "Earlier" };
5030
- const groupsHtml = groups.length === 0
5202
+ const groupsHtml = filtered.length === 0
5031
5203
  ? `
5032
5204
  <div class="notes-list-empty">
5033
5205
  <div class="notes-empty-mark">○</div>
@@ -5041,17 +5213,11 @@
5041
5213
  ` : ""}
5042
5214
  </div>
5043
5215
  `
5044
- : groups.map((g) => `
5045
- <section class="notes-group">
5046
- <div class="notes-group-head">
5047
- <span class="notes-group-label">${this.escape(g.label)}</span>
5048
- <span class="notes-group-count">${g.items.length}</span>
5049
- </div>
5050
- <ul class="notes-list">
5051
- ${g.items.map((n) => this.renderNoteItemHtml(n)).join("")}
5052
- </ul>
5053
- </section>
5054
- `).join("");
5216
+ : `
5217
+ <ul class="notes-list">
5218
+ ${filtered.map((n) => this.renderNoteItemHtml(n)).join("")}
5219
+ </ul>
5220
+ `;
5055
5221
 
5056
5222
  const totalLabel = `${total} ${total === 1 ? "note" : "notes"}`;
5057
5223
  const roomLabel = distinctRooms > 0
@@ -5091,9 +5257,12 @@
5091
5257
  const author = n.authorName || "Director";
5092
5258
  // The jump link uses the room's hash route + the note id as a
5093
5259
  // fragment-style query so openRoom can scroll to + flash the
5094
- // matching span (Step 5 wires the receiver). For now the link
5095
- // just navigates · the in-room overlay step adds the scroll.
5260
+ // matching span. The delete button sits as a SIBLING of the
5261
+ // anchor (inside the .notes-item) so its click never bubbles
5262
+ // through the navigation link — same pattern as session-row
5263
+ // delete in the rooms sidebar.
5096
5264
  const href = `#/r/${this.escape(n.roomId)}?note=${this.escape(n.id)}`;
5265
+ const deleteTitle = "Delete this note";
5097
5266
  return `
5098
5267
  <li class="notes-item" data-note-id="${this.escape(n.id)}">
5099
5268
  <a class="notes-item-link" href="${href}" data-note-jump="${this.escape(n.id)}" data-note-room="${this.escape(n.roomId)}">
@@ -5110,10 +5279,39 @@
5110
5279
  n.contextAfter ? `<span class="note-context note-context-after">${this.escape(n.contextAfter)}</span>` : ""
5111
5280
  }</p>
5112
5281
  </a>
5282
+ <button type="button" class="notes-item-delete" data-note-delete title="${this.escape(deleteTitle)}" aria-label="${this.escape(deleteTitle)}">✕</button>
5113
5283
  </li>
5114
5284
  `;
5115
5285
  },
5116
5286
 
5287
+ /** DELETE /api/notes/:id · drops a saved excerpt from the index.
5288
+ * Confirmation is light because the action is reversible only by
5289
+ * re-saving the same passage; we keep the prompt as a single
5290
+ * click-confirm rather than a full overlay. */
5291
+ async deleteNoteAt(id) {
5292
+ if (!id) return;
5293
+ if (!confirm("Delete this note? You can save it again from the room.")) return;
5294
+ try {
5295
+ const r = await fetch("/api/notes/" + encodeURIComponent(id), { method: "DELETE" });
5296
+ if (!r.ok) {
5297
+ const j = await r.json().catch(() => ({}));
5298
+ alert("Delete failed: " + (j.error || r.statusText));
5299
+ return;
5300
+ }
5301
+ } catch (e) {
5302
+ alert("Delete failed: " + (e && e.message ? e.message : e));
5303
+ return;
5304
+ }
5305
+ // Patch local cache so the immediate re-render reflects the
5306
+ // delete without a refetch round-trip. The sidebar count badge
5307
+ // re-fetches via the /count endpoint.
5308
+ if (Array.isArray(this._notesCache)) {
5309
+ this._notesCache = this._notesCache.filter((n) => n && n.id !== id);
5310
+ this.renderNotesPage(this._notesCache);
5311
+ }
5312
+ this.refreshNotesCount();
5313
+ },
5314
+
5117
5315
  /** Refresh the sidebar count badge · called on boot, after
5118
5316
  * every successful save (note:created event), and after a
5119
5317
  * delete. Hits /api/notes/count which is cheap (one COUNT query),
@@ -5535,31 +5733,23 @@
5535
5733
  const userName = (this.prefs?.name || "you").trim() || "you";
5536
5734
  const lang = this.composerLanguage();
5537
5735
  const greeting = this.composerGreeting(lang, userName);
5538
- const t = lang === "zh"
5539
- ? {
5540
- greet: greeting,
5541
- prompt: "今天想和董事会聊点什么?",
5542
- placeholder: "一个还没把握的想法 · 一个一直绕开的决定 · 一个想被压力测试的判断",
5543
- convene: "Convene",
5544
- tuneLabel: "tune",
5545
- starterLabel: "starter",
5546
- starterCaption: "或者试一个起手式",
5547
- pickerLabel: "选择董事",
5548
- directorsLabel: (n) => `${n} 位董事`,
5549
- directorsAdd: "添加",
5550
- }
5551
- : {
5552
- greet: greeting,
5553
- prompt: "What's on your mind today?",
5554
- placeholder: "an idea you're not sure about · a decision you keep avoiding · a thesis you want stress-tested",
5555
- convene: "Convene",
5556
- tuneLabel: "tune",
5557
- starterLabel: "starter",
5558
- starterCaption: "or try a starter",
5559
- pickerLabel: "Pick directors",
5560
- directorsLabel: (n) => `${n} director${n === 1 ? "" : "s"}`,
5561
- directorsAdd: "add",
5562
- };
5736
+ // System UI · always English. Composer chrome (prompt /
5737
+ // placeholder / button labels) doesn't follow the brief
5738
+ // language. The brief content + chair/director output still
5739
+ // honours the user's input language; only the app shell is
5740
+ // fixed-string.
5741
+ const t = {
5742
+ greet: greeting,
5743
+ prompt: "What's on your mind today?",
5744
+ placeholder: "an idea you're not sure about · a decision you keep avoiding · a thesis you want stress-tested",
5745
+ convene: "Convene",
5746
+ tuneLabel: "tune",
5747
+ starterLabel: "starter",
5748
+ starterCaption: "or try a starter",
5749
+ pickerLabel: "Pick directors",
5750
+ directorsLabel: (n) => `${n} director${n === 1 ? "" : "s"}`,
5751
+ directorsAdd: "add",
5752
+ };
5563
5753
 
5564
5754
  // Cast slot · two visual modes:
5565
5755
  // 1. Auto-pick (default · empty manual selection): chip
@@ -5578,16 +5768,15 @@
5578
5768
  // Label uses "directors" (not "cast") so the chip's purpose
5579
5769
  // is unambiguous: it's the slot that picks the boardroom's
5580
5770
  // director agents. Tooltip carries the long-form explanation.
5581
- const autoTip = lang === "zh"
5582
- ? "Convene 时由 chair 根据主题挑选 3 位董事 · 点击手动选择"
5583
- : "Chair picks 3 directors based on your subject when you Convene · click to pick manually";
5771
+ // System UI · always English (auto-pick chip tooltip).
5772
+ const autoTip = "Chair picks 3 directors based on your subject when you Convene · click to pick manually";
5584
5773
  castInner = `
5585
5774
  <span class="cmp-cast-stack cmp-cast-stack-auto" data-cast-auto title="${this.escape(autoTip)}">
5586
5775
  <span class="cmp-cast-auto-mark">✦</span>
5587
5776
  </span>
5588
5777
  <span class="cmp-cast-count cmp-cast-auto-label" title="${this.escape(autoTip)}">
5589
- <span class="cmp-cast-auto-key">${lang === "zh" ? "董事" : "directors"}</span>
5590
- <span class="cmp-cast-auto-val">${lang === "zh" ? "自动挑选" : "auto-pick"}</span>
5778
+ <span class="cmp-cast-auto-key">directors</span>
5779
+ <span class="cmp-cast-auto-val">auto-pick</span>
5591
5780
  </span>
5592
5781
  `;
5593
5782
  } else {
@@ -5598,7 +5787,7 @@
5598
5787
  `).join("");
5599
5788
  const dirCount = dirObjs.length
5600
5789
  ? `<span class="cmp-cast-count">${this.escape(t.directorsLabel(dirObjs.length))}</span>`
5601
- : `<span class="cmp-cast-count cmp-cast-empty">${lang === "zh" ? "未选董事" : "no directors"}</span>`;
5790
+ : `<span class="cmp-cast-count cmp-cast-empty">no directors</span>`;
5602
5791
  castInner = `
5603
5792
  <span class="cmp-cast-stack">
5604
5793
  ${dirAvatars}
@@ -5612,8 +5801,9 @@
5612
5801
  // Tune dropdowns · two trigger buttons that open option popovers
5613
5802
  // on click. Discoverable (label + value + chevron pattern is
5614
5803
  // unmistakeably a select) without taking up visual real estate.
5615
- const toneLbl = lang === "zh" ? "tone" : "tone";
5616
- const intensityLbl = lang === "zh" ? "intensity" : "intensity";
5804
+ // System UI · always English (tune dropdown labels).
5805
+ const toneLbl = "tone";
5806
+ const intensityLbl = "intensity";
5617
5807
 
5618
5808
  // Starter grid · 2-col responsive cards.
5619
5809
  const starters = Array.isArray(window.BOARDROOM_STARTERS) ? window.BOARDROOM_STARTERS : [];
@@ -5679,12 +5869,10 @@
5679
5869
  },
5680
5870
 
5681
5871
  /** Time-of-day greeting like "// good evening, Kay" / "// 晚上好,Kay". */
5682
- composerGreeting(lang, name) {
5872
+ composerGreeting(_lang, name) {
5873
+ // System UI · always English. Greeting is part of the composer
5874
+ // chrome; the brief language doesn't change the app's voice.
5683
5875
  const h = new Date().getHours();
5684
- if (lang === "zh") {
5685
- const part = h < 5 ? "凌晨好" : h < 12 ? "早上好" : h < 14 ? "中午好" : h < 18 ? "下午好" : h < 23 ? "晚上好" : "夜深了";
5686
- return `// ${part},${name}`;
5687
- }
5688
5876
  const part = h < 5 ? "Up late" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
5689
5877
  return `// ${part}, ${name}`;
5690
5878
  },
@@ -5851,7 +6039,7 @@
5851
6039
  * user can edit before submitting. */
5852
6040
  applyAgentStarter(idx) {
5853
6041
  const lang = this.composerLanguage();
5854
- const list = lang === "zh" ? this.AGENT_STARTERS_ZH : this.AGENT_STARTERS_EN;
6042
+ const list = this.AGENT_STARTERS_EN;
5855
6043
  const item = list[idx];
5856
6044
  if (!item) return;
5857
6045
  const ta = document.querySelector("[data-agent-composer-desc]");
@@ -5878,39 +6066,27 @@
5878
6066
  { key: "voice", label: "Picking the model voice", startSec: 28, sub: ["matching depth to role"] },
5879
6067
  { key: "polish", label: "Polishing", startSec: 31, sub: ["clamping lengths", "final tightening"] },
5880
6068
  ],
5881
- AGENT_GEN_STAGES_ZH_BASE: [
5882
- { key: "lineage", label: "勾画智识画像", startSec: 0, sub: ["梳理思想脉络", "标记反对的传统", "挑出具体引用"] },
5883
- { key: "imagine", label: "构思角色", startSec: 8, sub: ["勾画语气", "拟定立场", "确定视角"] },
5884
- { key: "name", label: "起名 + handle", startSec: 11, sub: ["试几个短名", "排查 handle 重名"] },
5885
- { key: "bio", label: "起草 bio", startSec: 14, sub: ["一两句话", "点明方法"] },
5886
- { key: "quote", label: "写一句开场问", startSec: 17, sub: ["这位董事每次会议会先问什么"] },
5887
- { key: "instruction", label: "撰写 instruction", startSec: 20, sub: ["脉络 + 概念", "method + 引用集", "语气 + 边界", "失效模式"] },
5888
- { key: "voice", label: "挑选模型嗓音", startSec: 28, sub: ["按角色深度匹配 model"] },
5889
- { key: "polish", label: "收尾打磨", startSec: 31, sub: ["长度修剪", "最后一遍"] },
5890
- ],
5891
6069
  /** Web-search prefix · prepended to the base list when the user
5892
6070
  * opted into web search this run. Adds ~5s to the perceived
5893
- * pipeline; downstream startSecs are shifted in agentGenStagesFor. */
6071
+ * pipeline; downstream startSecs are shifted in agentGenStagesFor.
6072
+ * System UI · English-only. */
5894
6073
  AGENT_GEN_STAGES_WS_EN: { key: "search", label: "Searching the web for context", startSec: 0, sub: ["refining the query", "scanning Brave results", "distilling 5–6 named sources"] },
5895
- AGENT_GEN_STAGES_WS_ZH: { key: "search", label: "联网检索领域上下文", startSec: 0, sub: ["精炼查询", "扫描 Brave 结果", "提炼 5–6 条具名来源"] },
5896
6074
 
5897
6075
  /** Build the active stage list for THIS generation. When web search
5898
- * is on, prepend the search stage and shift everything else later. */
5899
- agentGenStagesFor(lang) {
5900
- const base = lang === "zh" ? this.AGENT_GEN_STAGES_ZH_BASE : this.AGENT_GEN_STAGES_EN_BASE;
6076
+ * is on, prepend the search stage and shift everything else later.
6077
+ * System UI · English-only regardless of `lang`. */
6078
+ agentGenStagesFor(_lang) {
6079
+ const base = this.AGENT_GEN_STAGES_EN_BASE;
5901
6080
  const useWs = !!this._agentGenUsingWebSearch;
5902
6081
  if (!useWs) return base;
5903
- const wsEntry = lang === "zh" ? this.AGENT_GEN_STAGES_WS_ZH : this.AGENT_GEN_STAGES_WS_EN;
5904
6082
  const SHIFT = 5;
5905
6083
  const shifted = base.map((s) => ({ ...s, startSec: s.startSec + SHIFT }));
5906
- return [wsEntry, ...shifted];
6084
+ return [this.AGENT_GEN_STAGES_WS_EN, ...shifted];
5907
6085
  },
5908
6086
 
5909
- /** Back-compat shims · existing references read these names directly.
5910
- * Resolved at call-time so the web-search variant is picked when
5911
- * the flag is set. */
6087
+ /** Back-compat shims · existing references read these names directly. */
5912
6088
  get AGENT_GEN_STAGES_EN() { return this.agentGenStagesFor("en"); },
5913
- get AGENT_GEN_STAGES_ZH() { return this.agentGenStagesFor("zh"); },
6089
+ get AGENT_GEN_STAGES_ZH() { return this.agentGenStagesFor("en"); },
5914
6090
 
5915
6091
  /** Start the stage tick. Called when /generate-spec request fires.
5916
6092
  * Idempotent — calling twice is safe. */
@@ -5925,8 +6101,7 @@
5925
6101
  return;
5926
6102
  }
5927
6103
  const elapsed = (Date.now() - this.agentGenStartedAt) / 1000;
5928
- const lang = this.composerLanguage();
5929
- const stages = lang === "zh" ? this.AGENT_GEN_STAGES_ZH : this.AGENT_GEN_STAGES_EN;
6104
+ const stages = this.AGENT_GEN_STAGES_EN;
5930
6105
  // Find current stage index by elapsed time.
5931
6106
  let idx = 0;
5932
6107
  for (let i = 0; i < stages.length; i++) {
@@ -5957,12 +6132,13 @@
5957
6132
  },
5958
6133
 
5959
6134
  renderAgentGenStagesInner() {
5960
- const lang = this.composerLanguage();
5961
- const stages = lang === "zh" ? this.AGENT_GEN_STAGES_ZH : this.AGENT_GEN_STAGES_EN;
6135
+ // System UI · always English. Agent-generation stage panel is
6136
+ // app chrome around the LLM call.
6137
+ const stages = this.AGENT_GEN_STAGES_EN;
5962
6138
  const active = this.agentGenStageIndex;
5963
6139
  const elapsed = Math.max(0, (Date.now() - this.agentGenStartedAt) / 1000);
5964
- const elapsedLabel = lang === "zh" ? `已耗时 ${Math.round(elapsed)} s` : `${Math.round(elapsed)} s elapsed`;
5965
- const headerLabel = lang === "zh" ? "正在生成 director" : "Summoning director";
6140
+ const elapsedLabel = `${Math.round(elapsed)} s elapsed`;
6141
+ const headerLabel = "Summoning director";
5966
6142
  const sigilSvg = this.renderAgentGenSigilSvg(stages, active, elapsed);
5967
6143
  // The active stage's headline pulse — lifted out from the list so
5968
6144
  // it reads as the focal "what's happening RIGHT NOW" line.
@@ -5980,7 +6156,7 @@
5980
6156
  <div class="ag-gen-stage-area">
5981
6157
  <div class="ag-gen-sigil" aria-hidden="true">${sigilSvg}</div>
5982
6158
  <div class="ag-gen-active-block">
5983
- <div class="ag-gen-active-kicker">${this.escape(lang === "zh" ? `第 ${active + 1} / ${stages.length} 步` : `step ${active + 1} of ${stages.length}`)}</div>
6159
+ <div class="ag-gen-active-kicker">${this.escape(`step ${active + 1} of ${stages.length}`)}</div>
5984
6160
  <div class="ag-gen-active-label">${this.escape(activeStage ? activeStage.label : "")}</div>
5985
6161
  ${activeSubText ? `<div class="ag-gen-active-sub">${this.escape(activeSubText)}</div>` : ""}
5986
6162
  </div>
@@ -6096,42 +6272,28 @@
6096
6272
  { 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." },
6097
6273
  { tag: "phenomenologist", text: "An observer who notices what the room ISN'T saying. Tracks tone, what got skipped, who agreed too fast." },
6098
6274
  ],
6099
- AGENT_STARTERS_ZH: [
6100
- { tag: "long-horizon", text: "一位向前看四步的战略家,区分『此刻』和『真正起作用的时间点』,逼问决策落到哪个 horizon 上。" },
6101
- { tag: "user-empathy", text: "一位从用户摩擦时刻反推的产品老兵,反对任何不说清『用户那一刻在干嘛』的论点。" },
6102
- { tag: "first-principles", text: "一位把问题拆到可观测、因果链上的物理学家,拒绝从类比里搬假设。" },
6103
- { tag: "value-investor", text: "一位用三十年品类史做底的长周期读者,新点子要先和三个老案例对照才相信。" },
6104
- { tag: "critique-reviewer", text: "一位资深评审,对任何交付物做系统性审稿——每个瑕疵打 blocker / major / minor 严重度,指向具体段落、说出失败机制。不挑出至少一条 major 不会放过。" },
6105
- { tag: "phenomenologist", text: "一位观察者,捕捉房间里没说出来的东西:语气、被跳过的话题、太快达成的一致。" },
6106
- ],
6275
+ /** Legacy ZH list · kept as an alias of EN so any external caller
6276
+ * reading the property still resolves. System UI is English-only
6277
+ * per the global rule (the brief language doesn't change app
6278
+ * chrome). New code should reference AGENT_STARTERS_EN directly. */
6279
+ get AGENT_STARTERS_ZH() { return this.AGENT_STARTERS_EN; },
6107
6280
 
6108
6281
  renderAgentComposerHtml() {
6109
6282
  const userName = (this.prefs?.name || "you").trim() || "you";
6110
6283
  const lang = this.composerLanguage();
6111
6284
  const greeting = this.composerGreeting(lang, userName);
6112
- const t = lang === "zh"
6113
- ? {
6114
- greet: greeting,
6115
- prompt: "想招一位什么样的董事?",
6116
- placeholder: "几句话描述这位董事的角色、方法、立场。比如:一位每件事都从用户视角出发的产品老兵,会反对任何不带『用户在那一刻干嘛』的论点。",
6117
- cta: "Generate",
6118
- ctaHint: "AI 会生成一份完整 spec,你可以再调",
6119
- manual: "手动配置",
6120
- generating: "生成中…",
6121
- modelLabel: "model",
6122
- starterCaption: "或者从一个 archetype 起手",
6123
- }
6124
- : {
6125
- greet: greeting,
6126
- prompt: "What kind of director do you want?",
6127
- placeholder: "A few sentences on their role, method, stance. e.g. A seasoned product hand who reasons from the user's moment of friction. Will reject any argument that doesn't name what the user is doing right then.",
6128
- cta: "Generate",
6129
- ctaHint: "AI drafts the full spec — you'll edit before saving",
6130
- manual: "Configure manually",
6131
- generating: "Generating…",
6132
- modelLabel: "model",
6133
- starterCaption: "or start from an archetype",
6134
- };
6285
+ // System UI · always English (new-agent composer chrome).
6286
+ const t = {
6287
+ greet: greeting,
6288
+ prompt: "What kind of director do you want?",
6289
+ placeholder: "A few sentences on their role, method, stance. e.g. A seasoned product hand who reasons from the user's moment of friction. Will reject any argument that doesn't name what the user is doing right then.",
6290
+ cta: "Generate",
6291
+ ctaHint: "AI drafts the full spec — you'll edit before saving",
6292
+ manual: "Configure manually",
6293
+ generating: "Generating…",
6294
+ modelLabel: "model",
6295
+ starterCaption: "or start from an archetype",
6296
+ };
6135
6297
  // If we already have a spec preview, render that instead of the input.
6136
6298
  if (this.agentSpec) {
6137
6299
  return this.renderAgentSpecPreviewHtml(this.agentSpec, lang);
@@ -6145,7 +6307,7 @@
6145
6307
  const generating = this.agentSpecGenerating;
6146
6308
  const currentModel = this.loadAgentComposerModel();
6147
6309
  const modelDisplay = MODEL_LABELS[currentModel] || currentModel;
6148
- const starters = lang === "zh" ? this.AGENT_STARTERS_ZH : this.AGENT_STARTERS_EN;
6310
+ const starters = this.AGENT_STARTERS_EN;
6149
6311
  const starterCards = starters.map((q, idx) => `
6150
6312
  <button type="button" class="cmp-starter" data-agent-starter="${idx}">
6151
6313
  <div class="cmp-starter-tag">${this.escape(q.tag)}</div>
@@ -6181,23 +6343,14 @@
6181
6343
  // skill row at agent-profile.js:1411.
6182
6344
  const configured = this.agentComposerBraveConfigured();
6183
6345
  const on = configured && this.loadAgentComposerWebSearch();
6184
- const stateLabel = !configured
6185
- ? (lang === "zh" ? "未配置" : "needs key")
6186
- : on
6187
- ? (lang === "zh" ? "已开启" : "enabled")
6188
- : (lang === "zh" ? "已关闭" : "disabled");
6346
+ // System UI · always English (web-search toggle chrome).
6347
+ const stateLabel = !configured ? "needs key" : on ? "enabled" : "disabled";
6189
6348
  const titleText = !configured
6190
- ? (lang === "zh"
6191
- ? "联网搜索需要 Brave Search API key · 点击配置"
6192
- : "Web search needs a Brave Search API key · click to configure")
6349
+ ? "Web search needs a Brave Search API key · click to configure"
6193
6350
  : on
6194
- ? (lang === "zh"
6195
- ? "生成时联网检索领域真实案例 · 点击关闭"
6196
- : "Search the web for real domain references during generation · click to disable")
6197
- : (lang === "zh"
6198
- ? "生成时不联网 · 点击开启"
6199
- : "Generation runs offline · click to enable web search");
6200
- const wsLabel = lang === "zh" ? "联网搜索" : "web search";
6351
+ ? "Search the web for real domain references during generation · click to disable"
6352
+ : "Generation runs offline · click to enable web search";
6353
+ const wsLabel = "web search";
6201
6354
  const cls = [
6202
6355
  "ap-skill-row-toggle",
6203
6356
  "cmp-ws-toggle",
@@ -6243,38 +6396,23 @@
6243
6396
  },
6244
6397
 
6245
6398
  /** Preview card · all generated fields editable inline. */
6246
- renderAgentSpecPreviewHtml(spec, lang) {
6247
- const t = lang === "zh"
6248
- ? {
6249
- kicker: "// 生成的 director · 编辑后保存",
6250
- avatar: "头像",
6251
- reroll: "换一个",
6252
- name: "Name",
6253
- handle: "Handle",
6254
- role: "Role tag",
6255
- bio: "Bio",
6256
- quote: "Cover quote",
6257
- instruction: "Instruction",
6258
- model: "Model",
6259
- save: "Save director",
6260
- discard: "丢弃",
6261
- redo: "重新生成",
6262
- }
6263
- : {
6264
- kicker: "// generated director · edit and save",
6265
- avatar: "Avatar",
6266
- reroll: "Reroll",
6267
- name: "Name",
6268
- handle: "Handle",
6269
- role: "Role tag",
6270
- bio: "Bio",
6271
- quote: "Cover quote",
6272
- instruction: "Instruction",
6273
- model: "Model",
6274
- save: "Save director",
6275
- discard: "Discard",
6276
- redo: "Regenerate",
6277
- };
6399
+ renderAgentSpecPreviewHtml(spec, _lang) {
6400
+ // System UI · always English (agent-spec preview card chrome).
6401
+ const t = {
6402
+ kicker: "// generated director · edit and save",
6403
+ avatar: "Avatar",
6404
+ reroll: "Reroll",
6405
+ name: "Name",
6406
+ handle: "Handle",
6407
+ role: "Role tag",
6408
+ bio: "Bio",
6409
+ quote: "Cover quote",
6410
+ instruction: "Instruction",
6411
+ model: "Model",
6412
+ save: "Save director",
6413
+ discard: "Discard",
6414
+ redo: "Regenerate",
6415
+ };
6278
6416
  const seed = this.agentSpecAvatarSeed;
6279
6417
  const avatarSvg = (window.AvatarSkill && seed)
6280
6418
  ? window.AvatarSkill.generate(seed, { size: 96 })
@@ -6361,25 +6499,16 @@
6361
6499
  * composer back to its input state). The description text is
6362
6500
  * surfaced read-only so the user can copy it before discard. */
6363
6501
  renderAgentSpecErrorHtml(err, lang) {
6364
- const t = lang === "zh"
6365
- ? {
6366
- kicker: err.kind === "timeout" ? "// 生成超时" : "// 生成失败",
6367
- title: err.kind === "timeout" ? "生成超过 5 分钟仍未完成" : "生成失败",
6368
- hintTimeout: "可能是模型回应过慢、网络波动,或后端 LLM 流水线卡住了。点击重试再来一次。",
6369
- hintFailed: "请确认 API key 配置正确、模型可达。重试通常能解决临时性失败。",
6370
- descLabel: "你的描述(重试时会复用)",
6371
- retry: "重试",
6372
- discard: "放弃",
6373
- }
6374
- : {
6375
- kicker: err.kind === "timeout" ? "// generation timed out" : "// generation failed",
6376
- title: err.kind === "timeout" ? "Generation didn't complete after 5 minutes" : "Generation failed",
6377
- hintTimeout: "The model may be slow, the network flaky, or the backend pipeline stalled. Click retry to start a fresh run.",
6378
- hintFailed: "Check your API key configuration and model reachability. Retry often clears transient failures.",
6379
- descLabel: "Your description (re-used on retry)",
6380
- retry: "Retry",
6381
- discard: "Discard",
6382
- };
6502
+ // System UI · always English (agent-spec error card chrome).
6503
+ const t = {
6504
+ kicker: err.kind === "timeout" ? "// generation timed out" : "// generation failed",
6505
+ title: err.kind === "timeout" ? "Generation didn't complete after 5 minutes" : "Generation failed",
6506
+ hintTimeout: "The model may be slow, the network flaky, or the backend pipeline stalled. Click retry to start a fresh run.",
6507
+ hintFailed: "Check your API key configuration and model reachability. Retry often clears transient failures.",
6508
+ descLabel: "Your description (re-used on retry)",
6509
+ retry: "Retry",
6510
+ discard: "Discard",
6511
+ };
6383
6512
  const desc = this._agentComposerLastDesc || "";
6384
6513
  const hint = err.kind === "timeout" ? t.hintTimeout : t.hintFailed;
6385
6514
  const detail = err.message ? `<div class="ag-gen-error-detail">${this.escape(err.message)}</div>` : "";
@@ -6387,7 +6516,7 @@
6387
6516
  <section class="cmp ag-cmp">
6388
6517
  <header class="cmp-hero">
6389
6518
  <div class="cmp-greet">${this.escape(this.composerGreeting(lang, (this.prefs?.name || "you").trim() || "you"))}</div>
6390
- <h1 class="cmp-prompt">${this.escape(lang === "zh" ? "想招一位什么样的董事?" : "What kind of director do you want?")}</h1>
6519
+ <h1 class="cmp-prompt">${this.escape("What kind of director do you want?")}</h1>
6391
6520
  </header>
6392
6521
  <div class="ag-gen-error-card">
6393
6522
  <div class="ag-gen-error-kicker">${this.escape(t.kicker)}</div>
@@ -6552,13 +6681,11 @@
6552
6681
  this.agentSpecError = null;
6553
6682
  } catch (e) {
6554
6683
  const isAbort = (e && (e.name === "AbortError" || /aborted/i.test(String(e.message))));
6555
- const lang = this.composerLanguage();
6556
6684
  if (timedOut || isAbort) {
6685
+ // System UI · always English (error message text).
6557
6686
  this.agentSpecError = {
6558
6687
  kind: "timeout",
6559
- message: lang === "zh"
6560
- ? "生成超过 5 分钟仍未完成 · 模型回应慢、网络波动,或后端流水线卡住了。"
6561
- : "Generation took longer than 5 minutes · the model may be slow, the network flaky, or the backend stuck.",
6688
+ message: "Generation took longer than 5 minutes · the model may be slow, the network flaky, or the backend stuck.",
6562
6689
  };
6563
6690
  } else {
6564
6691
  this.agentSpecError = {
@@ -6722,10 +6849,9 @@
6722
6849
  // Sort newest-first · the most recently filed brief is the one a
6723
6850
  // returning user most likely wants to re-open.
6724
6851
  briefs.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
6725
- const lang = (this.currentRoom?.subject && /[一-鿿]/.test(this.currentRoom.subject)) ? "zh" : "en";
6726
- const t = lang === "zh"
6727
- ? { title: "选择一份报告打开", supplementPrefix: "补充视角:", initial: "初版", filed: "已归档" }
6728
- : { title: "Open a report", supplementPrefix: "Supplement: ", initial: "Initial", filed: "filed" };
6852
+ // System UI · always English. Brief picker chrome (popover
6853
+ // title, row labels) doesn't follow the brief language.
6854
+ const t = { title: "Open a report", supplementPrefix: "Supplement: ", initial: "Initial", filed: "filed" };
6729
6855
  const initialIdx = briefs.length - 1; // oldest is "Initial"
6730
6856
  const rows = briefs.map((b, i) => {
6731
6857
  // The numbering is stable across renders: oldest = 01, newest
@@ -6740,11 +6866,11 @@
6740
6866
  : "";
6741
6867
  const subtitle = isInitial ? t.initial : (supplementSnippet ? `${t.supplementPrefix}${supplementSnippet}` : "");
6742
6868
  const filedLabel = b.createdAt
6743
- ? new Date(b.createdAt).toLocaleString(lang === "zh" ? "zh-CN" : undefined, {
6869
+ ? new Date(b.createdAt).toLocaleString(undefined, {
6744
6870
  year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
6745
6871
  })
6746
6872
  : "";
6747
- const href = `/report.html?r=${encodeURIComponent(roomId)}&b=${encodeURIComponent(b.id)}`;
6873
+ const href = this.briefViewerHref(b, roomId);
6748
6874
  return `
6749
6875
  <a class="brief-picker-row" href="${this.escape(href)}" target="_blank" rel="noopener" data-brief-picker-row data-brief-id="${this.escape(b.id)}">
6750
6876
  <span class="brief-picker-num">${this.escape(num)}</span>
@@ -6834,10 +6960,8 @@
6834
6960
  const dirs = (this.agents || [])
6835
6961
  .filter((a) => a.roleKind !== "moderator")
6836
6962
  .sort((a, b) => (a.name || "").localeCompare(b.name || ""));
6837
- const lang = this.composerLanguage();
6838
- const t = lang === "zh"
6839
- ? { title: "选择董事", hint: "建议 2-4 位", done: "完成", info: "查看资料" }
6840
- : { title: "Pick directors", hint: "2-4 recommended", done: "Done", info: "View profile" };
6963
+ // System UI · always English (composer director picker chrome).
6964
+ const t = { title: "Pick directors", hint: "2-4 recommended", done: "Done", info: "View profile" };
6841
6965
  const rows = dirs.map((a) => {
6842
6966
  const checked = state.directorIds.includes(a.id);
6843
6967
  const modelLabel = MODEL_LABELS[a.modelV] || a.modelV || "";
@@ -6974,17 +7098,16 @@
6974
7098
  const isAutoPick = state.autoPickDirectors === true && dirObjs.length === 0;
6975
7099
  btn.classList.toggle("cmp-cast-btn-auto", isAutoPick);
6976
7100
  if (isAutoPick) {
6977
- const autoTip = lang === "zh"
6978
- ? "Convene 时由 chair 根据主题挑选 3 位董事 · 点击手动选择"
6979
- : "Chair picks 3 directors based on your subject when you Convene · click to pick manually";
7101
+ // System UI · always English (auto-pick chip tooltip).
7102
+ const autoTip = "Chair picks 3 directors based on your subject when you Convene · click to pick manually";
6980
7103
  btn.title = autoTip;
6981
7104
  btn.innerHTML = `
6982
7105
  <span class="cmp-cast-stack cmp-cast-stack-auto" data-cast-auto>
6983
7106
  <span class="cmp-cast-auto-mark">✦</span>
6984
7107
  </span>
6985
7108
  <span class="cmp-cast-count cmp-cast-auto-label">
6986
- <span class="cmp-cast-auto-key">${lang === "zh" ? "董事" : "directors"}</span>
6987
- <span class="cmp-cast-auto-val">${lang === "zh" ? "自动挑选" : "auto-pick"}</span>
7109
+ <span class="cmp-cast-auto-key">directors</span>
7110
+ <span class="cmp-cast-auto-val">auto-pick</span>
6988
7111
  </span>
6989
7112
  `;
6990
7113
  return;
@@ -6992,9 +7115,10 @@
6992
7115
  const visible = dirObjs.slice(0, 4);
6993
7116
  const overflow = Math.max(0, dirObjs.length - 4);
6994
7117
  const avs = visible.map((a) => `<img class="cmp-cast-av" src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" title="${this.escape(a.name)}">`).join("");
7118
+ // System UI · always English (cast button count chrome).
6995
7119
  const countText = dirObjs.length
6996
- ? (lang === "zh" ? `${dirObjs.length} 位董事` : `${dirObjs.length} director${dirObjs.length === 1 ? "" : "s"}`)
6997
- : (lang === "zh" ? "未选董事" : "no directors");
7120
+ ? `${dirObjs.length} director${dirObjs.length === 1 ? "" : "s"}`
7121
+ : "no directors";
6998
7122
  const countCls = dirObjs.length ? "cmp-cast-count" : "cmp-cast-count cmp-cast-empty";
6999
7123
  btn.innerHTML = `
7000
7124
  <span class="cmp-cast-stack">
@@ -7042,21 +7166,14 @@
7042
7166
  let opts;
7043
7167
  let current;
7044
7168
  if (kind === "tone") {
7045
- opts = lang === "zh"
7046
- ? [
7047
- { v: "brainstorm", label: "Brainstorm", hint: "共同发散" },
7048
- { v: "constructive", label: "Constructive", hint: "推一把" },
7049
- { v: "research", label: "Research", hint: "梳理材料找洞察" },
7050
- { v: "debate", label: "Debate", hint: "找漏洞" },
7051
- { v: "critique", label: "Critique", hint: "系统性挑毛病" },
7052
- ]
7053
- : [
7054
- { v: "brainstorm", label: "Brainstorm", hint: "yes-and" },
7055
- { v: "constructive", label: "Constructive", hint: "push & sharpen" },
7056
- { v: "research", label: "Research", hint: "mine the material" },
7057
- { v: "debate", label: "Debate", hint: "find the holes" },
7058
- { v: "critique", label: "Critique", hint: "audit the deliverable" },
7059
- ];
7169
+ // System UI · always English (tune dropdown options).
7170
+ opts = [
7171
+ { v: "brainstorm", label: "Brainstorm", hint: "yes-and" },
7172
+ { v: "constructive", label: "Constructive", hint: "push & sharpen" },
7173
+ { v: "research", label: "Research", hint: "mine the material" },
7174
+ { v: "debate", label: "Debate", hint: "find the holes" },
7175
+ { v: "critique", label: "Critique", hint: "audit the deliverable" },
7176
+ ];
7060
7177
  if (followUpScope) {
7061
7178
  const valSpan = triggerBtn.querySelector("[data-cmp-dd-value]");
7062
7179
  current = valSpan ? (valSpan.textContent || "").trim().toLowerCase() : "";
@@ -7068,17 +7185,12 @@
7068
7185
  // the third value (terse) is a cadence dial, not a harshness
7069
7186
  // dial. The earlier "no prisoners" / "直击痛点" copy pulled
7070
7187
  // users into thinking it controlled tone.
7071
- opts = lang === "zh"
7072
- ? [
7073
- { v: "calm", label: "Calm", hint: "慢慢说" },
7074
- { v: "sharp", label: "Sharp", hint: "不绕弯" },
7075
- { v: "terse", label: "Terse", hint: "一句话" },
7076
- ]
7077
- : [
7078
- { v: "calm", label: "Calm", hint: "let them think" },
7079
- { v: "sharp", label: "Sharp", hint: "no hedging" },
7080
- { v: "terse", label: "Terse", hint: "telegraphic" },
7081
- ];
7188
+ // System UI · always English (intensity dropdown options).
7189
+ opts = [
7190
+ { v: "calm", label: "Calm", hint: "let them think" },
7191
+ { v: "sharp", label: "Sharp", hint: "no hedging" },
7192
+ { v: "terse", label: "Terse", hint: "telegraphic" },
7193
+ ];
7082
7194
  if (followUpScope) {
7083
7195
  const valSpan = triggerBtn.querySelector("[data-cmp-dd-value]");
7084
7196
  current = valSpan ? (valSpan.textContent || "").trim().toLowerCase() : "";
@@ -7221,7 +7333,7 @@
7221
7333
  const useAutoPick = state.autoPickDirectors === true && state.directorIds.length === 0;
7222
7334
  if (!useAutoPick && !state.directorIds.length) {
7223
7335
  const lang = this.composerLanguage();
7224
- alert(lang === "zh" ? "请至少选择一位董事再 convene" : "Pick at least one director before convening");
7336
+ alert("Pick at least one director before convening");
7225
7337
  return;
7226
7338
  }
7227
7339
  const btn = document.querySelector("[data-composer-go]");
@@ -7428,14 +7540,14 @@
7428
7540
  // opens the picker.
7429
7541
  const briefs = Array.isArray(this.currentBriefs) ? this.currentBriefs : [];
7430
7542
  const multi = briefs.length > 1;
7431
- const directHref = `/report.html?r=${this.escape(r.id)}${this.currentBrief.id ? `&b=${this.escape(this.currentBrief.id)}` : ""}`;
7543
+ const directHref = this.briefViewerHref(this.currentBrief, r.id) || `/report.html?r=${this.escape(r.id)}`;
7432
7544
  if (!multi) {
7433
7545
  return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report>[ View Report ]</a>`;
7434
7546
  }
7435
- return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report data-view-report-trigger title="${this.escape(this.composerLanguage() === "zh" ? `${briefs.length} 份报告 · 点击选择` : `${briefs.length} reports · click to choose`)}">[ View Report <span class="vr-count">· ${briefs.length}</span> ▾ ]</a>`;
7547
+ return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report data-view-report-trigger title="${this.escape(`${briefs.length} reports · click to choose`)}">[ View Report <span class="vr-count">· ${briefs.length}</span> ▾ ]</a>`;
7436
7548
  })()
7437
7549
  : (r.status === "adjourned"
7438
- ? `<a href="#" class="view-report-btn generate-report" data-generate-brief title="${this.escape(this.composerLanguage() === "zh" ? "为这次会议补出一份报告" : "File a brief from this session")}"><span class="vr-mark">▸</span> ${this.escape(this.composerLanguage() === "zh" ? "生成报告" : "Generate Report")}</a>`
7550
+ ? `<a href="#" class="view-report-btn generate-report" data-generate-brief title="${this.escape("File a brief from this session")}"><span class="vr-mark">▸</span> Generate Report</a>`
7439
7551
  : "")}
7440
7552
  </div>
7441
7553
  `;
@@ -7516,23 +7628,17 @@
7516
7628
  conveningCardHtml() {
7517
7629
  const s = this.conveneState;
7518
7630
  if (!s) return "";
7519
- const lang = (s.subject && /[一-鿿]/.test(s.subject)) ? "zh" : "en";
7520
-
7631
+ // System UI · always English. Convening overlay (analyzing /
7632
+ // seating / preparing labels + decks) is app chrome.
7521
7633
  // Stages · auto-picked rooms run all three; manually-cast rooms
7522
7634
  // skip "analyzing" + "seating" because the cast is pre-set.
7523
7635
  const stageOrder = s.autoPicked
7524
7636
  ? ["analyzing", "seating", "preparing"]
7525
7637
  : ["preparing"];
7526
7638
  const STAGE_LABELS = {
7527
- analyzing: lang === "zh"
7528
- ? { title: "分析议题", deck: "haiku 路由器在拆解你提的话题" }
7529
- : { title: "Analyzing topic", deck: "Routing your topic to the right perspectives" },
7530
- seating: lang === "zh"
7531
- ? { title: "邀请董事", deck: "依据议题匹配的董事正在入席" }
7532
- : { title: "Seating directors", deck: "Picking the right perspectives for this question" },
7533
- preparing: lang === "zh"
7534
- ? { title: "主席组织开场陈词", deck: "主席正在准备介绍发言" }
7535
- : { title: "Chair preparing remarks", deck: "Drafting the convening speech" },
7639
+ analyzing: { title: "Analyzing topic", deck: "Routing your topic to the right perspectives" },
7640
+ seating: { title: "Seating directors", deck: "Picking the right perspectives for this question" },
7641
+ preparing: { title: "Chair preparing remarks", deck: "Drafting the convening speech" },
7536
7642
  };
7537
7643
 
7538
7644
  const currentIdx = stageOrder.indexOf(s.stage);
@@ -7564,7 +7670,7 @@
7564
7670
  // already in currentMembers; rendering it here would be redundant).
7565
7671
  const seatedRow = (s.autoPicked && s.seated.length > 0) ? `
7566
7672
  <div class="conv-seated">
7567
- <div class="conv-seated-label">${lang === "zh" ? "已入席" : "seated"} · ${s.seated.length}</div>
7673
+ <div class="conv-seated-label">seated · ${s.seated.length}</div>
7568
7674
  <div class="conv-seated-list">
7569
7675
  ${s.seated.map((a) => `
7570
7676
  <div class="conv-seated-item" data-agent-id="${this.escape(a.id)}">
@@ -7581,7 +7687,7 @@
7581
7687
 
7582
7688
  return `
7583
7689
  <article class="convening-card" data-convene-card>
7584
- <div class="conv-eyebrow">${lang === "zh" ? "▸ 召集中" : "▸ CONVENING"}</div>
7690
+ <div class="conv-eyebrow">▸ CONVENING</div>
7585
7691
  ${s.subject ? `<blockquote class="conv-subject">${this.escape(s.subject)}</blockquote>` : ""}
7586
7692
  <ul class="conv-stages">${stagesHtml}</ul>
7587
7693
  ${seatedRow}
@@ -7618,11 +7724,9 @@
7618
7724
  const chat = document.querySelector("[data-chat-messages]");
7619
7725
  if (!chat) return;
7620
7726
  const existing = chat.querySelector("[data-chair-pending]");
7621
- const isCjk = /[一-鿿]/.test(this.currentRoom?.subject || "");
7622
- const chairName = (this.currentChair?.name) || (isCjk ? "主席" : "Chair");
7623
- const labelMap = isCjk
7624
- ? { clarify: "正在整理你的问题", "chair-direct": "正在准备回应", "round-end": "正在收束这一轮", convening: "正在召集会议", "next-speaker": "正在判断接下来的发言", chair: "正在准备" }
7625
- : { clarify: "is reading your question", "chair-direct": "is preparing a response", "round-end": "is wrapping up the round", convening: "is convening the room", "next-speaker": "is reading the room", chair: "is preparing" };
7727
+ // System UI · always English (chair-pending placeholder chrome).
7728
+ const chairName = (this.currentChair?.name) || "Chair";
7729
+ const labelMap = { clarify: "is reading your question", "chair-direct": "is preparing a response", "round-end": "is wrapping up the round", convening: "is convening the room", "next-speaker": "is reading the room", chair: "is preparing" };
7626
7730
  const phraseRaw = labelMap[phase] || labelMap.chair;
7627
7731
  const phrase = `${chairName} ${phraseRaw}…`;
7628
7732
  if (existing) {
@@ -7635,7 +7739,7 @@
7635
7739
  node.setAttribute("data-chair-pending", "");
7636
7740
  node.innerHTML = `
7637
7741
  <div class="cp-rule" aria-hidden="true"></div>
7638
- <div class="cp-kicker">▸ ${this.escape(isCjk ? "主席" : "chair")}</div>
7742
+ <div class="cp-kicker">▸ chair</div>
7639
7743
  <div class="cp-body">
7640
7744
  <span class="cp-text">${this.escape(phrase)}</span>
7641
7745
  <span class="thinking-dots"><span></span><span></span><span></span></span>
@@ -7817,7 +7921,7 @@
7817
7921
  // 中文 copy. Reads from the chair's prompt body which lands in
7818
7922
  // the message body when a recommendation is present.
7819
7923
  const lang = (promptMsg && promptMsg.body && /[一-鿿]/.test(promptMsg.body)) ? "zh" : "en";
7820
- const recLabel = lang === "zh" ? "主席建议" : "chair recommends";
7924
+ const recLabel = "chair recommends";
7821
7925
  const recIndicator = recKind
7822
7926
  ? `
7823
7927
  <div class="rp-rec-line rp-rec-line-${this.escape(recKind)}">
@@ -8045,14 +8149,14 @@
8045
8149
  const truncated = parentSubject.length > 70
8046
8150
  ? parentSubject.slice(0, 70) + "…"
8047
8151
  : parentSubject;
8048
- const isZh = /[一-鿿]/.test(this.currentRoom?.subject || "");
8049
- const label = isZh ? "继续自" : "Following up on";
8050
- const roomTag = isZh ? "Room #" : "Room #";
8152
+ // System UI · always English (follow-up origin link chrome).
8153
+ const label = "Following up on";
8154
+ const roomTag = "Room #";
8051
8155
  const subjectChunk = truncated
8052
8156
  ? `<span class="convene-origin-sep">·</span><span class="convene-origin-subject">${this.escape(truncated)}</span>`
8053
8157
  : "";
8054
8158
  originHtml = `
8055
- <a class="convene-origin" href="#/r/${this.escape(parentId)}" data-parent-room-id="${this.escape(parentId)}" title="${this.escape((isZh ? "返回上一场会议 · " : "Open the prior session · ") + (parentSubject || parentId))}">
8159
+ <a class="convene-origin" href="#/r/${this.escape(parentId)}" data-parent-room-id="${this.escape(parentId)}" title="${this.escape("Open the prior session · " + (parentSubject || parentId))}">
8056
8160
  <span class="convene-origin-arrow">↩</span>
8057
8161
  <span class="convene-origin-label">${this.escape(label)}</span>
8058
8162
  <span class="convene-origin-room">${this.escape(roomTag)}${this.escape(String(parentNum))}</span>
@@ -8065,9 +8169,11 @@
8065
8169
  parentId ? "convene-opener-followup" : "",
8066
8170
  isLongOpener ? "convene-opener-clamped" : "",
8067
8171
  ].filter(Boolean).join(" ");
8068
- const isZhLang = /[一-鿿]/.test(m.body || "") || /[一-鿿]/.test(this.currentRoom?.subject || "");
8069
- const moreLabel = isZhLang ? "展开全文 ↓" : "Show more ↓";
8070
- const lessLabel = isZhLang ? "收起 ↑" : "Show less ↑";
8172
+ // System UI · always English. Earlier this followed the brief
8173
+ // language; now fixed-string per the rule that app chrome stays
8174
+ // English regardless of the user's query language.
8175
+ const moreLabel = "Show more ↓";
8176
+ const lessLabel = "Show less ↑";
8071
8177
  const toggleHtml = isLongOpener
8072
8178
  ? `<button type="button" class="convene-toggle" data-convene-toggle data-more="${this.escape(moreLabel)}" data-less="${this.escape(lessLabel)}">${moreLabel}</button>`
8073
8179
  : "";
@@ -8293,7 +8399,7 @@
8293
8399
  // as a historical marker only.
8294
8400
  if (isChair && metaKind === "no-brief") {
8295
8401
  const ts = this.timeFmt(m.createdAt);
8296
- const isZh = this.composerLanguage() === "zh";
8402
+ // System UI · always English (no-brief card chrome).
8297
8403
  const hasBrief = !!this.currentBrief;
8298
8404
  const cta = hasBrief
8299
8405
  ? ""
@@ -8301,7 +8407,7 @@
8301
8407
  <div class="nb-actions">
8302
8408
  <button type="button" class="nb-cta" data-generate-brief>
8303
8409
  <span class="nb-cta-mark">▸</span>
8304
- <span class="nb-cta-text">${isZh ? "生成报告" : "Generate report now"}</span>
8410
+ <span class="nb-cta-text">Generate report now</span>
8305
8411
  </button>
8306
8412
  </div>
8307
8413
  `;
@@ -8312,7 +8418,7 @@
8312
8418
  <span class="nb-eyebrow">adjourned · no brief filed</span>
8313
8419
  </span>
8314
8420
  <div class="nb-body">
8315
- <strong>${this.escape(this.prefs?.name || "The chair")}</strong> ${isZh ? "在结束时跳过了报告。" : "declared no report is needed for this session."}
8421
+ <strong>${this.escape(this.prefs?.name || "The chair")}</strong> declared no report is needed for this session.
8316
8422
  </div>
8317
8423
  ${cta}
8318
8424
  <div class="nb-meta">${this.escape(ts)}</div>
@@ -8763,31 +8869,17 @@
8763
8869
  * research note reads "sweet." */
8764
8870
  _briefWordCountTip(wc) {
8765
8871
  if (!wc) return "";
8766
- const isZh = wc.isCjk;
8767
- const toneLabel = ({
8768
- brainstorm: isZh ? "脑暴" : "brainstorm",
8769
- constructive: isZh ? "构建" : "constructive",
8770
- debate: isZh ? "辩论" : "debate",
8771
- research: isZh ? "研究" : "research",
8772
- critique: isZh ? "评审" : "critique",
8773
- })[wc.tone] || wc.tone;
8872
+ // System UI · always English (word-count chip tooltip).
8873
+ const toneLabel = wc.tone;
8774
8874
  const range = `${wc.bands.sweetLo.toLocaleString("en-US")}-${wc.bands.sweetHi.toLocaleString("en-US")}`;
8775
- const unit = isZh ? "字" : "words";
8776
- const copy = isZh
8777
- ? {
8778
- "thin": `偏短 · ${toneLabel} 模式甜点区约 ${range} ${unit}`,
8779
- "sweet": `甜点区 · ${toneLabel} 模式正合适`,
8780
- "dense": `偏密集 · 已超出 ${toneLabel} 模式甜点区,但仍可读`,
8781
- "long": `偏长 · 接近"会被存档而非阅读"的临界`,
8782
- "too-long": `过长 · 大概率会被快速跳读`,
8783
- }
8784
- : {
8785
- "thin": `Lean · ${toneLabel} sweet zone is ${range} ${unit}`,
8786
- "sweet": `Sweet zone for ${toneLabel} · most read-through-able length`,
8787
- "dense": `Dense · past the ${toneLabel} sweet zone, still readable`,
8788
- "long": `Long · approaching "filed instead of read"`,
8789
- "too-long": `Too long · likely to be skimmed, not read`,
8790
- };
8875
+ const unit = "words";
8876
+ const copy = {
8877
+ "thin": `Lean · ${toneLabel} sweet zone is ${range} ${unit}`,
8878
+ "sweet": `Sweet zone for ${toneLabel} · most read-through-able length`,
8879
+ "dense": `Dense · past the ${toneLabel} sweet zone, still readable`,
8880
+ "long": `Long · approaching "filed instead of read"`,
8881
+ "too-long": `Too long · likely to be skimmed, not read`,
8882
+ };
8791
8883
  return copy[wc.tier] || "";
8792
8884
  },
8793
8885
 
@@ -8800,9 +8892,7 @@
8800
8892
  const briefs = Array.isArray(this.currentBriefs) ? this.currentBriefs : [];
8801
8893
  if (briefs.length < 2) return "";
8802
8894
  const sortedBriefs = briefs.slice().sort((x, y) => (x.createdAt || 0) - (y.createdAt || 0));
8803
- const lang = (activeBrief && activeBrief.language === "zh")
8804
- || (this.currentRoom?.subject && /[一-鿿]/.test(this.currentRoom.subject))
8805
- ? "zh" : "en";
8895
+ // System UI · always English (brief-version tab strip chrome).
8806
8896
  return `
8807
8897
  <div class="brief-versions">
8808
8898
  ${sortedBriefs.map((bf, i) => {
@@ -8811,11 +8901,11 @@
8811
8901
  const isInitial = i === 0;
8812
8902
  const supp = bf.supplement && bf.supplement.trim()
8813
8903
  ? bf.supplement.trim()
8814
- : (isInitial ? (lang === "zh" ? "初版" : "Initial") : "");
8904
+ : (isInitial ? "Initial" : "");
8815
8905
  const tooltip = isInitial
8816
- ? (lang === "zh" ? `初版报告 · 由会议本身生成` : `Initial brief · generated from the session`)
8817
- : `${lang === "zh" ? "补充视角:" : "Supplement: "}${supp || "—"}`;
8818
- const closeTitle = lang === "zh" ? "删除这份报告" : "Delete this report";
8906
+ ? `Initial brief · generated from the session`
8907
+ : `Supplement: ${supp || "—"}`;
8908
+ const closeTitle = "Delete this report";
8819
8909
  // Errored / interrupted / timed-out tabs get a small
8820
8910
  // visual marker so the user can spot which one needs
8821
8911
  // attention without entering it. The full retry UI is
@@ -8829,7 +8919,7 @@
8829
8919
  <span class="brief-version-num">${num}</span>
8830
8920
  ${stateMark}
8831
8921
  ${isInitial
8832
- ? `<span class="brief-version-label">${lang === "zh" ? "初版" : "Initial"}</span>`
8922
+ ? `<span class="brief-version-label">Initial</span>`
8833
8923
  : `<span class="brief-version-label">${this.escape((supp || "").slice(0, 20))}${(supp || "").length > 20 ? "…" : ""}</span>`}
8834
8924
  </button>
8835
8925
  <button type="button" class="brief-version-close" data-brief-delete data-brief-id="${this.escape(bf.id)}" title="${this.escape(closeTitle)}" aria-label="${this.escape(closeTitle)}">×</button>
@@ -8892,7 +8982,7 @@
8892
8982
  // a failed-brief tab becomes unreachable — clicking it just
8893
8983
  // re-renders whichever good brief the salvage path picks.
8894
8984
  const goodBrief = bypassSalvage ? null : (this.currentBriefs || []).find(
8895
- (x) => x && x.id !== b.id && !x.error && x.bodyMd && x.bodyMd.trim().length > 0,
8985
+ (x) => x && x.id !== b.id && !x.error && this.briefHasBody(x),
8896
8986
  );
8897
8987
  if (goodBrief) {
8898
8988
  const failed = b;
@@ -8902,14 +8992,14 @@
8902
8992
  finally { this.currentBrief = prevCurrent; }
8903
8993
  // Prepend the compact retry banner to the rendered card so
8904
8994
  // the existing report stays fully visible below it.
8905
- const lang = (failed.language === "zh" || (this.currentRoom?.subject && /[一-鿿]/.test(this.currentRoom.subject))) ? "zh" : "en";
8995
+ // System UI · always English (error banner chrome).
8906
8996
  const detail = failed.timedOut
8907
- ? (lang === "zh" ? "重新生成超时" : "regeneration timed out")
8997
+ ? "regeneration timed out"
8908
8998
  : failed.interrupted
8909
- ? (lang === "zh" ? "重新生成被中断" : "regeneration interrupted")
8910
- : (failed.error || (lang === "zh" ? "重新生成失败" : "regeneration failed"));
8911
- const cta = lang === "zh" ? "重试" : "Retry";
8912
- const dismiss = lang === "zh" ? "关闭" : "Dismiss";
8999
+ ? "regeneration interrupted"
9000
+ : (failed.error || "regeneration failed");
9001
+ const cta = "Retry";
9002
+ const dismiss = "Dismiss";
8913
9003
  card.insertAdjacentHTML("afterbegin", `
8914
9004
  <div class="brief-retry-banner" data-brief-retry-banner data-failed-brief-id="${this.escape(failed.id)}">
8915
9005
  <span class="brb-mark">⚠</span>
@@ -8923,59 +9013,36 @@
8923
9013
  `);
8924
9014
  return;
8925
9015
  }
8926
- const lang = (b.language === "zh" || (this.currentRoom?.subject && /[一-鿿]/.test(this.currentRoom.subject))) ? "zh" : "en";
9016
+ // System UI · always English (full error-card copy).
8927
9017
  const copy = b.timedOut
8928
- ? (lang === "zh"
8929
- ? {
8930
- stamp: "timed out",
8931
- kicker: "// 报告生成超时",
8932
- detail: "已超过 8 分钟仍未收到完成信号 · 可能是模型回应过慢、网络中断,或后端流水线卡住了。点击下方按钮重试,或检查 LLM key 与网络后再试。",
8933
- hint: "",
8934
- cta: "重试",
8935
- }
8936
- : {
8937
- stamp: "timed out",
8938
- kicker: "// generation timed out",
8939
- detail: "No completion signal after 8 minutes — the model may be slow, the connection dropped, or the pipeline stalled. Click below to start a fresh run.",
8940
- hint: "",
8941
- cta: "Retry",
8942
- })
9018
+ ? {
9019
+ stamp: "timed out",
9020
+ kicker: "// generation timed out",
9021
+ detail: "No completion signal after 8 minutes — the model may be slow, the connection dropped, or the pipeline stalled. Click below to start a fresh run.",
9022
+ hint: "",
9023
+ cta: "Retry",
9024
+ }
8943
9025
  : b.interrupted
8944
- ? (lang === "zh"
8945
- ? {
8946
- stamp: "interrupted",
8947
- kicker: "// 报告生成被中断了",
8948
- detail: "上一次生成在浏览器刷新或服务重启时中止了。点击下方按钮重新生成一份报告。",
8949
- hint: "",
8950
- cta: "重新生成报告",
8951
- }
8952
- : {
8953
- stamp: "interrupted",
8954
- kicker: "// generation interrupted",
8955
- detail: "The previous generation was cut short likely by a browser refresh or a server restart. Click below to start a fresh report.",
8956
- hint: "",
8957
- cta: "Regenerate report",
8958
- })
8959
- : (lang === "zh"
8960
- ? {
8961
- stamp: "failed",
8962
- kicker: "// 报告生成失败",
8963
- detail: this.escape(b.error || ""),
8964
- hint: "Brief writer 需要一个 LLM key(OpenRouter,或 Anthropic / OpenAI / Google / xAI 直连)。在 <strong>Preference → API Key</strong> 中添加后再试。",
8965
- cta: "重试",
8966
- }
8967
- : {
8968
- stamp: "failed",
8969
- kicker: "// brief generation failed",
8970
- detail: this.escape(b.error || ""),
8971
- hint: "The brief writer needs an LLM key (OpenRouter, or a direct Anthropic / OpenAI / Google / xAI key). Add one in <strong>Preference → API Key</strong> and try again.",
8972
- cta: "Retry",
8973
- });
9026
+ ? {
9027
+ stamp: "interrupted",
9028
+ kicker: "// generation interrupted",
9029
+ detail: "The previous generation was cut short — likely by a browser refresh or a server restart. Click below to start a fresh report.",
9030
+ hint: "",
9031
+ cta: "Regenerate report",
9032
+ }
9033
+ : {
9034
+ stamp: "failed",
9035
+ kicker: "// brief generation failed",
9036
+ detail: this.escape(b.error || ""),
9037
+ hint: "The brief writer needs an LLM key (OpenRouter, or a direct Anthropic / OpenAI / Google / xAI key). Add one in <strong>Preference API Key</strong> and try again.",
9038
+ cta: "Retry",
9039
+ };
8974
9040
  card.innerHTML = `
8975
9041
  <div class="brief-card">
8976
9042
  ${tabsStripHtml}
8977
9043
  <div class="brief-banner">
8978
9044
  <span class="brief-banner-tag" style="color: var(--red);">// report</span>
9045
+ <span class="brief-banner-type" style="color: var(--red); border-color: var(--red);">${this.escape(this.briefModeLabel(b))}</span>
8979
9046
  <span class="brief-banner-stamp" style="color: var(--red);">${this.escape(copy.stamp)}</span>
8980
9047
  </div>
8981
9048
  <div class="brief-body brief-body-error">
@@ -8998,7 +9065,7 @@
8998
9065
  return;
8999
9066
  }
9000
9067
 
9001
- const generating = b.isGenerating === true || !b.bodyMd || b.title === "Generating…";
9068
+ const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
9002
9069
  const signed = this.currentMembers
9003
9070
  .map((a) => `<img src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" title="${this.escape(a.name)}">`)
9004
9071
  .join("");
@@ -9007,12 +9074,12 @@
9007
9074
  ? "GENERATING…"
9008
9075
  : "FILED · " + (b.createdAt ? this.timeFmt(b.createdAt) : this.timeFmt(Date.now()));
9009
9076
 
9010
- // Open Report links to /report.html with both r (room) and b
9011
- // (brief id) the viewer uses b when present so refining
9012
- // shows the right version.
9013
- const reportHref = this.currentRoomId && b.id
9014
- ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}&b=${encodeURIComponent(b.id)}`
9015
- : (this.currentRoomId ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}` : null);
9077
+ // Open Report URL · routes to /bento.html for bento briefs and
9078
+ // /report.html for research-note briefs (default). The bento
9079
+ // renderer doesn't need the room id so the URL is shorter for
9080
+ // that mode.
9081
+ const reportHref = this.briefViewerHref(b, this.currentRoomId)
9082
+ || (this.currentRoomId ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}` : null);
9016
9083
 
9017
9084
  // Tab strip — already computed at the top of renderBrief as
9018
9085
  // `tabsStripHtml` so both error and success paths can mount it.
@@ -9031,6 +9098,7 @@
9031
9098
  ${tabsHtml}
9032
9099
  <div class="brief-banner">
9033
9100
  <span class="brief-banner-tag">// report</span>
9101
+ <span class="brief-banner-type">${this.escape(this.briefModeLabel(b))}</span>
9034
9102
  <span class="brief-banner-stamp">${filedLabel}</span>
9035
9103
  </div>
9036
9104
 
@@ -9067,11 +9135,11 @@
9067
9135
  <div class="brief-supplement-row">
9068
9136
  <button type="button" class="brief-supplement-btn" data-brief-supplement>
9069
9137
  <span class="brief-supplement-mark">+</span>
9070
- <span class="brief-supplement-label">${(b.language === "zh" ? "补充视角,再生成一版报告" : "Add a perspective · regenerate")}</span>
9138
+ <span class="brief-supplement-label">Add a perspective · regenerate</span>
9071
9139
  </button>
9072
- <button type="button" class="brief-delete-btn" data-brief-delete data-brief-id="${this.escape(b.id)}" title="${(b.language === "zh" ? "删除这份报告" : "Delete this report")}">
9140
+ <button type="button" class="brief-delete-btn" data-brief-delete data-brief-id="${this.escape(b.id)}" title="Delete this report">
9073
9141
  <span class="brief-delete-mark">⌫</span>
9074
- <span class="brief-delete-label">${(b.language === "zh" ? "删除报告" : "Delete report")}</span>
9142
+ <span class="brief-delete-label">Delete report</span>
9075
9143
  </button>
9076
9144
  </div>
9077
9145
  ` : ""}
@@ -9149,55 +9217,53 @@
9149
9217
  "Writing the 3 Headline Findings",
9150
9218
  "Drafting Convergence + Divergence sections",
9151
9219
  "Composing Recommendations",
9152
- "Writing the Pre-mortem",
9153
9220
  "Surfacing New Questions",
9154
9221
  "Drafting the Strategic Planning Assumption",
9155
9222
  "Polishing the final pass",
9156
9223
  ],
9157
- },
9158
- zh: {
9159
- extract: [
9160
- "重读每位董事的发言",
9161
- "按视角标签(data / dissent / narrative / structural / first-principle)整理信号",
9162
- "压缩到每位董事 2-4 条关键信号",
9163
- ],
9164
- compose: [
9165
- "选定本份报告的 spine 风格",
9166
- "决定要纳入哪些组件块",
9167
- "估算密度与节奏",
9168
- ],
9169
- "scaffold-anchor": [
9170
- "读出 takeaway",
9171
- "校准 confidence",
9172
- "落定 working hypothesis",
9173
- ],
9174
- "scaffold-findings": [
9175
- "提炼 3 条 headline findings",
9176
- "复核每条是否撑住 anchor",
9177
- "如有勉强,3 → 2 收敛",
9178
- ],
9179
- "scaffold-cluster": [
9180
- "定位董事们达成共识的地方",
9181
- "标记核心张力",
9182
- "辨认未消化的立场",
9224
+ // Bento's `write` stage does different work · one chair-LLM
9225
+ // call that compresses the room into the 8-slot bento layout.
9226
+ // Keyed under `bento-write` and selected at render time when
9227
+ // the brief's mode is bento.
9228
+ "bento-write": [
9229
+ "Compressing to the takeaway · the 1-line title",
9230
+ "Picking the 3 milestones",
9231
+ "Sizing the big-number callouts",
9232
+ "Mapping ranked bars from the room's data",
9233
+ "Distilling the verification signals",
9234
+ "Compressing recommendations into elevator-pitch lines",
9235
+ "Naming the closing flow · before → after",
9183
9236
  ],
9184
- "scaffold-actions": [
9185
- "起草 recommendations",
9186
- "推演 pre-mortem",
9187
- "梳理会议长出的新问题",
9237
+ // Magazine mode · same single-pass chair-LLM call but the
9238
+ // emphasis is on editorial cover-line composition rather
9239
+ // than infographic compression. Keyed under `magazine-write`
9240
+ // and selected at render time when the brief's mode is
9241
+ // magazine.
9242
+ "magazine-write": [
9243
+ "Drafting the cover headline",
9244
+ "Writing the subdeck",
9245
+ "Picking the 5 numbered cards",
9246
+ "Sequencing the 3 setup steps",
9247
+ "Selecting why-this-matters reasons",
9248
+ "Stamping the masthead byline",
9188
9249
  ],
9189
- write: [
9190
- "撰写 Bottom Line",
9191
- "撰写 Frame Shift 段落",
9192
- "撰写 3 Headline Findings",
9193
- "起草 Convergence 与 Divergence 段落",
9194
- "撰写 Recommendations",
9195
- "撰写 Pre-mortem",
9196
- "梳理 New Questions",
9197
- "起草 Strategic Planning Assumption",
9198
- "通读润色,准备交稿",
9250
+ // Newspaper mode · same single-pass chair-LLM call but voice
9251
+ // shifts to broadsheet front-page journalism. Keyed under
9252
+ // `newspaper-write` and selected at render time when the
9253
+ // brief's mode is newspaper.
9254
+ "newspaper-write": [
9255
+ "Setting the banner headline",
9256
+ "Writing the subdeck",
9257
+ "Filing the three column stories",
9258
+ "Drafting the bottom-line callout",
9259
+ "Stacking the more-headings sidebar",
9260
+ "Stamping the masthead date",
9199
9261
  ],
9200
9262
  },
9263
+ // System UI · always English. The `zh` key is kept as an alias
9264
+ // of `en` so any caller indexing BRIEF_SUBSTAGES["zh"] still
9265
+ // resolves; brief language no longer changes the substage copy.
9266
+ get zh() { return this.en; },
9201
9267
  },
9202
9268
 
9203
9269
  /** Set up a 1s tick that re-renders the brief stages while at least
@@ -9213,7 +9279,7 @@
9213
9279
  }
9214
9280
  const stages = b.stages || {};
9215
9281
  const anyActive = Object.values(stages).some((s) => s && s.status === "active");
9216
- const generating = b.isGenerating === true || !b.bodyMd || b.title === "Generating…";
9282
+ const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
9217
9283
  if (!anyActive && !generating) {
9218
9284
  this.stopBriefStageTick();
9219
9285
  return;
@@ -9264,7 +9330,7 @@
9264
9330
  if (this._briefStallWatchTimer) return;
9265
9331
  const b = this.currentBrief;
9266
9332
  if (!b || !b.id || b.error) return;
9267
- const generating = b.isGenerating === true || !b.bodyMd || b.title === "Generating…";
9333
+ const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
9268
9334
  if (!generating) return;
9269
9335
  if (!this._lastBriefEventAt) this._lastBriefEventAt = Date.now();
9270
9336
  this._lastBriefHealthPollAt = 0;
@@ -9284,7 +9350,7 @@
9284
9350
  async tickBriefStallWatch() {
9285
9351
  const b = this.currentBrief;
9286
9352
  if (!b || b.error) { this.stopBriefStallWatch(); return; }
9287
- const generating = b.isGenerating === true || !b.bodyMd || b.title === "Generating…";
9353
+ const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
9288
9354
  if (!generating) { this.stopBriefStallWatch(); return; }
9289
9355
 
9290
9356
  const now = Date.now();
@@ -9293,9 +9359,8 @@
9293
9359
  // Hard ceiling · regardless of server state, flip the card to
9294
9360
  // a timed-out error so the user always has a way out.
9295
9361
  if (now - startedAt > this.BRIEF_HARD_TIMEOUT_MS) {
9296
- b.error = b.language === "zh"
9297
- ? "报告生成超时(超过 8 分钟仍未完成)。"
9298
- : "Brief generation timed out (no completion after 8 minutes).";
9362
+ // System UI · always English (timeout error message).
9363
+ b.error = "Brief generation timed out (no completion after 8 minutes).";
9299
9364
  b.timedOut = true;
9300
9365
  this.stopBriefStageTick();
9301
9366
  this.stopBriefStallWatch();
@@ -9340,30 +9405,39 @@
9340
9405
  write: { status: "pending", detail: "", progress: null, startedAt: null },
9341
9406
  };
9342
9407
  const lang = b.language === "zh" ? "zh" : "en";
9343
- const chairName = b.chairName || this.currentChair?.name || (lang === "zh" ? "主席" : "Chair");
9408
+ const chairName = b.chairName || this.currentChair?.name || "Chair";
9344
9409
 
9345
9410
  const wordCount = b.bodyMd
9346
9411
  ? (b.bodyMd.trim().match(/\S+/g) || []).length
9347
9412
  : 0;
9348
9413
 
9349
- // Stage definitions · 7 ordered cells. Must align with the wire
9350
- // format emitted by emitStage() in src/orchestrator/brief.ts:
9414
+ // Stage definitions · mode-aware. The wire format emitted by
9415
+ // emitStage() in src/orchestrator/brief.ts depends on which
9416
+ // pipeline ran:
9417
+ //
9418
+ // research-note (default) · extract → compose → scaffold-
9419
+ // {anchor,findings,cluster,actions} → write (7 stages · the
9420
+ // 4 scaffold sub-stages are driven by JSON-key arrival in
9421
+ // the streaming buffer, see SCAFFOLD_TRIGGERS in brief.ts)
9351
9422
  //
9352
- // extract → compose scaffold-{anchor,findings,cluster,actions} write
9423
+ // bento + magazine · extract → write (2 stages · runBentoStage
9424
+ // runs ONE chair-LLM call that produces the BentoScaffold;
9425
+ // composer and the scaffold sub-stages are skipped entirely.
9426
+ // Both modes share the same 2-stage rail · only the write
9427
+ // label and renderer differ)
9353
9428
  //
9354
- // The 4 scaffold sub-stages are driven by JSON-key arrival in the
9355
- // Stage 2 streaming buffer (see runStage2 / SCAFFOLD_TRIGGERS in
9356
- // brief.ts), so each pip transition reflects a real moment in the
9357
- // model's output — not a synthetic timer.
9358
- const STAGE_DEFS = lang === "zh"
9429
+ // System UI · always English regardless of brief language. The
9430
+ // pipeline labels are the app's voice (chrome around the
9431
+ // generation), not the report content itself.
9432
+ const STRUCTURED_WRITE_LABELS = {
9433
+ bento: "Composing the bento",
9434
+ magazine: "Composing the magazine",
9435
+ newspaper: "Composing the newspaper",
9436
+ };
9437
+ const STAGE_DEFS = this.isStructuredBriefMode(b.mode)
9359
9438
  ? [
9360
- { key: "extract", label: "读完房间里每个人的发言", pipShort: "" },
9361
- { key: "compose", label: "选定报告骨架与组件", pipShort: "" },
9362
- { key: "scaffold-anchor", label: "敲定核心判断 (anchor)", pipShort: "锚" },
9363
- { key: "scaffold-findings", label: "勾勒主张与发现", pipShort: "见" },
9364
- { key: "scaffold-cluster", label: "梳理共识与分歧", pipShort: "辨" },
9365
- { key: "scaffold-actions", label: "拟动作 · 推演风险", pipShort: "拟" },
9366
- { key: "write", label: "撰写最终报告", pipShort: "写" },
9439
+ { key: "extract", label: "Reading what each director said", pipShort: "read" },
9440
+ { key: "write", label: STRUCTURED_WRITE_LABELS[b.mode] || "Composing the report", pipShort: "compose" },
9367
9441
  ]
9368
9442
  : [
9369
9443
  { key: "extract", label: "Reading what each director said", pipShort: "read" },
@@ -9425,9 +9499,21 @@
9425
9499
  }
9426
9500
 
9427
9501
  // Rotating sub-line · advances every 3s within the active stage.
9502
+ // Bento mode's `write` stage does different work than the
9503
+ // research-note `write` stage — pick its dedicated rotator
9504
+ // (`bento-write`) when active. Falls through to the regular
9505
+ // key for every other stage / mode.
9428
9506
  let substageText = "";
9429
- if (activeStatus === "active" && substages[activeDef.key]?.length) {
9430
- const list = substages[activeDef.key];
9507
+ // Structured-mode write stages get their own rotator copy
9508
+ // ("bento-write" / "magazine-write" / "newspaper-write") since
9509
+ // the work is different from research-note's chapter-by-chapter
9510
+ // write.
9511
+ let subKey = activeDef.key;
9512
+ if (activeDef.key === "write" && this.isStructuredBriefMode(b.mode)) {
9513
+ subKey = `${b.mode}-write`;
9514
+ }
9515
+ if (activeStatus === "active" && substages[subKey]?.length) {
9516
+ const list = substages[subKey];
9431
9517
  substageText = list[Math.floor(activeElapsed / 3) % list.length];
9432
9518
  }
9433
9519
 
@@ -9437,12 +9523,12 @@
9437
9523
  if (activeDef.key === "extract" && activeStage.progress?.total) {
9438
9524
  const cur = activeStage.progress.current;
9439
9525
  const tot = activeStage.progress.total;
9440
- detailParts.push(lang === "zh" ? `${cur}/${tot} 位董事` : `${cur}/${tot} director${tot === 1 ? "" : "s"}`);
9526
+ detailParts.push(`${cur}/${tot} director${tot === 1 ? "" : "s"}`);
9441
9527
  } else if (activeStage.detail) {
9442
9528
  detailParts.push(activeStage.detail);
9443
9529
  }
9444
9530
  if (activeDef.key === "write" && activeStatus === "active" && wordCount > 0) {
9445
- detailParts.push(lang === "zh" ? `${wordCount} 字` : `${wordCount} word${wordCount === 1 ? "" : "s"}`);
9531
+ detailParts.push(`${wordCount} word${wordCount === 1 ? "" : "s"}`);
9446
9532
  }
9447
9533
  const detailLine = detailParts.join(" · ");
9448
9534
 
@@ -9454,7 +9540,7 @@
9454
9540
  ? `${activeElapsed}s · ~${activeEta[0]}–${activeEta[1]}s`
9455
9541
  : `~${activeEta[0]}–${activeEta[1]}s`;
9456
9542
  } else {
9457
- timing = lang === "zh" ? `已耗时 ${activeElapsed}s` : `${activeElapsed}s elapsed`;
9543
+ timing = `${activeElapsed}s elapsed`;
9458
9544
  }
9459
9545
  }
9460
9546
 
@@ -9510,19 +9596,15 @@
9510
9596
  return r === 0 ? `${m}m` : `${m}m ${r}s`;
9511
9597
  };
9512
9598
  const fmtRange = (lo, hi) => {
9513
- if (hi < 60) return lang === "zh" ? `约 ${lo}–${hi}s` : `~${lo}–${hi}s`;
9599
+ if (hi < 60) return `~${lo}–${hi}s`;
9514
9600
  const loM = Math.max(1, Math.round(lo / 60));
9515
9601
  const hiM = Math.max(loM, Math.round(hi / 60));
9516
- return lang === "zh" ? `约 ${loM}–${hiM} 分钟` : `~${loM}-${hiM}m`;
9602
+ return `~${loM}-${hiM}m`;
9517
9603
  };
9518
9604
 
9519
- const totalText = lang === "zh"
9520
- ? `已 ${fmtSec(totalElapsed)} · 还需${fmtRange(totalLo, totalHi)}`
9521
- : `${fmtSec(totalElapsed)} elapsed · ${fmtRange(totalLo, totalHi)} left`;
9522
-
9523
- const kickerCore = lang === "zh"
9524
- ? `// ${chairName} 正在整理纪要 · ${totalText}`
9525
- : `// ${chairName} is preparing the minutes · ${totalText}`;
9605
+ // System UI · always English (brief-progress kicker copy).
9606
+ const totalText = `${fmtSec(totalElapsed)} elapsed · ${fmtRange(totalLo, totalHi)} left`;
9607
+ const kickerCore = `// ${chairName} is preparing the minutes · ${totalText}`;
9526
9608
 
9527
9609
  const metaHtml = (detailLine || timing)
9528
9610
  ? `<span class="brief-active-meta">` +
@@ -9554,9 +9636,8 @@
9554
9636
  this._briefSeenHarvestKeys = this._briefSeenHarvestKeys || {};
9555
9637
  const seenH = this._briefSeenHarvestKeys[b.id] = this._briefSeenHarvestKeys[b.id] || new Set();
9556
9638
  const harvest = Array.isArray(b.extractHarvest) ? b.extractHarvest : [];
9557
- const kindLabels = lang === "zh"
9558
- ? { claims: "判断", evidence: "证据", tensions: "分歧", assumptions: "假设", risks: "风险", opportunities: "机会", actions: "动作", quotes: "原话", openQuestions: "悬而未决" }
9559
- : { claims: "claims", evidence: "evidence", tensions: "tensions", assumptions: "assumptions", risks: "risks", opportunities: "opportunities", actions: "actions", quotes: "quotes", openQuestions: "open-q" };
9639
+ // System UI · always English (signal-kind chip labels).
9640
+ const kindLabels = { claims: "claims", evidence: "evidence", tensions: "tensions", assumptions: "assumptions", risks: "risks", opportunities: "opportunities", actions: "actions", quotes: "quotes", openQuestions: "open-q" };
9560
9641
  if (harvest.length) {
9561
9642
  const chips = harvest.map((h) => {
9562
9643
  const isFresh = !seenH.has(h.directorId);
@@ -9581,7 +9662,7 @@
9581
9662
  // Live word count during write — large and visible since it's the
9582
9663
  // most engaging signal during the long write stage.
9583
9664
  if (writeActive && wordCount > 0) {
9584
- const w = lang === "zh" ? `${wordCount} 字 · 还在落笔` : `${wordCount} word${wordCount === 1 ? "" : "s"} · still writing`;
9665
+ const w = `${wordCount} word${wordCount === 1 ? "" : "s"} · still writing`;
9585
9666
  stats.push(`<span class="brief-stat-fact brief-stat-live">${this.escape(w)}</span>`);
9586
9667
  }
9587
9668
  const statsHtml = stats.length
@@ -9902,8 +9983,8 @@
9902
9983
  genBriefBtn.setAttribute("data-pending", "1");
9903
9984
  if ("disabled" in genBriefBtn) genBriefBtn.disabled = true;
9904
9985
 
9905
- const isZh = app.composerLanguage() === "zh";
9906
- const generatingText = isZh ? "正在生成…" : "generating…";
9986
+ // System UI · always English (generating-button chrome).
9987
+ const generatingText = "generating…";
9907
9988
  const originalHtml = genBriefBtn.innerHTML;
9908
9989
 
9909
9990
  // Swap to a "generating…" state. The two button shapes both
@@ -9926,10 +10007,31 @@
9926
10007
  genBriefBtn.removeAttribute("data-pending");
9927
10008
  if ("disabled" in genBriefBtn) genBriefBtn.disabled = false;
9928
10009
  genBriefBtn.innerHTML = originalHtml;
9929
- alert((isZh ? "生成失败:" : "Brief generation failed: ") + (err && err.message ? err.message : err));
10010
+ alert("Brief generation failed: " + (err && err.message ? err.message : err));
9930
10011
  });
9931
10012
  return;
9932
10013
  }
10014
+ // Brief-mode picker · clicking a label (or its radio) toggles the
10015
+ // .on class on that option AND off on the siblings. Used by both
10016
+ // the adjourn overlay and the supplement overlay. We scope to the
10017
+ // picker's `.adjourn-mode-options` container (set in
10018
+ // renderBriefModePicker) instead of a specific overlay id, so the
10019
+ // same handler works wherever the picker is embedded. The native
10020
+ // radio behaviour handles `:checked` state but we use a manual
10021
+ // class for visual styling (left accent stripe + tint) since we
10022
+ // can't `:has()` reliably on older browsers.
10023
+ const modeOpt = e.target.closest(".adjourn-mode-option");
10024
+ if (modeOpt) {
10025
+ const group = modeOpt.closest(".adjourn-mode-options");
10026
+ if (group) {
10027
+ group.querySelectorAll(".adjourn-mode-option").forEach((el) => el.classList.remove("on"));
10028
+ modeOpt.classList.add("on");
10029
+ const radio = modeOpt.querySelector('input[type="radio"]');
10030
+ if (radio) radio.checked = true;
10031
+ }
10032
+ // Don't preventDefault · let the label's native click behaviour
10033
+ // also tick the radio for keyboard / a11y users.
10034
+ }
9933
10035
  // Adjourn overlay · "skip report" footer button — clicking commits
9934
10036
  // immediately (mark picked + dispatch + close).
9935
10037
  if (e.target.closest("[data-adjourn-skip]")) {
@@ -9953,6 +10055,18 @@
9953
10055
  app.closeAdjournOverlay();
9954
10056
  return;
9955
10057
  }
10058
+ // Adjourn overlay · subject Show more / less toggle. Flips the
10059
+ // `is-clamped` class on the value span and swaps the button label.
10060
+ const subjToggle = e.target.closest("[data-adjourn-subject-toggle]");
10061
+ if (subjToggle) {
10062
+ e.preventDefault();
10063
+ const subjEl = document.querySelector("[data-adjourn-subject]");
10064
+ if (subjEl) {
10065
+ const expanded = subjEl.classList.toggle("is-clamped") === false;
10066
+ subjToggle.textContent = expanded ? "Show less" : "Show more";
10067
+ }
10068
+ return;
10069
+ }
9956
10070
  // Brief card · "Add a perspective" → opens the supplement overlay.
9957
10071
  if (e.target.closest("[data-brief-supplement]")) {
9958
10072
  e.preventDefault();
@@ -10185,11 +10299,9 @@
10185
10299
  // map, which user-settings.js patches in place after every
10186
10300
  // setProviderKey, so we get fresh truth here.
10187
10301
  const configured = !!(app.agentComposerBraveConfigured && app.agentComposerBraveConfigured());
10188
- const isZh = (app.composerLanguage && app.composerLanguage()) === "zh";
10189
10302
  if (!configured) {
10190
- const ok = confirm(isZh
10191
- ? "联网搜索需要 Brave Search API key。\n\nBrave Search · $5 / 1000 次查询 · 注重隐私\n\n现在去 Preferences 配置吗?"
10192
- : "Web Search needs a Brave Search API key.\n\nBrave Search · ≈ $5 per 1000 queries · privacy-respecting\n\nOpen Preferences to paste your key now?");
10303
+ // System UI · always English (Brave key prompt confirm dialog).
10304
+ const ok = confirm("Web Search needs a Brave Search API key.\n\nBrave Search · $5 per 1000 queries · privacy-respecting\n\nOpen Preferences to paste your key now?");
10193
10305
  if (ok && typeof window.openUserSettings === "function") {
10194
10306
  window.openUserSettings({ section: "keys", focusProvider: "brave" });
10195
10307
  }
@@ -10211,19 +10323,14 @@
10211
10323
  wsToggle.setAttribute("aria-pressed", next ? "true" : "false");
10212
10324
  const txt = wsToggle.querySelector(".ap-skill-row-toggle-text");
10213
10325
  if (txt) {
10214
- const wsLabel = isZh ? "联网搜索" : "web search";
10215
- const stateLabel = next
10216
- ? (isZh ? "已开启" : "enabled")
10217
- : (isZh ? "已关闭" : "disabled");
10326
+ // System UI · always English (web-search toggle chrome).
10327
+ const wsLabel = "web search";
10328
+ const stateLabel = next ? "enabled" : "disabled";
10218
10329
  txt.textContent = `${wsLabel} · ${stateLabel}`;
10219
10330
  }
10220
10331
  wsToggle.title = next
10221
- ? (isZh
10222
- ? "生成时联网检索领域真实案例 · 点击关闭"
10223
- : "Search the web for real domain references during generation · click to disable")
10224
- : (isZh
10225
- ? "生成时不联网 · 点击开启"
10226
- : "Generation runs offline · click to enable web search");
10332
+ ? "Search the web for real domain references during generation · click to disable"
10333
+ : "Generation runs offline · click to enable web search";
10227
10334
  return;
10228
10335
  }
10229
10336
  // ─── Agent spec preview · field actions
@@ -10359,6 +10466,20 @@
10359
10466
  if (id) app.deleteRoom(id);
10360
10467
  return;
10361
10468
  }
10469
+ // Delete a saved note from the All Notes list. The button is a
10470
+ // sibling of the note's anchor (inside .notes-item) so its click
10471
+ // never bubbles through the navigation link — but we still
10472
+ // preventDefault + stopPropagation defensively to keep the row's
10473
+ // hover/focus state quiet.
10474
+ const noteDel = e.target.closest("[data-note-delete]");
10475
+ if (noteDel) {
10476
+ e.preventDefault();
10477
+ e.stopPropagation();
10478
+ const item = noteDel.closest("[data-note-id]");
10479
+ const id = item?.dataset.noteId;
10480
+ if (id) app.deleteNoteAt(id);
10481
+ return;
10482
+ }
10362
10483
  // Delete a custom agent · this used to live as an inline X button
10363
10484
  // on the sidebar row. It's been moved into the agent profile's
10364
10485
  // ⋯ overflow menu (see agent-profile.js → "delete" menu action),