privateboard 0.1.6 → 0.1.7

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) => {
@@ -1078,6 +1076,10 @@
1078
1076
  }
1079
1077
  }
1080
1078
  }
1079
+ // Typing-SFX presence cue · same as message-token. Ticks
1080
+ // even on off-tab briefs so the user has aural awareness
1081
+ // that the brief writer is making progress.
1082
+ window.boardroomTypingSfx && window.boardroomTypingSfx.tick();
1081
1083
  } else if (kind === "brief-final") {
1082
1084
  this.markBriefEvent();
1083
1085
  const target = this._briefById(payload.briefId);
@@ -1498,24 +1500,15 @@
1498
1500
  * visual treatment stays consistent. */
1499
1501
  openNoKeyModal() {
1500
1502
  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
- };
1503
+ // System UI · always English. No-key modal is app chrome.
1504
+ const t = {
1505
+ title: "Configure a model API key",
1506
+ 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.",
1507
+ primary: "[ Open settings ]",
1508
+ dismiss: "[ Dismiss ]",
1509
+ classification: " ai · no model key",
1510
+ tag: " Configure API key",
1511
+ };
1519
1512
  const html = `
1520
1513
  <div id="no-key-overlay" class="pc-overlay" data-no-key-overlay>
1521
1514
  <div class="pc-modal">
@@ -1531,7 +1524,7 @@
1531
1524
  <div class="pc-body">
1532
1525
  <button type="button" class="pc-choice primary" data-no-key-open-settings>
1533
1526
  <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>
1527
+ <div class="pc-choice-deck">Jump to Preferences → API Key. Paste any one provider's key to unlock the room.</div>
1535
1528
  </button>
1536
1529
  <button type="button" class="pc-choice ghost" data-no-key-dismiss>
1537
1530
  <div class="pc-choice-mark">${this.escape(t.dismiss)}</div>
@@ -1783,30 +1776,18 @@
1783
1776
  openSupplementOverlay() {
1784
1777
  if (!this.currentRoomId || !this.currentBrief) return;
1785
1778
  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
- };
1779
+ // System UI · always English. Supplement-overlay chrome.
1780
+ const t = {
1781
+ classify: "report · regenerate with supplement",
1782
+ classifyRight: "// regenerate",
1783
+ title: "Add a perspective",
1784
+ metaPrefix: "// Current report",
1785
+ 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",
1786
+ hint: "The new perspective will be woven through the existing Findings, Recommendations, and New Questions sections — not added as a separate section.",
1787
+ cancel: "[ Cancel ]",
1788
+ confirm: "[ Regenerate ]",
1789
+ confirmBusy: "[ Regenerating… ]",
1790
+ };
1810
1791
  const html = `
1811
1792
  <div class="supplement-overlay" id="supplement-overlay" role="dialog" aria-modal="true">
1812
1793
  <div class="supplement-backdrop" data-supplement-close></div>
@@ -1875,31 +1856,19 @@
1875
1856
  if (!this.currentRoomId || !this.currentRoom) return;
1876
1857
  if (this.currentRoom.status !== "paused") return;
1877
1858
  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)");
1859
+ // System UI · always English. Paused-supplement overlay chrome.
1860
+ const t = {
1861
+ classify: "room · paused supplement",
1862
+ classifyRight: "// queued first",
1863
+ title: "Add a supplemental input",
1864
+ metaPrefix: "// Current room",
1865
+ 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.",
1866
+ 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.",
1867
+ cancel: "[ Cancel ]",
1868
+ confirm: "[ Add to chat ]",
1869
+ confirmBusy: "[ Posting… ]",
1870
+ };
1871
+ const subject = (this.currentRoom.subject || "").trim() || "(no subject)";
1903
1872
  const html = `
1904
1873
  <div class="supplement-overlay" id="paused-supplement-overlay" role="dialog" aria-modal="true">
1905
1874
  <div class="supplement-backdrop" data-paused-supplement-close></div>
@@ -2019,54 +1988,30 @@
2019
1988
  if (this.currentRoom.status !== "adjourned") return;
2020
1989
  this.closeFollowUpOverlay();
2021
1990
 
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
- };
1991
+ // System UI · always English. Follow-up overlay chrome.
1992
+ const t = {
1993
+ classify: "follow-up · continuation room",
1994
+ classifyRight: "// continuing",
1995
+ title: "Convene a follow-up",
1996
+ metaPrefix: "// following up",
1997
+ placeholder: "What's the next question to chase, given what the prior session settled?",
1998
+ 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.",
1999
+ castLabel: "Directors",
2000
+ castHint: "2–4 recommended",
2001
+ castSame: "Same cast as last session",
2002
+ pickerLabel: "Pick directors",
2003
+ autoLabel: "directors",
2004
+ autoVal: "auto-pick",
2005
+ countersDirectors: (n) => `${n} director${n === 1 ? "" : "s"}`,
2006
+ toneLabel: "Tone",
2007
+ intensityLabel: "Intensity",
2008
+ cancel: "[ Cancel ]",
2009
+ confirm: "[ Convene ]",
2010
+ confirmBusy: "[ Convening… ]",
2011
+ adjournedAtPrefix: "adjourned",
2012
+ briefsCount: (n) => `${n} ${n === 1 ? "brief" : "briefs"} filed`,
2013
+ noBrief: "no brief filed",
2014
+ };
2070
2015
 
2071
2016
  const room = this.currentRoom;
2072
2017
  const briefCount = Array.isArray(this.currentBriefs) ? this.currentBriefs.length : 0;
@@ -2105,7 +2050,6 @@
2105
2050
  directorIds: [],
2106
2051
  autoPick: true,
2107
2052
  parentDirectorIds: parentDirectorIds.slice(),
2108
- lang,
2109
2053
  };
2110
2054
 
2111
2055
  const html = `
@@ -2269,7 +2213,6 @@
2269
2213
  const btn = document.querySelector("[data-followup-cast-btn]");
2270
2214
  if (!btn || !this._followupCastState) return;
2271
2215
  const state = this._followupCastState;
2272
- const lang = state.lang || "en";
2273
2216
 
2274
2217
  btn.classList.remove("cmp-cast-btn-auto");
2275
2218
  btn.removeAttribute("data-cast-mode");
@@ -2286,9 +2229,8 @@
2286
2229
  const avatars = visible.map((a) =>
2287
2230
  `<img class="cmp-cast-av" src="${this.escape(a.avatarPath || "")}" alt="${this.escape(a.name || "")}" title="${this.escape(a.name || "")}">`,
2288
2231
  ).join("");
2289
- const count = lang === "zh"
2290
- ? `${parents.length} · 沿用上一场`
2291
- : `${parents.length} · same as last`;
2232
+ // System UI · always English (cast button chrome).
2233
+ const count = `${parents.length} · same as last`;
2292
2234
  btn.innerHTML =
2293
2235
  `<span class="cmp-cast-stack">${avatars}${overflow > 0 ? `<span class="cmp-cast-more">+${overflow}</span>` : ""}</span>` +
2294
2236
  `<span class="cmp-cast-count">${this.escape(count)}</span>`;
@@ -2304,8 +2246,9 @@
2304
2246
  // Auto-pick · default
2305
2247
  btn.classList.add("cmp-cast-btn-auto");
2306
2248
  btn.setAttribute("data-cast-mode", "auto");
2307
- const autoKey = lang === "zh" ? "directors" : "directors";
2308
- const autoVal = lang === "zh" ? "自动挑选" : "auto-pick";
2249
+ // System UI · always English (cast button chrome).
2250
+ const autoKey = "directors";
2251
+ const autoVal = "auto-pick";
2309
2252
  btn.innerHTML =
2310
2253
  `<span class="cmp-cast-stack cmp-cast-stack-auto"><span class="cmp-cast-auto-mark">✦</span></span>` +
2311
2254
  `<span class="cmp-cast-count cmp-cast-auto-label">` +
@@ -2320,9 +2263,8 @@
2320
2263
  const avatars = visible.map((a) =>
2321
2264
  `<img class="cmp-cast-av" src="${this.escape(a.avatarPath || "")}" alt="${this.escape(a.name || "")}" title="${this.escape(a.name || "")}">`,
2322
2265
  ).join("");
2323
- const countText = lang === "zh"
2324
- ? `${picked.length} 位董事`
2325
- : `${picked.length} director${picked.length === 1 ? "" : "s"}`;
2266
+ // System UI · always English (cast button chrome count).
2267
+ const countText = `${picked.length} director${picked.length === 1 ? "" : "s"}`;
2326
2268
  btn.setAttribute("data-cast-mode", "manual");
2327
2269
  btn.innerHTML =
2328
2270
  `<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 +2280,12 @@
2338
2280
  if (!anchorBtn || !this._followupCastState) return;
2339
2281
  if (this._followupCastState.sameAsLast) return; // disabled when "same cast" is on
2340
2282
  const state = this._followupCastState;
2341
- const lang = state.lang || "en";
2342
2283
  const dirs = (this.agents || [])
2343
2284
  .filter((a) => a.roleKind !== "moderator")
2344
2285
  .slice()
2345
2286
  .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" };
2287
+ // System UI · always English (director picker chrome).
2288
+ const t = { title: "Pick directors", hint: "2-4 recommended", info: "View profile" };
2349
2289
  const rows = dirs.map((a) => {
2350
2290
  const checked = state.directorIds.includes(a.id);
2351
2291
  return `
@@ -2679,23 +2619,14 @@
2679
2619
  async deleteBriefAt(briefId) {
2680
2620
  if (!briefId) return;
2681
2621
  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.
2622
+ // System UI · always English. Confirm + alert dialogs are app
2623
+ // chrome and stay fixed-string regardless of brief language.
2687
2624
  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."));
2625
+ const confirmText = isStillGenerating
2626
+ ? "This report is still generating. Deleting will stop the generation and remove the report. This can't be undone."
2627
+ : (target?.supplement
2628
+ ? `Delete the "${target.supplement.trim().slice(0, 20)}${target.supplement.length > 20 ? "…" : ""}" version? This can't be undone.`
2629
+ : "Delete this report? This can't be undone.");
2699
2630
  if (!confirm(confirmText)) return;
2700
2631
  try {
2701
2632
  const r = await fetch("/api/briefs/" + encodeURIComponent(briefId), { method: "DELETE" });
@@ -2704,7 +2635,7 @@
2704
2635
  throw new Error(e.error || ("HTTP " + r.status));
2705
2636
  }
2706
2637
  } catch (e) {
2707
- alert((lang === "zh" ? "删除失败:" : "Delete failed: ") + (e && e.message ? e.message : e));
2638
+ alert("Delete failed: " + (e && e.message ? e.message : e));
2708
2639
  return;
2709
2640
  }
2710
2641
  // Patch local state · remove the deleted brief, refresh active.
@@ -3047,7 +2978,7 @@
3047
2978
  /** Spin up the countdown if conditions are met; otherwise cancel. */
3048
2979
  maybeStartContinueCountdown() {
3049
2980
  if (!this.canAutoContinue()) {
3050
- this.cancelContinueCountdown("not-idle");
2981
+ this.cancelContinueCountdown();
3051
2982
  this.refreshContinueButton();
3052
2983
  return;
3053
2984
  }
@@ -3844,14 +3775,11 @@
3844
3775
  renderSidebarCounts() {
3845
3776
  const roomsCount = this.rooms.length;
3846
3777
  const agentsCount = this.agents.length;
3847
- const liveCount = this.rooms.filter((r) => r.status === "live").length;
3848
3778
 
3849
3779
  const r = document.querySelector('[data-sidebar-tab-count="rooms"]');
3850
3780
  if (r) r.textContent = String(roomsCount);
3851
3781
  const a = document.querySelector('[data-sidebar-tab-count="agents"]');
3852
3782
  if (a) a.textContent = String(agentsCount);
3853
- const sum = document.querySelector("[data-sidebar-summary]");
3854
- if (sum) sum.textContent = `${liveCount} LIVE / ${agentsCount} AGENTS`;
3855
3783
  },
3856
3784
 
3857
3785
  // ── Rendering · main view ─────────────────────────────────
@@ -3886,7 +3814,8 @@
3886
3814
  if (existingBanner) existingBanner.remove();
3887
3815
  const parent = this.currentParentRef;
3888
3816
  if (chat && parent && parent.id) {
3889
- const labelText = lang === "zh" ? "// 跟进自" : "// following up";
3817
+ // System UI · always English (banner chrome on the follow-up parent link).
3818
+ const labelText = "// following up";
3890
3819
  const subject = (parent.subject || "(no subject)").trim();
3891
3820
  const banner = document.createElement("a");
3892
3821
  banner.href = "#";
@@ -3906,9 +3835,8 @@
3906
3835
  if (existingChildren) existingChildren.remove();
3907
3836
  const kids = Array.isArray(this.currentFollowUps) ? this.currentFollowUps : [];
3908
3837
  if (briefCard && kids.length > 0) {
3909
- const headLabel = lang === "zh"
3910
- ? `跟进会议 · ${kids.length}`
3911
- : `Follow-up rooms · ${kids.length}`;
3838
+ // System UI · always English (sidebar / brief-card chrome head).
3839
+ const headLabel = `Follow-up rooms · ${kids.length}`;
3912
3840
  const block = document.createElement("div");
3913
3841
  block.className = "followup-children";
3914
3842
  block.innerHTML = [
@@ -4031,36 +3959,23 @@
4031
3959
  const briefCard = document.querySelector("[data-brief-card]");
4032
3960
  if (!briefCard) return;
4033
3961
 
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
- };
3962
+ // System UI · always English. Session-analytics tile chrome
3963
+ // (banner, metric labels, section heads, chip text) is part of
3964
+ // the app shell and doesn't follow the brief language.
3965
+ const t = {
3966
+ head: "// session analytics",
3967
+ stamp: "closed",
3968
+ tokens: "tokens",
3969
+ messages: "msgs",
3970
+ rounds: "rounds",
3971
+ minutes: "min",
3972
+ modelHead: "Model usage",
3973
+ valueHead: "What you valued",
3974
+ valueEmpty: "No key-point votes, probes, or seconds in this session.",
3975
+ voted: "▲ voted",
3976
+ seconded: "★ seconded",
3977
+ probed: "✎ probed",
3978
+ };
4064
3979
 
4065
3980
  const fmtTokens = (n) => {
4066
3981
  if (!Number.isFinite(n) || n <= 0) return "0";
@@ -4164,8 +4079,10 @@
4164
4079
  // analytics tile; capping keeps the section as a tight strip
4165
4080
  // and lets the user opt in.
4166
4081
  const VALUE_PREVIEW_CAP = 2;
4167
- const moreLabel = (n) => isZh ? `[ + 展开剩余 ${n} 条 ]` : `[ + show ${n} more ]`;
4168
- const lessLabel = isZh ? "[ 收起 ]" : "[ collapse ]";
4082
+ // System UI · always English (the surrounding tile content can
4083
+ // still localize via `t`, but toggle chrome is fixed-string).
4084
+ const moreLabel = (n) => `[ + show ${n} more ]`;
4085
+ const lessLabel = "[ collapse ]";
4169
4086
  const upvotedHtml = stats.upvotedPoints.length > 0
4170
4087
  ? (() => {
4171
4088
  const items = stats.upvotedPoints.map((p, i) => {
@@ -4281,12 +4198,13 @@
4281
4198
  ? this.escape(nextSpeaker.handle.replace(/^\//, ""))
4282
4199
  : "";
4283
4200
 
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";
4201
+ // System UI · always English. App chrome (paused bar, action
4202
+ // buttons, status labels) doesn't follow the brief language.
4203
+ const addInputLabel = "[ + Add input ]";
4204
+ const adjournLabel = "[ Adjourn & File Brief ]";
4205
+ const resumeLabel = "[ Resume Discussion ]";
4206
+ const pausedLabel = "paused";
4207
+ const nextLabel = "next";
4290
4208
  const nextChunk = nextHandle
4291
4209
  ? ` · ${nextLabel} → <span class="lime">${nextHandle}</span>`
4292
4210
  : "";
@@ -4445,6 +4363,12 @@
4445
4363
  try { this._reportsLoadObserver.disconnect(); } catch { /* noop */ }
4446
4364
  this._reportsLoadObserver = null;
4447
4365
  }
4366
+ // The floating sidebar-expand button is gated on `html.no-room`.
4367
+ // Without setting it here, a user who collapses the sidebar
4368
+ // while on All Reports loses access to the expand control —
4369
+ // they have to navigate back to a room view to recover. Same
4370
+ // reasoning for openAllNotes / openAgentProfile.
4371
+ document.documentElement.classList.add("no-room");
4448
4372
  // If we're inside a room or on the agent profile, leave them.
4449
4373
  if (this.currentRoomId) {
4450
4374
  this.disconnectSSE?.();
@@ -4650,32 +4574,14 @@
4650
4574
  `;
4651
4575
  };
4652
4576
 
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
-
4577
+ // Flat list under the filter chips · earlier iterations grouped
4578
+ // by Today / Yesterday / This week / Earlier, but the user
4579
+ // wanted a single divider followed by the items. The filter
4580
+ // chips above already disclose the recency dimension; redundant
4581
+ // group headers were just visual chrome.
4676
4582
  const filterLabels = { all: "the archive", today: "Today", week: "This week", earlier: "Earlier" };
4677
4583
  const filterCopyTitle = filterLabels[activeFilter] || "this window";
4678
- const groupsHtml = groups.length === 0
4584
+ const groupsHtml = visibleFiltered.length === 0
4679
4585
  ? `
4680
4586
  <div class="reports-list-empty">
4681
4587
  <!-- Notice text · explains the empty window. -->
@@ -4700,14 +4606,11 @@
4700
4606
  ` : ""}
4701
4607
  </div>
4702
4608
  `
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("");
4609
+ : `
4610
+ <ul class="reports-list">
4611
+ ${visibleFiltered.map((b) => this.renderReportItemHtml(b)).join("")}
4612
+ </ul>
4613
+ `;
4711
4614
 
4712
4615
  // Bottom sentinel · IntersectionObserver target. Renders only
4713
4616
  // when there's more to load. The "+ N more" hint doubles as a
@@ -4862,6 +4765,9 @@
4862
4765
  * Pulls the live list and renders three time-bucket sections
4863
4766
  * (Today / This Week / Earlier). */
4864
4767
  async openAllNotes() {
4768
+ // Set the no-room flag for the same reason openAllReports does
4769
+ // — the floating sidebar-expand button is gated on this class.
4770
+ document.documentElement.classList.add("no-room");
4865
4771
  // Same view-leaving routine as openAllReports.
4866
4772
  if (this.currentRoomId) {
4867
4773
  this.disconnectSSE?.();
@@ -4990,9 +4896,30 @@
4990
4896
  <div class="notes-empty-mark">○</div>
4991
4897
  <div class="notes-empty-title">no saved notes yet</div>
4992
4898
  <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.
4899
+ Capture a director's line as a note:
4900
+ </div>
4901
+ <ol class="notes-empty-steps">
4902
+ <li>
4903
+ <span class="notes-empty-step-num">1</span>
4904
+ <span class="notes-empty-step-text">
4905
+ <strong>Open a room.</strong> Any director's reply can be saved.
4906
+ </span>
4907
+ </li>
4908
+ <li>
4909
+ <span class="notes-empty-step-num">2</span>
4910
+ <span class="notes-empty-step-text">
4911
+ <strong>Select a passage.</strong> Drag-highlight the sentence or paragraph you want to keep.
4912
+ </span>
4913
+ </li>
4914
+ <li>
4915
+ <span class="notes-empty-step-num">3</span>
4916
+ <span class="notes-empty-step-text">
4917
+ <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.
4918
+ </span>
4919
+ </li>
4920
+ </ol>
4921
+ <div class="notes-empty-foot">
4922
+ Each saved excerpt links back to the original room + speaker, so you can jump back any time.
4996
4923
  </div>
4997
4924
  </div>
4998
4925
  `;
@@ -5007,27 +4934,12 @@
5007
4934
  return true;
5008
4935
  });
5009
4936
 
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
-
4937
+ // Flat list under the filter chips. Earlier iterations grouped
4938
+ // by Today / This week / Earlier headers; the user wanted a
4939
+ // single divider followed by the items, since the chips above
4940
+ // already disclose the recency dimension.
5029
4941
  const filterLabels = { all: "the archive", today: "Today", week: "This week", earlier: "Earlier" };
5030
- const groupsHtml = groups.length === 0
4942
+ const groupsHtml = filtered.length === 0
5031
4943
  ? `
5032
4944
  <div class="notes-list-empty">
5033
4945
  <div class="notes-empty-mark">○</div>
@@ -5041,17 +4953,11 @@
5041
4953
  ` : ""}
5042
4954
  </div>
5043
4955
  `
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("");
4956
+ : `
4957
+ <ul class="notes-list">
4958
+ ${filtered.map((n) => this.renderNoteItemHtml(n)).join("")}
4959
+ </ul>
4960
+ `;
5055
4961
 
5056
4962
  const totalLabel = `${total} ${total === 1 ? "note" : "notes"}`;
5057
4963
  const roomLabel = distinctRooms > 0
@@ -5091,9 +4997,12 @@
5091
4997
  const author = n.authorName || "Director";
5092
4998
  // The jump link uses the room's hash route + the note id as a
5093
4999
  // 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.
5000
+ // matching span. The delete button sits as a SIBLING of the
5001
+ // anchor (inside the .notes-item) so its click never bubbles
5002
+ // through the navigation link — same pattern as session-row
5003
+ // delete in the rooms sidebar.
5096
5004
  const href = `#/r/${this.escape(n.roomId)}?note=${this.escape(n.id)}`;
5005
+ const deleteTitle = "Delete this note";
5097
5006
  return `
5098
5007
  <li class="notes-item" data-note-id="${this.escape(n.id)}">
5099
5008
  <a class="notes-item-link" href="${href}" data-note-jump="${this.escape(n.id)}" data-note-room="${this.escape(n.roomId)}">
@@ -5110,10 +5019,39 @@
5110
5019
  n.contextAfter ? `<span class="note-context note-context-after">${this.escape(n.contextAfter)}</span>` : ""
5111
5020
  }</p>
5112
5021
  </a>
5022
+ <button type="button" class="notes-item-delete" data-note-delete title="${this.escape(deleteTitle)}" aria-label="${this.escape(deleteTitle)}">✕</button>
5113
5023
  </li>
5114
5024
  `;
5115
5025
  },
5116
5026
 
5027
+ /** DELETE /api/notes/:id · drops a saved excerpt from the index.
5028
+ * Confirmation is light because the action is reversible only by
5029
+ * re-saving the same passage; we keep the prompt as a single
5030
+ * click-confirm rather than a full overlay. */
5031
+ async deleteNoteAt(id) {
5032
+ if (!id) return;
5033
+ if (!confirm("Delete this note? You can save it again from the room.")) return;
5034
+ try {
5035
+ const r = await fetch("/api/notes/" + encodeURIComponent(id), { method: "DELETE" });
5036
+ if (!r.ok) {
5037
+ const j = await r.json().catch(() => ({}));
5038
+ alert("Delete failed: " + (j.error || r.statusText));
5039
+ return;
5040
+ }
5041
+ } catch (e) {
5042
+ alert("Delete failed: " + (e && e.message ? e.message : e));
5043
+ return;
5044
+ }
5045
+ // Patch local cache so the immediate re-render reflects the
5046
+ // delete without a refetch round-trip. The sidebar count badge
5047
+ // re-fetches via the /count endpoint.
5048
+ if (Array.isArray(this._notesCache)) {
5049
+ this._notesCache = this._notesCache.filter((n) => n && n.id !== id);
5050
+ this.renderNotesPage(this._notesCache);
5051
+ }
5052
+ this.refreshNotesCount();
5053
+ },
5054
+
5117
5055
  /** Refresh the sidebar count badge · called on boot, after
5118
5056
  * every successful save (note:created event), and after a
5119
5057
  * delete. Hits /api/notes/count which is cheap (one COUNT query),
@@ -5535,31 +5473,23 @@
5535
5473
  const userName = (this.prefs?.name || "you").trim() || "you";
5536
5474
  const lang = this.composerLanguage();
5537
5475
  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
- };
5476
+ // System UI · always English. Composer chrome (prompt /
5477
+ // placeholder / button labels) doesn't follow the brief
5478
+ // language. The brief content + chair/director output still
5479
+ // honours the user's input language; only the app shell is
5480
+ // fixed-string.
5481
+ const t = {
5482
+ greet: greeting,
5483
+ prompt: "What's on your mind today?",
5484
+ placeholder: "an idea you're not sure about · a decision you keep avoiding · a thesis you want stress-tested",
5485
+ convene: "Convene",
5486
+ tuneLabel: "tune",
5487
+ starterLabel: "starter",
5488
+ starterCaption: "or try a starter",
5489
+ pickerLabel: "Pick directors",
5490
+ directorsLabel: (n) => `${n} director${n === 1 ? "" : "s"}`,
5491
+ directorsAdd: "add",
5492
+ };
5563
5493
 
5564
5494
  // Cast slot · two visual modes:
5565
5495
  // 1. Auto-pick (default · empty manual selection): chip
@@ -5578,16 +5508,15 @@
5578
5508
  // Label uses "directors" (not "cast") so the chip's purpose
5579
5509
  // is unambiguous: it's the slot that picks the boardroom's
5580
5510
  // 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";
5511
+ // System UI · always English (auto-pick chip tooltip).
5512
+ const autoTip = "Chair picks 3 directors based on your subject when you Convene · click to pick manually";
5584
5513
  castInner = `
5585
5514
  <span class="cmp-cast-stack cmp-cast-stack-auto" data-cast-auto title="${this.escape(autoTip)}">
5586
5515
  <span class="cmp-cast-auto-mark">✦</span>
5587
5516
  </span>
5588
5517
  <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>
5518
+ <span class="cmp-cast-auto-key">directors</span>
5519
+ <span class="cmp-cast-auto-val">auto-pick</span>
5591
5520
  </span>
5592
5521
  `;
5593
5522
  } else {
@@ -5598,7 +5527,7 @@
5598
5527
  `).join("");
5599
5528
  const dirCount = dirObjs.length
5600
5529
  ? `<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>`;
5530
+ : `<span class="cmp-cast-count cmp-cast-empty">no directors</span>`;
5602
5531
  castInner = `
5603
5532
  <span class="cmp-cast-stack">
5604
5533
  ${dirAvatars}
@@ -5612,8 +5541,9 @@
5612
5541
  // Tune dropdowns · two trigger buttons that open option popovers
5613
5542
  // on click. Discoverable (label + value + chevron pattern is
5614
5543
  // unmistakeably a select) without taking up visual real estate.
5615
- const toneLbl = lang === "zh" ? "tone" : "tone";
5616
- const intensityLbl = lang === "zh" ? "intensity" : "intensity";
5544
+ // System UI · always English (tune dropdown labels).
5545
+ const toneLbl = "tone";
5546
+ const intensityLbl = "intensity";
5617
5547
 
5618
5548
  // Starter grid · 2-col responsive cards.
5619
5549
  const starters = Array.isArray(window.BOARDROOM_STARTERS) ? window.BOARDROOM_STARTERS : [];
@@ -5679,12 +5609,10 @@
5679
5609
  },
5680
5610
 
5681
5611
  /** Time-of-day greeting like "// good evening, Kay" / "// 晚上好,Kay". */
5682
- composerGreeting(lang, name) {
5612
+ composerGreeting(_lang, name) {
5613
+ // System UI · always English. Greeting is part of the composer
5614
+ // chrome; the brief language doesn't change the app's voice.
5683
5615
  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
5616
  const part = h < 5 ? "Up late" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
5689
5617
  return `// ${part}, ${name}`;
5690
5618
  },
@@ -5851,7 +5779,7 @@
5851
5779
  * user can edit before submitting. */
5852
5780
  applyAgentStarter(idx) {
5853
5781
  const lang = this.composerLanguage();
5854
- const list = lang === "zh" ? this.AGENT_STARTERS_ZH : this.AGENT_STARTERS_EN;
5782
+ const list = this.AGENT_STARTERS_EN;
5855
5783
  const item = list[idx];
5856
5784
  if (!item) return;
5857
5785
  const ta = document.querySelector("[data-agent-composer-desc]");
@@ -5878,39 +5806,27 @@
5878
5806
  { key: "voice", label: "Picking the model voice", startSec: 28, sub: ["matching depth to role"] },
5879
5807
  { key: "polish", label: "Polishing", startSec: 31, sub: ["clamping lengths", "final tightening"] },
5880
5808
  ],
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
5809
  /** Web-search prefix · prepended to the base list when the user
5892
5810
  * opted into web search this run. Adds ~5s to the perceived
5893
- * pipeline; downstream startSecs are shifted in agentGenStagesFor. */
5811
+ * pipeline; downstream startSecs are shifted in agentGenStagesFor.
5812
+ * System UI · English-only. */
5894
5813
  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
5814
 
5897
5815
  /** 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;
5816
+ * is on, prepend the search stage and shift everything else later.
5817
+ * System UI · English-only regardless of `lang`. */
5818
+ agentGenStagesFor(_lang) {
5819
+ const base = this.AGENT_GEN_STAGES_EN_BASE;
5901
5820
  const useWs = !!this._agentGenUsingWebSearch;
5902
5821
  if (!useWs) return base;
5903
- const wsEntry = lang === "zh" ? this.AGENT_GEN_STAGES_WS_ZH : this.AGENT_GEN_STAGES_WS_EN;
5904
5822
  const SHIFT = 5;
5905
5823
  const shifted = base.map((s) => ({ ...s, startSec: s.startSec + SHIFT }));
5906
- return [wsEntry, ...shifted];
5824
+ return [this.AGENT_GEN_STAGES_WS_EN, ...shifted];
5907
5825
  },
5908
5826
 
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. */
5827
+ /** Back-compat shims · existing references read these names directly. */
5912
5828
  get AGENT_GEN_STAGES_EN() { return this.agentGenStagesFor("en"); },
5913
- get AGENT_GEN_STAGES_ZH() { return this.agentGenStagesFor("zh"); },
5829
+ get AGENT_GEN_STAGES_ZH() { return this.agentGenStagesFor("en"); },
5914
5830
 
5915
5831
  /** Start the stage tick. Called when /generate-spec request fires.
5916
5832
  * Idempotent — calling twice is safe. */
@@ -5925,8 +5841,7 @@
5925
5841
  return;
5926
5842
  }
5927
5843
  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;
5844
+ const stages = this.AGENT_GEN_STAGES_EN;
5930
5845
  // Find current stage index by elapsed time.
5931
5846
  let idx = 0;
5932
5847
  for (let i = 0; i < stages.length; i++) {
@@ -5957,12 +5872,13 @@
5957
5872
  },
5958
5873
 
5959
5874
  renderAgentGenStagesInner() {
5960
- const lang = this.composerLanguage();
5961
- const stages = lang === "zh" ? this.AGENT_GEN_STAGES_ZH : this.AGENT_GEN_STAGES_EN;
5875
+ // System UI · always English. Agent-generation stage panel is
5876
+ // app chrome around the LLM call.
5877
+ const stages = this.AGENT_GEN_STAGES_EN;
5962
5878
  const active = this.agentGenStageIndex;
5963
5879
  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";
5880
+ const elapsedLabel = `${Math.round(elapsed)} s elapsed`;
5881
+ const headerLabel = "Summoning director";
5966
5882
  const sigilSvg = this.renderAgentGenSigilSvg(stages, active, elapsed);
5967
5883
  // The active stage's headline pulse — lifted out from the list so
5968
5884
  // it reads as the focal "what's happening RIGHT NOW" line.
@@ -5980,7 +5896,7 @@
5980
5896
  <div class="ag-gen-stage-area">
5981
5897
  <div class="ag-gen-sigil" aria-hidden="true">${sigilSvg}</div>
5982
5898
  <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>
5899
+ <div class="ag-gen-active-kicker">${this.escape(`step ${active + 1} of ${stages.length}`)}</div>
5984
5900
  <div class="ag-gen-active-label">${this.escape(activeStage ? activeStage.label : "")}</div>
5985
5901
  ${activeSubText ? `<div class="ag-gen-active-sub">${this.escape(activeSubText)}</div>` : ""}
5986
5902
  </div>
@@ -6096,42 +6012,28 @@
6096
6012
  { 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
6013
  { tag: "phenomenologist", text: "An observer who notices what the room ISN'T saying. Tracks tone, what got skipped, who agreed too fast." },
6098
6014
  ],
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
- ],
6015
+ /** Legacy ZH list · kept as an alias of EN so any external caller
6016
+ * reading the property still resolves. System UI is English-only
6017
+ * per the global rule (the brief language doesn't change app
6018
+ * chrome). New code should reference AGENT_STARTERS_EN directly. */
6019
+ get AGENT_STARTERS_ZH() { return this.AGENT_STARTERS_EN; },
6107
6020
 
6108
6021
  renderAgentComposerHtml() {
6109
6022
  const userName = (this.prefs?.name || "you").trim() || "you";
6110
6023
  const lang = this.composerLanguage();
6111
6024
  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
- };
6025
+ // System UI · always English (new-agent composer chrome).
6026
+ const t = {
6027
+ greet: greeting,
6028
+ prompt: "What kind of director do you want?",
6029
+ 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.",
6030
+ cta: "Generate",
6031
+ ctaHint: "AI drafts the full spec — you'll edit before saving",
6032
+ manual: "Configure manually",
6033
+ generating: "Generating…",
6034
+ modelLabel: "model",
6035
+ starterCaption: "or start from an archetype",
6036
+ };
6135
6037
  // If we already have a spec preview, render that instead of the input.
6136
6038
  if (this.agentSpec) {
6137
6039
  return this.renderAgentSpecPreviewHtml(this.agentSpec, lang);
@@ -6145,7 +6047,7 @@
6145
6047
  const generating = this.agentSpecGenerating;
6146
6048
  const currentModel = this.loadAgentComposerModel();
6147
6049
  const modelDisplay = MODEL_LABELS[currentModel] || currentModel;
6148
- const starters = lang === "zh" ? this.AGENT_STARTERS_ZH : this.AGENT_STARTERS_EN;
6050
+ const starters = this.AGENT_STARTERS_EN;
6149
6051
  const starterCards = starters.map((q, idx) => `
6150
6052
  <button type="button" class="cmp-starter" data-agent-starter="${idx}">
6151
6053
  <div class="cmp-starter-tag">${this.escape(q.tag)}</div>
@@ -6181,23 +6083,14 @@
6181
6083
  // skill row at agent-profile.js:1411.
6182
6084
  const configured = this.agentComposerBraveConfigured();
6183
6085
  const on = configured && this.loadAgentComposerWebSearch();
6184
- const stateLabel = !configured
6185
- ? (lang === "zh" ? "未配置" : "needs key")
6186
- : on
6187
- ? (lang === "zh" ? "已开启" : "enabled")
6188
- : (lang === "zh" ? "已关闭" : "disabled");
6086
+ // System UI · always English (web-search toggle chrome).
6087
+ const stateLabel = !configured ? "needs key" : on ? "enabled" : "disabled";
6189
6088
  const titleText = !configured
6190
- ? (lang === "zh"
6191
- ? "联网搜索需要 Brave Search API key · 点击配置"
6192
- : "Web search needs a Brave Search API key · click to configure")
6089
+ ? "Web search needs a Brave Search API key · click to configure"
6193
6090
  : 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";
6091
+ ? "Search the web for real domain references during generation · click to disable"
6092
+ : "Generation runs offline · click to enable web search";
6093
+ const wsLabel = "web search";
6201
6094
  const cls = [
6202
6095
  "ap-skill-row-toggle",
6203
6096
  "cmp-ws-toggle",
@@ -6243,38 +6136,23 @@
6243
6136
  },
6244
6137
 
6245
6138
  /** 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
- };
6139
+ renderAgentSpecPreviewHtml(spec, _lang) {
6140
+ // System UI · always English (agent-spec preview card chrome).
6141
+ const t = {
6142
+ kicker: "// generated director · edit and save",
6143
+ avatar: "Avatar",
6144
+ reroll: "Reroll",
6145
+ name: "Name",
6146
+ handle: "Handle",
6147
+ role: "Role tag",
6148
+ bio: "Bio",
6149
+ quote: "Cover quote",
6150
+ instruction: "Instruction",
6151
+ model: "Model",
6152
+ save: "Save director",
6153
+ discard: "Discard",
6154
+ redo: "Regenerate",
6155
+ };
6278
6156
  const seed = this.agentSpecAvatarSeed;
6279
6157
  const avatarSvg = (window.AvatarSkill && seed)
6280
6158
  ? window.AvatarSkill.generate(seed, { size: 96 })
@@ -6361,25 +6239,16 @@
6361
6239
  * composer back to its input state). The description text is
6362
6240
  * surfaced read-only so the user can copy it before discard. */
6363
6241
  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
- };
6242
+ // System UI · always English (agent-spec error card chrome).
6243
+ const t = {
6244
+ kicker: err.kind === "timeout" ? "// generation timed out" : "// generation failed",
6245
+ title: err.kind === "timeout" ? "Generation didn't complete after 5 minutes" : "Generation failed",
6246
+ hintTimeout: "The model may be slow, the network flaky, or the backend pipeline stalled. Click retry to start a fresh run.",
6247
+ hintFailed: "Check your API key configuration and model reachability. Retry often clears transient failures.",
6248
+ descLabel: "Your description (re-used on retry)",
6249
+ retry: "Retry",
6250
+ discard: "Discard",
6251
+ };
6383
6252
  const desc = this._agentComposerLastDesc || "";
6384
6253
  const hint = err.kind === "timeout" ? t.hintTimeout : t.hintFailed;
6385
6254
  const detail = err.message ? `<div class="ag-gen-error-detail">${this.escape(err.message)}</div>` : "";
@@ -6387,7 +6256,7 @@
6387
6256
  <section class="cmp ag-cmp">
6388
6257
  <header class="cmp-hero">
6389
6258
  <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>
6259
+ <h1 class="cmp-prompt">${this.escape("What kind of director do you want?")}</h1>
6391
6260
  </header>
6392
6261
  <div class="ag-gen-error-card">
6393
6262
  <div class="ag-gen-error-kicker">${this.escape(t.kicker)}</div>
@@ -6552,13 +6421,11 @@
6552
6421
  this.agentSpecError = null;
6553
6422
  } catch (e) {
6554
6423
  const isAbort = (e && (e.name === "AbortError" || /aborted/i.test(String(e.message))));
6555
- const lang = this.composerLanguage();
6556
6424
  if (timedOut || isAbort) {
6425
+ // System UI · always English (error message text).
6557
6426
  this.agentSpecError = {
6558
6427
  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.",
6428
+ message: "Generation took longer than 5 minutes · the model may be slow, the network flaky, or the backend stuck.",
6562
6429
  };
6563
6430
  } else {
6564
6431
  this.agentSpecError = {
@@ -6722,10 +6589,9 @@
6722
6589
  // Sort newest-first · the most recently filed brief is the one a
6723
6590
  // returning user most likely wants to re-open.
6724
6591
  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" };
6592
+ // System UI · always English. Brief picker chrome (popover
6593
+ // title, row labels) doesn't follow the brief language.
6594
+ const t = { title: "Open a report", supplementPrefix: "Supplement: ", initial: "Initial", filed: "filed" };
6729
6595
  const initialIdx = briefs.length - 1; // oldest is "Initial"
6730
6596
  const rows = briefs.map((b, i) => {
6731
6597
  // The numbering is stable across renders: oldest = 01, newest
@@ -6740,7 +6606,7 @@
6740
6606
  : "";
6741
6607
  const subtitle = isInitial ? t.initial : (supplementSnippet ? `${t.supplementPrefix}${supplementSnippet}` : "");
6742
6608
  const filedLabel = b.createdAt
6743
- ? new Date(b.createdAt).toLocaleString(lang === "zh" ? "zh-CN" : undefined, {
6609
+ ? new Date(b.createdAt).toLocaleString(undefined, {
6744
6610
  year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
6745
6611
  })
6746
6612
  : "";
@@ -6834,10 +6700,8 @@
6834
6700
  const dirs = (this.agents || [])
6835
6701
  .filter((a) => a.roleKind !== "moderator")
6836
6702
  .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" };
6703
+ // System UI · always English (composer director picker chrome).
6704
+ const t = { title: "Pick directors", hint: "2-4 recommended", done: "Done", info: "View profile" };
6841
6705
  const rows = dirs.map((a) => {
6842
6706
  const checked = state.directorIds.includes(a.id);
6843
6707
  const modelLabel = MODEL_LABELS[a.modelV] || a.modelV || "";
@@ -6974,17 +6838,16 @@
6974
6838
  const isAutoPick = state.autoPickDirectors === true && dirObjs.length === 0;
6975
6839
  btn.classList.toggle("cmp-cast-btn-auto", isAutoPick);
6976
6840
  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";
6841
+ // System UI · always English (auto-pick chip tooltip).
6842
+ const autoTip = "Chair picks 3 directors based on your subject when you Convene · click to pick manually";
6980
6843
  btn.title = autoTip;
6981
6844
  btn.innerHTML = `
6982
6845
  <span class="cmp-cast-stack cmp-cast-stack-auto" data-cast-auto>
6983
6846
  <span class="cmp-cast-auto-mark">✦</span>
6984
6847
  </span>
6985
6848
  <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>
6849
+ <span class="cmp-cast-auto-key">directors</span>
6850
+ <span class="cmp-cast-auto-val">auto-pick</span>
6988
6851
  </span>
6989
6852
  `;
6990
6853
  return;
@@ -6992,9 +6855,10 @@
6992
6855
  const visible = dirObjs.slice(0, 4);
6993
6856
  const overflow = Math.max(0, dirObjs.length - 4);
6994
6857
  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("");
6858
+ // System UI · always English (cast button count chrome).
6995
6859
  const countText = dirObjs.length
6996
- ? (lang === "zh" ? `${dirObjs.length} 位董事` : `${dirObjs.length} director${dirObjs.length === 1 ? "" : "s"}`)
6997
- : (lang === "zh" ? "未选董事" : "no directors");
6860
+ ? `${dirObjs.length} director${dirObjs.length === 1 ? "" : "s"}`
6861
+ : "no directors";
6998
6862
  const countCls = dirObjs.length ? "cmp-cast-count" : "cmp-cast-count cmp-cast-empty";
6999
6863
  btn.innerHTML = `
7000
6864
  <span class="cmp-cast-stack">
@@ -7042,21 +6906,14 @@
7042
6906
  let opts;
7043
6907
  let current;
7044
6908
  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
- ];
6909
+ // System UI · always English (tune dropdown options).
6910
+ opts = [
6911
+ { v: "brainstorm", label: "Brainstorm", hint: "yes-and" },
6912
+ { v: "constructive", label: "Constructive", hint: "push & sharpen" },
6913
+ { v: "research", label: "Research", hint: "mine the material" },
6914
+ { v: "debate", label: "Debate", hint: "find the holes" },
6915
+ { v: "critique", label: "Critique", hint: "audit the deliverable" },
6916
+ ];
7060
6917
  if (followUpScope) {
7061
6918
  const valSpan = triggerBtn.querySelector("[data-cmp-dd-value]");
7062
6919
  current = valSpan ? (valSpan.textContent || "").trim().toLowerCase() : "";
@@ -7068,17 +6925,12 @@
7068
6925
  // the third value (terse) is a cadence dial, not a harshness
7069
6926
  // dial. The earlier "no prisoners" / "直击痛点" copy pulled
7070
6927
  // 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
- ];
6928
+ // System UI · always English (intensity dropdown options).
6929
+ opts = [
6930
+ { v: "calm", label: "Calm", hint: "let them think" },
6931
+ { v: "sharp", label: "Sharp", hint: "no hedging" },
6932
+ { v: "terse", label: "Terse", hint: "telegraphic" },
6933
+ ];
7082
6934
  if (followUpScope) {
7083
6935
  const valSpan = triggerBtn.querySelector("[data-cmp-dd-value]");
7084
6936
  current = valSpan ? (valSpan.textContent || "").trim().toLowerCase() : "";
@@ -7221,7 +7073,7 @@
7221
7073
  const useAutoPick = state.autoPickDirectors === true && state.directorIds.length === 0;
7222
7074
  if (!useAutoPick && !state.directorIds.length) {
7223
7075
  const lang = this.composerLanguage();
7224
- alert(lang === "zh" ? "请至少选择一位董事再 convene" : "Pick at least one director before convening");
7076
+ alert("Pick at least one director before convening");
7225
7077
  return;
7226
7078
  }
7227
7079
  const btn = document.querySelector("[data-composer-go]");
@@ -7432,10 +7284,10 @@
7432
7284
  if (!multi) {
7433
7285
  return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report>[ View Report ]</a>`;
7434
7286
  }
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>`;
7287
+ 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
7288
  })()
7437
7289
  : (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>`
7290
+ ? `<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
7291
  : "")}
7440
7292
  </div>
7441
7293
  `;
@@ -7516,23 +7368,17 @@
7516
7368
  conveningCardHtml() {
7517
7369
  const s = this.conveneState;
7518
7370
  if (!s) return "";
7519
- const lang = (s.subject && /[一-鿿]/.test(s.subject)) ? "zh" : "en";
7520
-
7371
+ // System UI · always English. Convening overlay (analyzing /
7372
+ // seating / preparing labels + decks) is app chrome.
7521
7373
  // Stages · auto-picked rooms run all three; manually-cast rooms
7522
7374
  // skip "analyzing" + "seating" because the cast is pre-set.
7523
7375
  const stageOrder = s.autoPicked
7524
7376
  ? ["analyzing", "seating", "preparing"]
7525
7377
  : ["preparing"];
7526
7378
  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" },
7379
+ analyzing: { title: "Analyzing topic", deck: "Routing your topic to the right perspectives" },
7380
+ seating: { title: "Seating directors", deck: "Picking the right perspectives for this question" },
7381
+ preparing: { title: "Chair preparing remarks", deck: "Drafting the convening speech" },
7536
7382
  };
7537
7383
 
7538
7384
  const currentIdx = stageOrder.indexOf(s.stage);
@@ -7564,7 +7410,7 @@
7564
7410
  // already in currentMembers; rendering it here would be redundant).
7565
7411
  const seatedRow = (s.autoPicked && s.seated.length > 0) ? `
7566
7412
  <div class="conv-seated">
7567
- <div class="conv-seated-label">${lang === "zh" ? "已入席" : "seated"} · ${s.seated.length}</div>
7413
+ <div class="conv-seated-label">seated · ${s.seated.length}</div>
7568
7414
  <div class="conv-seated-list">
7569
7415
  ${s.seated.map((a) => `
7570
7416
  <div class="conv-seated-item" data-agent-id="${this.escape(a.id)}">
@@ -7581,7 +7427,7 @@
7581
7427
 
7582
7428
  return `
7583
7429
  <article class="convening-card" data-convene-card>
7584
- <div class="conv-eyebrow">${lang === "zh" ? "▸ 召集中" : "▸ CONVENING"}</div>
7430
+ <div class="conv-eyebrow">▸ CONVENING</div>
7585
7431
  ${s.subject ? `<blockquote class="conv-subject">${this.escape(s.subject)}</blockquote>` : ""}
7586
7432
  <ul class="conv-stages">${stagesHtml}</ul>
7587
7433
  ${seatedRow}
@@ -7618,11 +7464,9 @@
7618
7464
  const chat = document.querySelector("[data-chat-messages]");
7619
7465
  if (!chat) return;
7620
7466
  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" };
7467
+ // System UI · always English (chair-pending placeholder chrome).
7468
+ const chairName = (this.currentChair?.name) || "Chair";
7469
+ 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
7470
  const phraseRaw = labelMap[phase] || labelMap.chair;
7627
7471
  const phrase = `${chairName} ${phraseRaw}…`;
7628
7472
  if (existing) {
@@ -7635,7 +7479,7 @@
7635
7479
  node.setAttribute("data-chair-pending", "");
7636
7480
  node.innerHTML = `
7637
7481
  <div class="cp-rule" aria-hidden="true"></div>
7638
- <div class="cp-kicker">▸ ${this.escape(isCjk ? "主席" : "chair")}</div>
7482
+ <div class="cp-kicker">▸ chair</div>
7639
7483
  <div class="cp-body">
7640
7484
  <span class="cp-text">${this.escape(phrase)}</span>
7641
7485
  <span class="thinking-dots"><span></span><span></span><span></span></span>
@@ -7817,7 +7661,7 @@
7817
7661
  // 中文 copy. Reads from the chair's prompt body which lands in
7818
7662
  // the message body when a recommendation is present.
7819
7663
  const lang = (promptMsg && promptMsg.body && /[一-鿿]/.test(promptMsg.body)) ? "zh" : "en";
7820
- const recLabel = lang === "zh" ? "主席建议" : "chair recommends";
7664
+ const recLabel = "chair recommends";
7821
7665
  const recIndicator = recKind
7822
7666
  ? `
7823
7667
  <div class="rp-rec-line rp-rec-line-${this.escape(recKind)}">
@@ -8045,14 +7889,14 @@
8045
7889
  const truncated = parentSubject.length > 70
8046
7890
  ? parentSubject.slice(0, 70) + "…"
8047
7891
  : parentSubject;
8048
- const isZh = /[一-鿿]/.test(this.currentRoom?.subject || "");
8049
- const label = isZh ? "继续自" : "Following up on";
8050
- const roomTag = isZh ? "Room #" : "Room #";
7892
+ // System UI · always English (follow-up origin link chrome).
7893
+ const label = "Following up on";
7894
+ const roomTag = "Room #";
8051
7895
  const subjectChunk = truncated
8052
7896
  ? `<span class="convene-origin-sep">·</span><span class="convene-origin-subject">${this.escape(truncated)}</span>`
8053
7897
  : "";
8054
7898
  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))}">
7899
+ <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
7900
  <span class="convene-origin-arrow">↩</span>
8057
7901
  <span class="convene-origin-label">${this.escape(label)}</span>
8058
7902
  <span class="convene-origin-room">${this.escape(roomTag)}${this.escape(String(parentNum))}</span>
@@ -8065,9 +7909,11 @@
8065
7909
  parentId ? "convene-opener-followup" : "",
8066
7910
  isLongOpener ? "convene-opener-clamped" : "",
8067
7911
  ].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 ↑";
7912
+ // System UI · always English. Earlier this followed the brief
7913
+ // language; now fixed-string per the rule that app chrome stays
7914
+ // English regardless of the user's query language.
7915
+ const moreLabel = "Show more ↓";
7916
+ const lessLabel = "Show less ↑";
8071
7917
  const toggleHtml = isLongOpener
8072
7918
  ? `<button type="button" class="convene-toggle" data-convene-toggle data-more="${this.escape(moreLabel)}" data-less="${this.escape(lessLabel)}">${moreLabel}</button>`
8073
7919
  : "";
@@ -8293,7 +8139,7 @@
8293
8139
  // as a historical marker only.
8294
8140
  if (isChair && metaKind === "no-brief") {
8295
8141
  const ts = this.timeFmt(m.createdAt);
8296
- const isZh = this.composerLanguage() === "zh";
8142
+ // System UI · always English (no-brief card chrome).
8297
8143
  const hasBrief = !!this.currentBrief;
8298
8144
  const cta = hasBrief
8299
8145
  ? ""
@@ -8301,7 +8147,7 @@
8301
8147
  <div class="nb-actions">
8302
8148
  <button type="button" class="nb-cta" data-generate-brief>
8303
8149
  <span class="nb-cta-mark">▸</span>
8304
- <span class="nb-cta-text">${isZh ? "生成报告" : "Generate report now"}</span>
8150
+ <span class="nb-cta-text">Generate report now</span>
8305
8151
  </button>
8306
8152
  </div>
8307
8153
  `;
@@ -8312,7 +8158,7 @@
8312
8158
  <span class="nb-eyebrow">adjourned · no brief filed</span>
8313
8159
  </span>
8314
8160
  <div class="nb-body">
8315
- <strong>${this.escape(this.prefs?.name || "The chair")}</strong> ${isZh ? "在结束时跳过了报告。" : "declared no report is needed for this session."}
8161
+ <strong>${this.escape(this.prefs?.name || "The chair")}</strong> declared no report is needed for this session.
8316
8162
  </div>
8317
8163
  ${cta}
8318
8164
  <div class="nb-meta">${this.escape(ts)}</div>
@@ -8763,31 +8609,17 @@
8763
8609
  * research note reads "sweet." */
8764
8610
  _briefWordCountTip(wc) {
8765
8611
  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;
8612
+ // System UI · always English (word-count chip tooltip).
8613
+ const toneLabel = wc.tone;
8774
8614
  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
- };
8615
+ const unit = "words";
8616
+ const copy = {
8617
+ "thin": `Lean · ${toneLabel} sweet zone is ${range} ${unit}`,
8618
+ "sweet": `Sweet zone for ${toneLabel} · most read-through-able length`,
8619
+ "dense": `Dense · past the ${toneLabel} sweet zone, still readable`,
8620
+ "long": `Long · approaching "filed instead of read"`,
8621
+ "too-long": `Too long · likely to be skimmed, not read`,
8622
+ };
8791
8623
  return copy[wc.tier] || "";
8792
8624
  },
8793
8625
 
@@ -8800,9 +8632,7 @@
8800
8632
  const briefs = Array.isArray(this.currentBriefs) ? this.currentBriefs : [];
8801
8633
  if (briefs.length < 2) return "";
8802
8634
  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";
8635
+ // System UI · always English (brief-version tab strip chrome).
8806
8636
  return `
8807
8637
  <div class="brief-versions">
8808
8638
  ${sortedBriefs.map((bf, i) => {
@@ -8811,11 +8641,11 @@
8811
8641
  const isInitial = i === 0;
8812
8642
  const supp = bf.supplement && bf.supplement.trim()
8813
8643
  ? bf.supplement.trim()
8814
- : (isInitial ? (lang === "zh" ? "初版" : "Initial") : "");
8644
+ : (isInitial ? "Initial" : "");
8815
8645
  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";
8646
+ ? `Initial brief · generated from the session`
8647
+ : `Supplement: ${supp || "—"}`;
8648
+ const closeTitle = "Delete this report";
8819
8649
  // Errored / interrupted / timed-out tabs get a small
8820
8650
  // visual marker so the user can spot which one needs
8821
8651
  // attention without entering it. The full retry UI is
@@ -8829,7 +8659,7 @@
8829
8659
  <span class="brief-version-num">${num}</span>
8830
8660
  ${stateMark}
8831
8661
  ${isInitial
8832
- ? `<span class="brief-version-label">${lang === "zh" ? "初版" : "Initial"}</span>`
8662
+ ? `<span class="brief-version-label">Initial</span>`
8833
8663
  : `<span class="brief-version-label">${this.escape((supp || "").slice(0, 20))}${(supp || "").length > 20 ? "…" : ""}</span>`}
8834
8664
  </button>
8835
8665
  <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>
@@ -8902,14 +8732,14 @@
8902
8732
  finally { this.currentBrief = prevCurrent; }
8903
8733
  // Prepend the compact retry banner to the rendered card so
8904
8734
  // the existing report stays fully visible below it.
8905
- const lang = (failed.language === "zh" || (this.currentRoom?.subject && /[一-鿿]/.test(this.currentRoom.subject))) ? "zh" : "en";
8735
+ // System UI · always English (error banner chrome).
8906
8736
  const detail = failed.timedOut
8907
- ? (lang === "zh" ? "重新生成超时" : "regeneration timed out")
8737
+ ? "regeneration timed out"
8908
8738
  : 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";
8739
+ ? "regeneration interrupted"
8740
+ : (failed.error || "regeneration failed");
8741
+ const cta = "Retry";
8742
+ const dismiss = "Dismiss";
8913
8743
  card.insertAdjacentHTML("afterbegin", `
8914
8744
  <div class="brief-retry-banner" data-brief-retry-banner data-failed-brief-id="${this.escape(failed.id)}">
8915
8745
  <span class="brb-mark">⚠</span>
@@ -8923,54 +8753,30 @@
8923
8753
  `);
8924
8754
  return;
8925
8755
  }
8926
- const lang = (b.language === "zh" || (this.currentRoom?.subject && /[一-鿿]/.test(this.currentRoom.subject))) ? "zh" : "en";
8756
+ // System UI · always English (full error-card copy).
8927
8757
  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
- })
8758
+ ? {
8759
+ stamp: "timed out",
8760
+ kicker: "// generation timed out",
8761
+ 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.",
8762
+ hint: "",
8763
+ cta: "Retry",
8764
+ }
8943
8765
  : 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
- });
8766
+ ? {
8767
+ stamp: "interrupted",
8768
+ kicker: "// generation interrupted",
8769
+ detail: "The previous generation was cut short — likely by a browser refresh or a server restart. Click below to start a fresh report.",
8770
+ hint: "",
8771
+ cta: "Regenerate report",
8772
+ }
8773
+ : {
8774
+ stamp: "failed",
8775
+ kicker: "// brief generation failed",
8776
+ detail: this.escape(b.error || ""),
8777
+ 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.",
8778
+ cta: "Retry",
8779
+ };
8974
8780
  card.innerHTML = `
8975
8781
  <div class="brief-card">
8976
8782
  ${tabsStripHtml}
@@ -9067,11 +8873,11 @@
9067
8873
  <div class="brief-supplement-row">
9068
8874
  <button type="button" class="brief-supplement-btn" data-brief-supplement>
9069
8875
  <span class="brief-supplement-mark">+</span>
9070
- <span class="brief-supplement-label">${(b.language === "zh" ? "补充视角,再生成一版报告" : "Add a perspective · regenerate")}</span>
8876
+ <span class="brief-supplement-label">Add a perspective · regenerate</span>
9071
8877
  </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")}">
8878
+ <button type="button" class="brief-delete-btn" data-brief-delete data-brief-id="${this.escape(b.id)}" title="Delete this report">
9073
8879
  <span class="brief-delete-mark">⌫</span>
9074
- <span class="brief-delete-label">${(b.language === "zh" ? "删除报告" : "Delete report")}</span>
8880
+ <span class="brief-delete-label">Delete report</span>
9075
8881
  </button>
9076
8882
  </div>
9077
8883
  ` : ""}
@@ -9155,49 +8961,10 @@
9155
8961
  "Polishing the final pass",
9156
8962
  ],
9157
8963
  },
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
- "辨认未消化的立场",
9183
- ],
9184
- "scaffold-actions": [
9185
- "起草 recommendations",
9186
- "推演 pre-mortem",
9187
- "梳理会议长出的新问题",
9188
- ],
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
- "通读润色,准备交稿",
9199
- ],
9200
- },
8964
+ // System UI · always English. The `zh` key is kept as an alias
8965
+ // of `en` so any caller indexing BRIEF_SUBSTAGES["zh"] still
8966
+ // resolves; brief language no longer changes the substage copy.
8967
+ get zh() { return this.en; },
9201
8968
  },
9202
8969
 
9203
8970
  /** Set up a 1s tick that re-renders the brief stages while at least
@@ -9293,9 +9060,8 @@
9293
9060
  // Hard ceiling · regardless of server state, flip the card to
9294
9061
  // a timed-out error so the user always has a way out.
9295
9062
  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).";
9063
+ // System UI · always English (timeout error message).
9064
+ b.error = "Brief generation timed out (no completion after 8 minutes).";
9299
9065
  b.timedOut = true;
9300
9066
  this.stopBriefStageTick();
9301
9067
  this.stopBriefStallWatch();
@@ -9340,7 +9106,7 @@
9340
9106
  write: { status: "pending", detail: "", progress: null, startedAt: null },
9341
9107
  };
9342
9108
  const lang = b.language === "zh" ? "zh" : "en";
9343
- const chairName = b.chairName || this.currentChair?.name || (lang === "zh" ? "主席" : "Chair");
9109
+ const chairName = b.chairName || this.currentChair?.name || "Chair";
9344
9110
 
9345
9111
  const wordCount = b.bodyMd
9346
9112
  ? (b.bodyMd.trim().match(/\S+/g) || []).length
@@ -9355,25 +9121,18 @@
9355
9121
  // Stage 2 streaming buffer (see runStage2 / SCAFFOLD_TRIGGERS in
9356
9122
  // brief.ts), so each pip transition reflects a real moment in the
9357
9123
  // model's output — not a synthetic timer.
9358
- const STAGE_DEFS = lang === "zh"
9359
- ? [
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: "" },
9367
- ]
9368
- : [
9369
- { key: "extract", label: "Reading what each director said", pipShort: "read" },
9370
- { key: "compose", label: "Picking the report shape", pipShort: "pick" },
9371
- { key: "scaffold-anchor", label: "Setting the anchor", pipShort: "anchor" },
9372
- { key: "scaffold-findings", label: "Sketching findings", pipShort: "find" },
9373
- { key: "scaffold-cluster", label: "Mapping consensus + dissent", pipShort: "split" },
9374
- { key: "scaffold-actions", label: "Drafting actions + risks", pipShort: "act" },
9375
- { key: "write", label: "Writing the report", pipShort: "write" },
9376
- ];
9124
+ // System UI · always English regardless of brief language.
9125
+ // The pipeline labels are the app's voice (chrome around the
9126
+ // generation), not the report content itself.
9127
+ const STAGE_DEFS = [
9128
+ { key: "extract", label: "Reading what each director said", pipShort: "read" },
9129
+ { key: "compose", label: "Picking the report shape", pipShort: "pick" },
9130
+ { key: "scaffold-anchor", label: "Setting the anchor", pipShort: "anchor" },
9131
+ { key: "scaffold-findings", label: "Sketching findings", pipShort: "find" },
9132
+ { key: "scaffold-cluster", label: "Mapping consensus + dissent", pipShort: "split" },
9133
+ { key: "scaffold-actions", label: "Drafting actions + risks", pipShort: "act" },
9134
+ { key: "write", label: "Writing the report", pipShort: "write" },
9135
+ ];
9377
9136
 
9378
9137
  const meta = this.BRIEF_STAGE_META;
9379
9138
  const substages = (this.BRIEF_SUBSTAGES[lang] || this.BRIEF_SUBSTAGES.en);
@@ -9437,12 +9196,12 @@
9437
9196
  if (activeDef.key === "extract" && activeStage.progress?.total) {
9438
9197
  const cur = activeStage.progress.current;
9439
9198
  const tot = activeStage.progress.total;
9440
- detailParts.push(lang === "zh" ? `${cur}/${tot} 位董事` : `${cur}/${tot} director${tot === 1 ? "" : "s"}`);
9199
+ detailParts.push(`${cur}/${tot} director${tot === 1 ? "" : "s"}`);
9441
9200
  } else if (activeStage.detail) {
9442
9201
  detailParts.push(activeStage.detail);
9443
9202
  }
9444
9203
  if (activeDef.key === "write" && activeStatus === "active" && wordCount > 0) {
9445
- detailParts.push(lang === "zh" ? `${wordCount} 字` : `${wordCount} word${wordCount === 1 ? "" : "s"}`);
9204
+ detailParts.push(`${wordCount} word${wordCount === 1 ? "" : "s"}`);
9446
9205
  }
9447
9206
  const detailLine = detailParts.join(" · ");
9448
9207
 
@@ -9454,7 +9213,7 @@
9454
9213
  ? `${activeElapsed}s · ~${activeEta[0]}–${activeEta[1]}s`
9455
9214
  : `~${activeEta[0]}–${activeEta[1]}s`;
9456
9215
  } else {
9457
- timing = lang === "zh" ? `已耗时 ${activeElapsed}s` : `${activeElapsed}s elapsed`;
9216
+ timing = `${activeElapsed}s elapsed`;
9458
9217
  }
9459
9218
  }
9460
9219
 
@@ -9510,19 +9269,15 @@
9510
9269
  return r === 0 ? `${m}m` : `${m}m ${r}s`;
9511
9270
  };
9512
9271
  const fmtRange = (lo, hi) => {
9513
- if (hi < 60) return lang === "zh" ? `约 ${lo}–${hi}s` : `~${lo}–${hi}s`;
9272
+ if (hi < 60) return `~${lo}–${hi}s`;
9514
9273
  const loM = Math.max(1, Math.round(lo / 60));
9515
9274
  const hiM = Math.max(loM, Math.round(hi / 60));
9516
- return lang === "zh" ? `约 ${loM}–${hiM} 分钟` : `~${loM}-${hiM}m`;
9275
+ return `~${loM}-${hiM}m`;
9517
9276
  };
9518
9277
 
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}`;
9278
+ // System UI · always English (brief-progress kicker copy).
9279
+ const totalText = `${fmtSec(totalElapsed)} elapsed · ${fmtRange(totalLo, totalHi)} left`;
9280
+ const kickerCore = `// ${chairName} is preparing the minutes · ${totalText}`;
9526
9281
 
9527
9282
  const metaHtml = (detailLine || timing)
9528
9283
  ? `<span class="brief-active-meta">` +
@@ -9554,9 +9309,8 @@
9554
9309
  this._briefSeenHarvestKeys = this._briefSeenHarvestKeys || {};
9555
9310
  const seenH = this._briefSeenHarvestKeys[b.id] = this._briefSeenHarvestKeys[b.id] || new Set();
9556
9311
  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" };
9312
+ // System UI · always English (signal-kind chip labels).
9313
+ const kindLabels = { claims: "claims", evidence: "evidence", tensions: "tensions", assumptions: "assumptions", risks: "risks", opportunities: "opportunities", actions: "actions", quotes: "quotes", openQuestions: "open-q" };
9560
9314
  if (harvest.length) {
9561
9315
  const chips = harvest.map((h) => {
9562
9316
  const isFresh = !seenH.has(h.directorId);
@@ -9581,7 +9335,7 @@
9581
9335
  // Live word count during write — large and visible since it's the
9582
9336
  // most engaging signal during the long write stage.
9583
9337
  if (writeActive && wordCount > 0) {
9584
- const w = lang === "zh" ? `${wordCount} 字 · 还在落笔` : `${wordCount} word${wordCount === 1 ? "" : "s"} · still writing`;
9338
+ const w = `${wordCount} word${wordCount === 1 ? "" : "s"} · still writing`;
9585
9339
  stats.push(`<span class="brief-stat-fact brief-stat-live">${this.escape(w)}</span>`);
9586
9340
  }
9587
9341
  const statsHtml = stats.length
@@ -9902,8 +9656,8 @@
9902
9656
  genBriefBtn.setAttribute("data-pending", "1");
9903
9657
  if ("disabled" in genBriefBtn) genBriefBtn.disabled = true;
9904
9658
 
9905
- const isZh = app.composerLanguage() === "zh";
9906
- const generatingText = isZh ? "正在生成…" : "generating…";
9659
+ // System UI · always English (generating-button chrome).
9660
+ const generatingText = "generating…";
9907
9661
  const originalHtml = genBriefBtn.innerHTML;
9908
9662
 
9909
9663
  // Swap to a "generating…" state. The two button shapes both
@@ -9926,7 +9680,7 @@
9926
9680
  genBriefBtn.removeAttribute("data-pending");
9927
9681
  if ("disabled" in genBriefBtn) genBriefBtn.disabled = false;
9928
9682
  genBriefBtn.innerHTML = originalHtml;
9929
- alert((isZh ? "生成失败:" : "Brief generation failed: ") + (err && err.message ? err.message : err));
9683
+ alert("Brief generation failed: " + (err && err.message ? err.message : err));
9930
9684
  });
9931
9685
  return;
9932
9686
  }
@@ -10185,11 +9939,9 @@
10185
9939
  // map, which user-settings.js patches in place after every
10186
9940
  // setProviderKey, so we get fresh truth here.
10187
9941
  const configured = !!(app.agentComposerBraveConfigured && app.agentComposerBraveConfigured());
10188
- const isZh = (app.composerLanguage && app.composerLanguage()) === "zh";
10189
9942
  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?");
9943
+ // System UI · always English (Brave key prompt confirm dialog).
9944
+ 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
9945
  if (ok && typeof window.openUserSettings === "function") {
10194
9946
  window.openUserSettings({ section: "keys", focusProvider: "brave" });
10195
9947
  }
@@ -10211,19 +9963,14 @@
10211
9963
  wsToggle.setAttribute("aria-pressed", next ? "true" : "false");
10212
9964
  const txt = wsToggle.querySelector(".ap-skill-row-toggle-text");
10213
9965
  if (txt) {
10214
- const wsLabel = isZh ? "联网搜索" : "web search";
10215
- const stateLabel = next
10216
- ? (isZh ? "已开启" : "enabled")
10217
- : (isZh ? "已关闭" : "disabled");
9966
+ // System UI · always English (web-search toggle chrome).
9967
+ const wsLabel = "web search";
9968
+ const stateLabel = next ? "enabled" : "disabled";
10218
9969
  txt.textContent = `${wsLabel} · ${stateLabel}`;
10219
9970
  }
10220
9971
  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");
9972
+ ? "Search the web for real domain references during generation · click to disable"
9973
+ : "Generation runs offline · click to enable web search";
10227
9974
  return;
10228
9975
  }
10229
9976
  // ─── Agent spec preview · field actions
@@ -10359,6 +10106,20 @@
10359
10106
  if (id) app.deleteRoom(id);
10360
10107
  return;
10361
10108
  }
10109
+ // Delete a saved note from the All Notes list. The button is a
10110
+ // sibling of the note's anchor (inside .notes-item) so its click
10111
+ // never bubbles through the navigation link — but we still
10112
+ // preventDefault + stopPropagation defensively to keep the row's
10113
+ // hover/focus state quiet.
10114
+ const noteDel = e.target.closest("[data-note-delete]");
10115
+ if (noteDel) {
10116
+ e.preventDefault();
10117
+ e.stopPropagation();
10118
+ const item = noteDel.closest("[data-note-id]");
10119
+ const id = item?.dataset.noteId;
10120
+ if (id) app.deleteNoteAt(id);
10121
+ return;
10122
+ }
10362
10123
  // Delete a custom agent · this used to live as an inline X button
10363
10124
  // on the sidebar row. It's been moved into the agent profile's
10364
10125
  // ⋯ overflow menu (see agent-profile.js → "delete" menu action),