privateboard 0.1.9 → 0.1.11

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
@@ -34,6 +34,7 @@
34
34
  "grok-4-1-fast": "Grok 4.1 Fast",
35
35
  "grok-4-20": "Grok 4.20",
36
36
  "deepseek-v4-pro": "DeepSeek V4 Pro",
37
+ "deepseek-v4-flash": "DeepSeek Lite",
37
38
  };
38
39
 
39
40
  /** Full model catalog for the new-agent composer dropdown. Mirrors
@@ -47,8 +48,7 @@
47
48
  { v: "sonnet-4-6", label: "Claude Sonnet 4.6", provider: "Anthropic", deck: "balanced · default" },
48
49
  { v: "opus-4-6", label: "Claude Opus 4.6", provider: "Anthropic", deck: "prior-gen flagship" },
49
50
  { v: "opus-4-6-fast", label: "Claude Opus 4.6 Fast", provider: "Anthropic", deck: "faster 4.6 · same intelligence" },
50
- { v: "haiku-4-5", label: "Claude Haiku 4.5", provider: "Anthropic", deck: "fast · low-cost" },
51
- { v: "gpt-5-5-pro", label: "GPT-5.5 Pro", provider: "OpenAI", deck: "flagship · 1M ctx" },
51
+ { v: "haiku-4-5", label: "Claude Haiku 4.5", provider: "Anthropic", deck: "fast · low-cost" }, { v: "gpt-5-5-pro", label: "GPT-5.5 Pro", provider: "OpenAI", deck: "flagship · 1M ctx" },
52
52
  { v: "gpt-5-5", label: "GPT-5.5", provider: "OpenAI", deck: "1M ctx" },
53
53
  { v: "gpt-5-4", label: "GPT-5.4", provider: "OpenAI", deck: "general · 1M ctx" },
54
54
  { v: "gpt-5-4-mini", label: "GPT-5.4 Mini", provider: "OpenAI", deck: "fast · 400k ctx" },
@@ -60,6 +60,7 @@
60
60
  { v: "grok-4-1-fast", label: "Grok 4.1 Fast", provider: "xAI", deck: "fast · 256k ctx" },
61
61
  { v: "grok-4-20", label: "Grok 4.20", provider: "xAI", deck: "2M ctx · big context" },
62
62
  { v: "deepseek-v4-pro", label: "DeepSeek V4 Pro", provider: "DeepSeek", deck: "reasoning · open weights" },
63
+ { v: "deepseek-v4-flash", label: "DeepSeek Lite", provider: "DeepSeek", deck: "V4 Flash · fast · 1M ctx" },
63
64
  ];
64
65
 
65
66
  /** Tone tooltips · short, user-readable summary of how each tone
@@ -93,6 +94,7 @@
93
94
  currentMembers: [], // directors only (chair excluded)
94
95
  currentChair: null, // chair agent for the current room
95
96
  currentQueue: [],
97
+ voiceQueues: {},
96
98
  /** Round progress from the orchestrator: how many directors have
97
99
  * spoken in the current round vs. the cap (= cast size). */
98
100
  currentRound: { spoken: 0, total: 0 },
@@ -181,10 +183,20 @@
181
183
  if (!card) return;
182
184
  const expanded = card.classList.toggle("expanded");
183
185
  const label = expanded
184
- ? (btn.getAttribute("data-less") || "Show less")
185
- : (btn.getAttribute("data-more") || "Show more");
186
+ ? (btn.getAttribute("data-less") || this._t("convene_show_less"))
187
+ : (btn.getAttribute("data-more") || this._t("convene_show_more"));
186
188
  btn.textContent = label;
187
189
  });
190
+ document.addEventListener("boardroom:locale", () => {
191
+ this.renderSidebarRooms();
192
+ this.renderSidebarAgents();
193
+ this.renderSidebarCounts();
194
+ this.renderUserBlock();
195
+ if (this.currentRoomId) this.renderRoom();
196
+ else this.renderEmptyState();
197
+ if (Array.isArray(this._reportsCache)) this.renderReportsPage(this._reportsCache);
198
+ if (Array.isArray(this._notesCache)) this.renderNotesPage(this._notesCache);
199
+ });
188
200
  window.addEventListener("hashchange", () => this.handleRoute());
189
201
  this.handleRoute();
190
202
 
@@ -294,11 +306,10 @@
294
306
 
295
307
  const count = fresh.length;
296
308
  const names = fresh.map((m) => m.name).join(", ");
297
- // System UI · always English (storage-migration banner).
309
+ const bodyKey = count === 1 ? "migrate_body_one" : "migrate_body";
298
310
  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,
311
+ head: this._t("migrate_head"),
312
+ body: this._t(bodyKey, { count }), tooltip: names,
302
313
  };
303
314
  textEl.innerHTML =
304
315
  `<span class="sys-notice-strong">${this.escape(copy.head)}</span> · ${this.escape(copy.body)}`;
@@ -806,12 +817,32 @@
806
817
 
807
818
  this.sse.addEventListener("message-removed", (e) => {
808
819
  const data = JSON.parse(e.data);
820
+ // Stop any voice playback for this message (e.g. READY control token)
821
+ const vq = this.voiceQueues[data.messageId];
822
+ if (vq) {
823
+ if (vq.audio) { try { vq.audio.pause(); } catch(_) {} }
824
+ delete this.voiceQueues[data.messageId];
825
+ }
809
826
  // Drop the empty placeholder bubble.
810
827
  this.currentMessages = this.currentMessages.filter((m) => m.id !== data.messageId);
811
828
  const article = document.querySelector(`[data-message-id="${data.messageId}"]`);
812
829
  if (article) article.remove();
813
830
  });
814
831
 
832
+ this.sse.addEventListener("voice-chunk", (e) => {
833
+ const data = JSON.parse(e.data);
834
+ this.enqueueVoiceChunk(roomId, data);
835
+ });
836
+
837
+ this.sse.addEventListener("voice-final", (e) => {
838
+ const data = JSON.parse(e.data);
839
+ const q = this.voiceQueues[data.messageId] || (this.voiceQueues[data.messageId] = { chunks: [], final: false, scheduled: false, roomId, messageId: data.messageId });
840
+ q.final = true;
841
+ q.messageId = data.messageId;
842
+ q.roomId = roomId;
843
+ this.drainVoiceQueue(roomId, data.messageId);
844
+ });
845
+
815
846
  // Full body+meta replacement · used by tool-use rows whose
816
847
  // status flips running → done|failed once the side-effect
817
848
  // (URL fetch) completes. We re-render the affected message in
@@ -854,6 +885,11 @@
854
885
  this.currentRoom.status = "paused";
855
886
  this.currentRoom.pausedAt = ts;
856
887
  }
888
+ // Hard pause: stop audio immediately (speaker was aborted mid-stream).
889
+ // Soft pause: let current audio finish naturally (speaker completed their turn).
890
+ if (payload.mode === "hard") {
891
+ this.stopVoicePlayback();
892
+ }
857
893
  document.documentElement.classList.remove("pause-pending");
858
894
  document.documentElement.setAttribute("data-status", "paused");
859
895
  this.renderHeader();
@@ -955,13 +991,9 @@
955
991
  language: payload.language === "zh" ? "zh" : "en",
956
992
  pipelineStartedAt: Date.now(),
957
993
  createdAt: Date.now(),
958
- // Stage checklist · seeded according to the brief mode (see
959
- // briefMode resolution above). brief-stage events flip
960
- // them active → done as the pipeline progresses.
961
- // startedAt is captured when each stage first becomes
962
- // active so the UI can display elapsed time alongside the
963
- // ETA range.
964
994
  stages: seededStages,
995
+ llmLogs: [],
996
+ llmLogOpen: false,
965
997
  };
966
998
  this.currentBrief = newBrief;
967
999
  // Insert the in-progress brief into currentBriefs so the tab
@@ -1067,6 +1099,51 @@
1067
1099
  this.renderBrief();
1068
1100
  }
1069
1101
  }
1102
+ } else if (kind === "brief-llm-start") {
1103
+ this.markBriefEvent();
1104
+ const target = this._briefById(payload.briefId);
1105
+ if (target && payload.log) {
1106
+ const logs = target.llmLogs || (target.llmLogs = []);
1107
+ const idx = logs.findIndex((l) => l.id === payload.log.id);
1108
+ const log = { ...payload.log, text: payload.log.text || "" };
1109
+ if (idx >= 0) logs[idx] = log;
1110
+ else logs.push(log);
1111
+ if (this.currentBrief && this.currentBrief.id === target.id) this.renderBrief();
1112
+ }
1113
+ } else if (kind === "brief-llm-token") {
1114
+ this.markBriefEvent();
1115
+ const target = this._briefById(payload.briefId);
1116
+ if (target && payload.logId) {
1117
+ const logs = target.llmLogs || (target.llmLogs = []);
1118
+ const log = logs.find((l) => l.id === payload.logId);
1119
+ if (log) {
1120
+ const limit = 6000;
1121
+ if ((log.text || "").length < limit) {
1122
+ log.text = ((log.text || "") + (payload.delta || "")).slice(0, limit);
1123
+ }
1124
+ if (this.currentBrief && this.currentBrief.id === target.id && target.llmLogOpen) {
1125
+ const now = Date.now();
1126
+ if (!this._briefLlmLastRender || (now - this._briefLlmLastRender) > 500) {
1127
+ this._briefLlmLastRender = now;
1128
+ this.renderBrief();
1129
+ }
1130
+ }
1131
+ }
1132
+ }
1133
+ } else if (kind === "brief-llm-end") {
1134
+ this.markBriefEvent();
1135
+ const target = this._briefById(payload.briefId);
1136
+ if (target && payload.logId) {
1137
+ const logs = target.llmLogs || (target.llmLogs = []);
1138
+ const log = logs.find((l) => l.id === payload.logId);
1139
+ if (log) {
1140
+ log.status = payload.status || log.status;
1141
+ log.finishedAt = typeof payload.finishedAt === "number" ? payload.finishedAt : Date.now();
1142
+ if (typeof payload.totalTokens === "number") log.totalTokens = payload.totalTokens;
1143
+ if (typeof payload.error === "string") log.error = payload.error;
1144
+ if (this.currentBrief && this.currentBrief.id === target.id) this.renderBrief();
1145
+ }
1146
+ }
1070
1147
  } else if (kind === "brief-token") {
1071
1148
  this.markBriefEvent();
1072
1149
  // Append tokens to the brief identified by payload.briefId,
@@ -1198,6 +1275,7 @@
1198
1275
  if (ch.mode) this.currentRoom.mode = ch.mode.to;
1199
1276
  if (ch.intensity) this.currentRoom.intensity = ch.intensity.to;
1200
1277
  if (ch.briefStyle) this.currentRoom.briefStyle = ch.briefStyle.to;
1278
+ if (ch.deliveryMode) this.currentRoom.deliveryMode = ch.deliveryMode.to;
1201
1279
  }
1202
1280
  this.renderHeader();
1203
1281
  syncSidebar({
@@ -1361,7 +1439,7 @@
1361
1439
  },
1362
1440
 
1363
1441
  // ── Actions ───────────────────────────────────────────────
1364
- async createRoom({ subject, agentIds, mode, intensity, briefStyle, autoPick }) {
1442
+ async createRoom({ subject, agentIds, mode, intensity, briefStyle, autoPick, deliveryMode }) {
1365
1443
  const r = await fetch("/api/rooms", {
1366
1444
  method: "POST",
1367
1445
  headers: { "content-type": "application/json" },
@@ -1371,6 +1449,7 @@
1371
1449
  mode: mode || "constructive",
1372
1450
  intensity: intensity || "sharp",
1373
1451
  briefStyle: briefStyle || "auto",
1452
+ deliveryMode: deliveryMode === "voice" ? "voice" : "text",
1374
1453
  ...(autoPick ? { autoPick: true } : {}),
1375
1454
  }),
1376
1455
  });
@@ -1704,27 +1783,55 @@
1704
1783
  return MODEL_PROVIDERS.some((p) => keys[p] && keys[p].configured);
1705
1784
  },
1706
1785
 
1786
+ /** Pure cache read · true when at least one voice provider
1787
+ * (MiniMax / ElevenLabs) is configured. The composer's voice
1788
+ * toggle calls this before flipping ON; if false, we redirect
1789
+ * the user to the keys panel rather than silently enabling a
1790
+ * feature that has no provider behind it. */
1791
+ hasAnyVoiceKey() {
1792
+ const VOICE_PROVIDERS = ["minimax", "elevenlabs"];
1793
+ const keys = this.keys || {};
1794
+ return VOICE_PROVIDERS.some((p) => keys[p] && keys[p].configured);
1795
+ },
1796
+
1797
+ /** Secondary hint under a failed brief · the legacy copy always blamed
1798
+ * missing keys even when stage-2 scaffolding failed after successful
1799
+ * LLM calls. Route hints by substring of the server's `brief-error`
1800
+ * message (`NoKeyError` vs scaffold failures vs everything else). */
1801
+ _briefSecondaryHintHtml(errorText) {
1802
+ const s = String(errorText || "").toLowerCase();
1803
+ if (/no key configured|no openrouter fallback|add a key in preference/i.test(s)) {
1804
+ return this._t("brief_err_hint_failed_html");
1805
+ }
1806
+ if (
1807
+ /couldn't structure|report writer couldn't|json scaffold|repeat(ed)? attempts|retries failed|parse scaffold|\[brief\.stage2\]|stage 2/i.test(s)
1808
+ ) {
1809
+ return this._t("brief_err_hint_scaffold_html");
1810
+ }
1811
+ return this._t("brief_err_hint_generic_html");
1812
+ },
1813
+
1707
1814
  /** Modal that fires when an AI action is attempted but no model
1708
1815
  * provider key is configured. Two CTAs · open settings (preferred)
1709
1816
  * or dismiss. Same chrome family as openSendChoiceModal so the
1710
1817
  * visual treatment stays consistent. */
1711
1818
  openNoKeyModal() {
1712
1819
  this.closeNoKeyModal();
1713
- // System UI · always English. No-key modal is app chrome.
1714
1820
  const t = {
1715
- title: "Configure a model API key",
1716
- 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.",
1717
- primary: "[ Open settings ▸ ]",
1718
- dismiss: "[ Dismiss ]",
1719
- classification: "● ai · no model key",
1720
- tag: "▸ Configure API key",
1721
- };
1821
+ title: this._t("nk_title"),
1822
+ deck: this._t("nk_deck"),
1823
+ primary: this._t("nk_primary"),
1824
+ dismiss: this._t("nk_dismiss"),
1825
+ classification: this._t("nk_classification"),
1826
+ tag: this._t("nk_tag"),
1827
+ primaryDeck: this._t("nk_primary_deck"),
1828
+ gate: this._t("nk_frontend_gate"), };
1722
1829
  const html = `
1723
1830
  <div id="no-key-overlay" class="pc-overlay" data-no-key-overlay>
1724
1831
  <div class="pc-modal">
1725
1832
  <div class="pc-classification">
1726
- <span><span class="dot">●</span> ${this.escape(t.classification.replace(/^●\s*/, ""))}</span>
1727
- <span class="right">// frontend gate</span>
1833
+ <span><span class="dot">●</span> ${this.escape(t.classification)}</span>
1834
+ <span class="right">${this.escape(t.gate)}</span>
1728
1835
  </div>
1729
1836
  <div class="pc-head">
1730
1837
  <div class="pc-tag">${this.escape(t.tag)}</div>
@@ -1734,8 +1841,7 @@
1734
1841
  <div class="pc-body">
1735
1842
  <button type="button" class="pc-choice primary" data-no-key-open-settings>
1736
1843
  <div class="pc-choice-mark">${this.escape(t.primary)}</div>
1737
- <div class="pc-choice-deck">Jump to Preferences → API Key. Paste any one provider's key to unlock the room.</div>
1738
- </button>
1844
+ <div class="pc-choice-deck">${this.escape(t.primaryDeck)}</div> </button>
1739
1845
  <button type="button" class="pc-choice ghost" data-no-key-dismiss>
1740
1846
  <div class="pc-choice-mark">${this.escape(t.dismiss)}</div>
1741
1847
  </button>
@@ -1760,31 +1866,32 @@
1760
1866
  const speaker = this.currentQueue[0]
1761
1867
  ? this.agentsById[this.currentQueue[0].agentId]
1762
1868
  : null;
1763
- const speakerLabel = speaker ? this.escape(speaker.name) : "a director";
1869
+ const speakerName = speaker ? speaker.name : this._t("sc_speaker_fallback");
1870
+ const speakerLabel = this.escape(speakerName);
1764
1871
  const html = `
1765
1872
  <div id="send-choice-overlay" class="pc-overlay">
1766
1873
  <div class="pc-modal">
1767
1874
  <div class="pc-classification">
1768
- <span><span class="dot">●</span> send · choose</span>
1769
- <span class="right">// ${speakerLabel} is speaking</span>
1875
+ <span><span class="dot">●</span> ${this.escape(this._t("sc_send_class"))}</span>
1876
+ <span class="right">${this.escape(this._t("sc_send_right", { name: speakerName }))}</span>
1770
1877
  </div>
1771
1878
  <div class="pc-head">
1772
- <div class="pc-tag">▸ Send while mid-turn</div>
1773
- <h2 class="pc-title">${speakerLabel} is in the middle of a turn.</h2>
1774
- <p class="pc-deck">Cut in now, or queue your message until they finish?</p>
1879
+ <div class="pc-tag">${this.escape(this._t("sc_send_tag"))}</div>
1880
+ <h2 class="pc-title">${this.escape(this._t("sc_send_title", { name: speakerName }))}</h2>
1881
+ <p class="pc-deck">${this.escape(this._t("sc_send_deck"))}</p>
1775
1882
  </div>
1776
1883
  <div class="pc-body">
1777
1884
  <button type="button" class="pc-choice" data-send-choice="interrupt">
1778
- <div class="pc-choice-mark">▸ Interrupt and send now</div>
1779
- <div class="pc-choice-deck">Drops into the room immediately. ${speakerLabel} keeps going on top of your message.</div>
1885
+ <div class="pc-choice-mark">${this.escape(this._t("sc_interrupt_mark"))}</div>
1886
+ <div class="pc-choice-deck">${this.escape(this._t("sc_interrupt_deck", { name: speakerName }))}</div>
1780
1887
  </button>
1781
1888
  <button type="button" class="pc-choice primary" data-send-choice="queue">
1782
- <div class="pc-choice-mark">→ Wait until ${speakerLabel} finishes</div>
1783
- <div class="pc-choice-deck">Your message lines up after the current turn and posts as soon as it ends.</div>
1889
+ <div class="pc-choice-mark">${this.escape(this._t("sc_queue_mark", { name: speakerName }))}</div>
1890
+ <div class="pc-choice-deck">${this.escape(this._t("sc_queue_deck"))}</div>
1784
1891
  </button>
1785
1892
  <button type="button" class="pc-choice ghost" data-send-choice="cancel">
1786
- <div class="pc-choice-mark">✕ Cancel</div>
1787
- <div class="pc-choice-deck">Keep typing and decide later.</div>
1893
+ <div class="pc-choice-mark">${this.escape(this._t("sc_cancel_mark"))}</div>
1894
+ <div class="pc-choice-deck">${this.escape(this._t("sc_cancel_deck"))}</div>
1788
1895
  </button>
1789
1896
  </div>
1790
1897
  </div>
@@ -1898,15 +2005,21 @@
1898
2005
  const room = this.currentRoom || {};
1899
2006
  const turns = (this.currentMessages || []).filter((m) => m.body && m.body.trim()).length;
1900
2007
  const status = room.status || "live";
1901
- const titleTxt = isGen ? "Generate the report" : "File the report?";
1902
- const classifyTxt = isGen ? "room · generate report" : "room · adjourn";
1903
- const classifyRight = isGen ? "// post-hoc" : "// terminal";
1904
- const confirmTxt = isGen ? "[ Generate ]" : "[ Adjourn & file ]";
2008
+ const titleTxt = isGen ? this._t("adj_title_generate") : this._t("adj_title_file");
2009
+ const classifyTxt = isGen ? this._t("adj_classify_generate") : this._t("adj_classify_adjourn");
2010
+ const classifyRight = isGen ? this._t("adj_classify_right_posthoc") : this._t("adj_classify_right_terminal");
2011
+ const confirmTxt = isGen ? this._t("adj_confirm_generate") : this._t("adj_confirm_file");
1905
2012
  const subjectTxt = room.subject || room.name || "—";
1906
2013
  const memberCount = (this.currentMembers || []).length;
1907
- // Default the mode picker to whatever the user picked last. Bento
1908
- // is opt-in · 'research-note' is the safe initial default for new
1909
- // users / private-mode browsers (where localStorage is empty).
2014
+ const statusLabel = this._t(
2015
+ status === "paused" ? "adj_meta_status_paused"
2016
+ : status === "adjourned" ? "adj_meta_status_adjourned"
2017
+ : "adj_meta_status_live",
2018
+ );
2019
+ const roomKicker = this._t("adj_meta_room_kicker");
2020
+ const metaSep = this._t("adj_meta_sep");
2021
+ const metaTurns = this._t("adj_meta_turns", { n: turns });
2022
+ const noteTxt = isGen ? this._t("adj_note_generate") : this._t("adj_note_adjourn");
1910
2023
  const defaultMode = this.lastBriefMode();
1911
2024
  const html = `
1912
2025
  <div class="adjourn-overlay" id="adjourn-overlay" role="dialog" aria-modal="true" data-adjourn-mode="${this.escape(mode)}">
@@ -1920,34 +2033,32 @@
1920
2033
 
1921
2034
  <header class="adjourn-head">
1922
2035
  <div>
1923
- <div class="meta">// room #<span>${this.escape(String(room.number ?? "—"))}</span> · <span class="${status === "live" ? "live" : "status"}">${this.escape(status)}</span> · <span>${turns}</span> turns</div>
2036
+ <div class="meta">${this.escape(roomKicker)}<span>${this.escape(String(room.number ?? "—"))}</span>${this.escape(metaSep)}<span class="${status === "live" ? "live" : "status"}">${this.escape(statusLabel)}</span>${this.escape(metaSep)}${this.escape(metaTurns)}</div>
1924
2037
  <div class="title">${this.escape(titleTxt)}</div>
1925
2038
  </div>
1926
- <button type="button" class="adjourn-close" data-adjourn-close aria-label="Close">✕</button>
2039
+ <button type="button" class="adjourn-close" data-adjourn-close aria-label="${this.escape(this._t("adj_close_aria"))}">✕</button>
1927
2040
  </header>
1928
2041
 
1929
2042
  <div class="adjourn-body">
1930
2043
  <div class="adjourn-summary">
1931
- <div class="adjourn-summary-row adjourn-summary-row-subject">
1932
- <span class="adjourn-summary-key">// subject</span>
2044
+ <div class="adjourn-summary-row adjourn-summary-row-subject">
2045
+ <span class="adjourn-summary-key">${this.escape(this._t("adj_key_subject"))}</span>
1933
2046
  <div class="adjourn-summary-val adjourn-subject-wrap">
1934
2047
  <span class="adjourn-subject-text is-clamped" data-adjourn-subject>${this.escape(subjectTxt)}</span>
1935
2048
  <button type="button" class="adjourn-subject-toggle" data-adjourn-subject-toggle hidden>Show more</button>
1936
2049
  </div>
1937
2050
  </div>
1938
2051
  <div class="adjourn-summary-row">
1939
- <span class="adjourn-summary-key">// authors</span>
1940
- <span class="adjourn-summary-val">${memberCount} agents</span>
2052
+ <span class="adjourn-summary-key">${this.escape(this._t("adj_key_authors"))}</span>
2053
+ <span class="adjourn-summary-val">${this.escape(this._t("adj_agents_count", { n: memberCount }))}</span>
1941
2054
  </div>
1942
2055
  <div class="adjourn-summary-row">
1943
- <span class="adjourn-summary-key">// turns</span>
2056
+ <span class="adjourn-summary-key">${this.escape(this._t("adj_key_turns"))}</span>
1944
2057
  <span class="adjourn-summary-val">${turns}</span>
1945
2058
  </div>
1946
2059
  </div>
1947
2060
  <p class="adjourn-summary-note">
1948
- The chair compiles a report from the room's transcript. Pick the
1949
- format below — the room is marked adjourned and the report is
1950
- filed in the chat once it's ready.
2061
+ ${this.escape(noteTxt)}
1951
2062
  </p>
1952
2063
 
1953
2064
  ${this.renderBriefModePicker(defaultMode)}
@@ -1957,10 +2068,10 @@
1957
2068
  ${isGen ? `<span class="adjourn-skip-spacer"></span>` : `
1958
2069
  <button type="button" class="adjourn-skip-btn" data-adjourn-skip>
1959
2070
  <span class="adjourn-skip-mark">⊘</span>
1960
- <span>End without report</span>
2071
+ <span>${this.escape(this._t("adj_skip"))}</span>
1961
2072
  </button>`}
1962
2073
  <div class="adjourn-foot-actions">
1963
- <button type="button" class="adjourn-cancel" data-adjourn-close>[ Cancel ]</button>
2074
+ <button type="button" class="adjourn-cancel" data-adjourn-close>${this.escape(this._t("adj_cancel"))}</button>
1964
2075
  <button type="button" class="adjourn-confirm" data-adjourn-confirm>${this.escape(confirmTxt)}</button>
1965
2076
  </div>
1966
2077
  </footer>
@@ -2230,31 +2341,50 @@
2230
2341
  if (this.currentRoom.status !== "adjourned") return;
2231
2342
  this.closeFollowUpOverlay();
2232
2343
 
2233
- // System UI · always English. Follow-up overlay chrome.
2234
- const t = {
2235
- classify: "follow-up · continuation room",
2236
- classifyRight: "// continuing",
2237
- title: "Convene a follow-up",
2238
- metaPrefix: "// following up",
2239
- placeholder: "What's the next question to chase, given what the prior session settled?",
2240
- contextNote: "The prior subject, the filed brief (room's settled judgement), and each director's load-bearing observations are bundled as context for this follow-up — the new cast picks up where the prior session left off rather than starting from scratch.",
2241
- castLabel: "Directors",
2242
- castHint: "2–4 recommended",
2243
- castSame: "Same cast as last session",
2244
- pickerLabel: "Pick directors",
2245
- autoLabel: "directors",
2246
- autoVal: "auto-pick",
2247
- countersDirectors: (n) => `${n} director${n === 1 ? "" : "s"}`,
2248
- toneLabel: "Tone",
2249
- intensityLabel: "Intensity",
2250
- cancel: "[ Cancel ]",
2251
- confirm: "[ Convene ]",
2252
- confirmBusy: "[ Convening… ]",
2253
- adjournedAtPrefix: "adjourned",
2254
- briefsCount: (n) => `${n} ${n === 1 ? "brief" : "briefs"} filed`,
2255
- noBrief: "no brief filed",
2256
- };
2257
-
2344
+ const lang = this.composerLanguage();
2345
+ const t = lang === "zh"
2346
+ ? {
2347
+ classify: "follow-up · 跟进会议",
2348
+ classifyRight: "// continuing",
2349
+ title: "开一场跟进会议",
2350
+ metaPrefix: "// following up",
2351
+ placeholder: "在上一场判断之上,下一个要追问的问题是什么?",
2352
+ contextNote: "上一场的议题、最终判断(brief)和每位 director 的关键观察会作为这场 follow-up 房间的上下文交给新一组 director —— 他们可以直接在已成型的判断上推进,不会从零开始。",
2353
+ castLabel: "Directors",
2354
+ castHint: "建议 2-4 ",
2355
+ castSame: "沿用上一场的 cast",
2356
+ pickerLabel: "选择董事",
2357
+ autoLabel: "directors",
2358
+ autoVal: "自动挑选",
2359
+ countersDirectors: (n) => `${n} 位董事`,
2360
+ cancel: "[ Cancel ]",
2361
+ confirm: "[ Convene ]",
2362
+ confirmBusy: "[ Convening… ]",
2363
+ adjournedAtPrefix: "adjourned",
2364
+ briefsCount: (n) => `${n} ${n === 1 ? "brief" : "briefs"} filed`,
2365
+ noBrief: "no brief filed",
2366
+ }
2367
+ : {
2368
+ classify: "follow-up · continuation room",
2369
+ classifyRight: "// continuing",
2370
+ title: "Convene a follow-up",
2371
+ metaPrefix: "// following up",
2372
+ placeholder: "What's the next question to chase, given what the prior session settled?",
2373
+ 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.",
2374
+ castLabel: "Directors",
2375
+ castHint: "2–4 recommended",
2376
+ castSame: "Same cast as last session",
2377
+ pickerLabel: "Pick directors",
2378
+ autoLabel: "directors",
2379
+ autoVal: "auto-pick",
2380
+ countersDirectors: (n) => `${n} director${n === 1 ? "" : "s"}`,
2381
+ cancel: "[ Cancel ]",
2382
+ confirm: "[ Convene → ]",
2383
+ confirmBusy: "[ Convening… ]",
2384
+ adjournedAtPrefix: "adjourned",
2385
+ briefsCount: (n) => `${n} ${n === 1 ? "brief" : "briefs"} filed`,
2386
+ noBrief: "no brief filed",
2387
+ };
2258
2388
  const room = this.currentRoom;
2259
2389
  const briefCount = Array.isArray(this.currentBriefs) ? this.currentBriefs.length : 0;
2260
2390
  const briefLine = briefCount > 0 ? t.briefsCount(briefCount) : t.noBrief;
@@ -2345,13 +2475,13 @@
2345
2475
  </span>
2346
2476
  </button>
2347
2477
  <span class="followup-cast-row-sep" aria-hidden="true"></span>
2348
- <button type="button" class="cmp-dd" data-cmp-dropdown="tone" title="${this.escape(t.toneLabel)}">
2349
- <span class="cmp-dd-label">tone</span>
2478
+ <button type="button" class="cmp-dd" data-cmp-dropdown="tone" title="${this.escape(this._t("cmp_tone_label"))}">
2479
+ <span class="cmp-dd-label">${this.escape(this._t("cmp_tone_label"))}</span>
2350
2480
  <span class="cmp-dd-value" data-cmp-dd-value="tone">${this.escape(inheritedMode)}</span>
2351
2481
  <span class="cmp-dd-chevron">▾</span>
2352
2482
  </button>
2353
- <button type="button" class="cmp-dd" data-cmp-dropdown="intensity" title="${this.escape(t.intensityLabel)}">
2354
- <span class="cmp-dd-label">intensity</span>
2483
+ <button type="button" class="cmp-dd" data-cmp-dropdown="intensity" title="${this.escape(this._t("cmp_intensity_label"))}">
2484
+ <span class="cmp-dd-label">${this.escape(this._t("cmp_intensity_label"))}</span>
2355
2485
  <span class="cmp-dd-value" data-cmp-dd-value="intensity">${this.escape(inheritedIntensity)}</span>
2356
2486
  <span class="cmp-dd-chevron">▾</span>
2357
2487
  </button>
@@ -2570,7 +2700,7 @@
2570
2700
  <span class="composer-pick-title">${this.escape(t.title)}</span>
2571
2701
  <span class="composer-pick-hint">${this.escape(t.hint)}</span>
2572
2702
  </div>
2573
- <div class="composer-pick-list">${rows || `<div class="composer-pick-empty">no directors</div>`}</div>
2703
+ <div class="composer-pick-list">${rows || `<div class="composer-pick-empty">${this.escape(this._t("picker_no_directors"))}</div>`}</div>
2574
2704
  `;
2575
2705
  document.body.appendChild(pop);
2576
2706
  const r = anchorBtn.getBoundingClientRect();
@@ -2884,6 +3014,20 @@
2884
3014
  target.progress = incoming.progress || target.progress;
2885
3015
  target.etaSec = (incoming.etaSec && typeof incoming.etaSec.lo === "number") ? incoming.etaSec : target.etaSec;
2886
3016
  }
3017
+ if (Array.isArray(state.llmLogs)) {
3018
+ brief.llmLogs = state.llmLogs.map((l) => ({
3019
+ id: String(l.id || ""),
3020
+ stage: String(l.stage || ""),
3021
+ label: String(l.label || ""),
3022
+ modelV: String(l.modelV || ""),
3023
+ status: l.status === "done" || l.status === "failed" ? l.status : "running",
3024
+ startedAt: typeof l.startedAt === "number" ? l.startedAt : Date.now(),
3025
+ finishedAt: typeof l.finishedAt === "number" ? l.finishedAt : null,
3026
+ text: String(l.text || ""),
3027
+ totalTokens: typeof l.totalTokens === "number" ? l.totalTokens : null,
3028
+ error: typeof l.error === "string" ? l.error : null,
3029
+ })).filter((l) => l.id);
3030
+ }
2887
3031
  },
2888
3032
 
2889
3033
  /** Delete a single brief from this room's history. Asks for
@@ -2956,12 +3100,25 @@
2956
3100
  }
2957
3101
  } catch { /* swallow */ }
2958
3102
  }
3103
+ // Preserve the failed brief's mode on retry · without this
3104
+ // the POST defaulted back to `research-note` regardless of
3105
+ // whether the user originally picked Magazine / Newspaper /
3106
+ // Slides. Same goes for the supplement string (regenerate-
3107
+ // with-perspective flow). Anything else (style etc) is
3108
+ // re-derived server-side from the room's stored config.
3109
+ const retryBody = {};
3110
+ if (failed && this.isStructuredBriefMode(failed.mode)) {
3111
+ retryBody.mode = failed.mode;
3112
+ }
3113
+ if (failed && typeof failed.supplement === "string" && failed.supplement.trim()) {
3114
+ retryBody.supplement = failed.supplement;
3115
+ }
2959
3116
  const r = await fetch(
2960
3117
  "/api/rooms/" + encodeURIComponent(this.currentRoomId) + "/brief",
2961
3118
  {
2962
3119
  method: "POST",
2963
3120
  headers: { "content-type": "application/json" },
2964
- body: "{}",
3121
+ body: JSON.stringify(retryBody),
2965
3122
  },
2966
3123
  );
2967
3124
  if (!r.ok) {
@@ -3004,8 +3161,8 @@
3004
3161
  const briefMode = this.isStructuredBriefMode(v) ? v : "research-note";
3005
3162
  this.saveLastBriefMode(briefMode);
3006
3163
  const btn = overlay.querySelector("[data-adjourn-confirm]");
3007
- const origLabel = isGen ? "[ Generate ]" : "[ Adjourn & file ]";
3008
- const busyLabel = isGen ? "[ Generating… ]" : "[ Adjourning… ]";
3164
+ const origLabel = isGen ? this._t("adj_confirm_generate") : this._t("adj_confirm_file");
3165
+ const busyLabel = isGen ? this._t("adj_busy_generate") : this._t("adj_busy_adjourn");
3009
3166
  if (btn) { btn.disabled = true; btn.textContent = busyLabel; }
3010
3167
  try {
3011
3168
  if (isGen) {
@@ -3018,7 +3175,7 @@
3018
3175
  this.closeAdjournOverlay();
3019
3176
  } catch (e) {
3020
3177
  if (btn) { btn.disabled = false; btn.textContent = origLabel; }
3021
- alert((isGen ? "Generate failed: " : "Adjourn failed: ") + (e && e.message ? e.message : e));
3178
+ alert((isGen ? this._t("adj_err_generate") : this._t("adj_err_adjourn")) + (e && e.message ? e.message : e));
3022
3179
  }
3023
3180
  },
3024
3181
 
@@ -3046,6 +3203,27 @@
3046
3203
  }
3047
3204
  },
3048
3205
 
3206
+ async toggleDeliveryMode() {
3207
+ if (!this.currentRoomId || !this.currentRoom) return;
3208
+ const next = this.currentRoom.deliveryMode === "voice" ? "text" : "voice";
3209
+ // Unlock audio if switching to voice
3210
+ if (next === "voice") this.unlockAudioPlayback();
3211
+ try {
3212
+ const r = await fetch(
3213
+ "/api/rooms/" + encodeURIComponent(this.currentRoomId),
3214
+ {
3215
+ method: "PATCH",
3216
+ headers: { "content-type": "application/json" },
3217
+ body: JSON.stringify({ deliveryMode: next }),
3218
+ },
3219
+ );
3220
+ if (r.ok) {
3221
+ this.currentRoom.deliveryMode = next;
3222
+ this.renderHeader();
3223
+ }
3224
+ } catch (_) { /* offline */ }
3225
+ },
3226
+
3049
3227
  async pauseRoom(mode) {
3050
3228
  if (!this.currentRoomId) return;
3051
3229
  // Soft pause: flip the input bar to a "pausing after current turn"
@@ -3110,31 +3288,32 @@
3110
3288
  const speaker = this.currentQueue[0]
3111
3289
  ? this.agentsById[this.currentQueue[0].agentId]
3112
3290
  : null;
3113
- const speakerLabel = speaker ? this.escape(speaker.name) : "a director";
3291
+ const speakerName = speaker ? speaker.name : this._t("sc_speaker_fallback");
3292
+ const speakerLabel = this.escape(speakerName);
3114
3293
  const html = `
3115
3294
  <div id="pause-choice-overlay" class="pc-overlay">
3116
3295
  <div class="pc-modal">
3117
3296
  <div class="pc-classification">
3118
- <span><span class="dot">●</span> pause · choose</span>
3119
- <span class="right">// the room is mid-turn</span>
3297
+ <span><span class="dot">●</span> ${this.escape(this._t("pause_class"))}</span>
3298
+ <span class="right">${this.escape(this._t("pause_right"))}</span>
3120
3299
  </div>
3121
3300
  <div class="pc-head">
3122
- <div class="pc-tag">▸ Pause discussion</div>
3123
- <h2 class="pc-title">${speakerLabel} is speaking right now.</h2>
3124
- <p class="pc-deck">How would you like to pause?</p>
3301
+ <div class="pc-tag">${this.escape(this._t("pause_tag"))}</div>
3302
+ <h2 class="pc-title">${this.escape(this._t("pause_title", { name: speakerName }))}</h2>
3303
+ <p class="pc-deck">${this.escape(this._t("pause_deck"))}</p>
3125
3304
  </div>
3126
3305
  <div class="pc-body">
3127
3306
  <button type="button" class="pc-choice danger" data-pause-choice="hard">
3128
- <div class="pc-choice-mark">▍ Stop immediately</div>
3129
- <div class="pc-choice-deck">Cut their reply mid-sentence. The partial response is dropped.</div>
3307
+ <div class="pc-choice-mark">${this.escape(this._t("pause_hard_mark"))}</div>
3308
+ <div class="pc-choice-deck">${this.escape(this._t("pause_hard_deck"))}</div>
3130
3309
  </button>
3131
3310
  <button type="button" class="pc-choice primary" data-pause-choice="soft">
3132
- <div class="pc-choice-mark">⌛ After they finish</div>
3133
- <div class="pc-choice-deck">Let ${speakerLabel} complete this turn, then pause the queue.</div>
3311
+ <div class="pc-choice-mark">${this.escape(this._t("pause_soft_mark"))}</div>
3312
+ <div class="pc-choice-deck">${this.escape(this._t("pause_soft_deck", { name: speakerName }))}</div>
3134
3313
  </button>
3135
3314
  <button type="button" class="pc-choice ghost" data-pause-choice="cancel">
3136
- <div class="pc-choice-mark">↩ Cancel</div>
3137
- <div class="pc-choice-deck">Keep the discussion running.</div>
3315
+ <div class="pc-choice-mark">${this.escape(this._t("pause_cancel_mark"))}</div>
3316
+ <div class="pc-choice-deck">${this.escape(this._t("pause_cancel_deck"))}</div>
3138
3317
  </button>
3139
3318
  </div>
3140
3319
  </div>
@@ -3773,9 +3952,10 @@
3773
3952
  const adj = this.rooms.filter((r) => r.status === "adjourned");
3774
3953
 
3775
3954
  const renderRow = (r) => {
3955
+ const pausedLbl = this._t("sidebar_paused");
3776
3956
  const status =
3777
3957
  r.status === "paused"
3778
- ? '<span class="row-status paused">❚❚ paused</span>'
3958
+ ? `<span class="row-status paused">❚❚ ${this.escape(pausedLbl)}</span>`
3779
3959
  : "";
3780
3960
  const time =
3781
3961
  r.status === "paused"
@@ -3799,20 +3979,19 @@
3799
3979
  <div class="row-content">
3800
3980
  <div class="row-top-line">
3801
3981
  <span class="row-title">${this.escape(fullTitle)}</span>
3802
- <span class="row-time">${this.escape(time)}</span>
3803
3982
  </div>
3804
3983
  <div class="row-subtitle">${status}${this.escape(r.subject || "")}</div>
3805
3984
  </div>
3806
3985
  </a>
3807
- <button type="button" class="row-delete" data-room-delete title="Delete room">✕</button>
3986
+ <button type="button" class="row-delete" data-room-delete title="${this.escape(this._t("sidebar_delete_room"))}">✕</button>
3808
3987
  </div>
3809
3988
  `;
3810
3989
  };
3811
3990
 
3812
- list.innerHTML = `
3991
+ const html = `
3813
3992
  ${live.length > 0 ? `
3814
3993
  <div class="section-header live">
3815
- <span>Live</span>
3994
+ <span>${this.escape(this._t("sidebar_section_live"))}</span>
3816
3995
  <span class="line"></span>
3817
3996
  <span class="badge">${live.length}</span>
3818
3997
  </div>
@@ -3821,7 +4000,7 @@
3821
4000
 
3822
4001
  ${paused.length > 0 ? `
3823
4002
  <div class="section-header paused">
3824
- <span>Paused</span>
4003
+ <span>${this.escape(this._t("sidebar_section_paused"))}</span>
3825
4004
  <span class="line"></span>
3826
4005
  <span class="badge">${paused.length}</span>
3827
4006
  </div>
@@ -3829,7 +4008,7 @@
3829
4008
  ` : ""}
3830
4009
 
3831
4010
  <div class="section-header adjourned">
3832
- <span>Adjourned</span>
4011
+ <span>${this.escape(this._t("sidebar_section_adjourned"))}</span>
3833
4012
  <span class="line"></span>
3834
4013
  <span class="badge" data-adjourned-count>${adj.length}</span>
3835
4014
  </div>
@@ -3838,11 +4017,24 @@
3838
4017
  </div>
3839
4018
  <div class="adjourned-empty" data-adjourned-empty ${adj.length > 0 ? "hidden" : ""}>
3840
4019
  <div class="adjourned-empty-mark">○</div>
3841
- <div class="adjourned-empty-title">no adjourned rooms</div>
3842
- <div class="adjourned-empty-deck">conclude a discussion to file it here.</div>
4020
+ <div class="adjourned-empty-title">${this.escape(this._t("sidebar_no_adjourned_title"))}</div>
4021
+ <div class="adjourned-empty-deck">${this.escape(this._t("sidebar_no_adjourned_deck"))}</div>
3843
4022
  </div>
3844
4023
  `;
3845
4024
 
4025
+ // SSE handlers (room.updated / message.appended / brief.appended)
4026
+ // call renderSidebarRooms unconditionally; many of those events
4027
+ // don't actually change anything visible in the sidebar. A no-op
4028
+ // innerHTML rewrite still tears down + recreates every anchor,
4029
+ // which kills any in-flight click whose mousedown/mouseup
4030
+ // straddle the wipe — symptom: "I clicked a room and nothing
4031
+ // happened, had to refresh." Skip the write when the rendered
4032
+ // string is identical to last time.
4033
+ if (this._sidebarRoomsHtml !== html) {
4034
+ this._sidebarRoomsHtml = html;
4035
+ list.innerHTML = html;
4036
+ }
4037
+
3846
4038
  this.markActiveRoom(this.currentRoomId);
3847
4039
  },
3848
4040
 
@@ -3968,7 +4160,7 @@
3968
4160
  const status = "active"; // every persisted director is active in v1
3969
4161
  const time = this.relTime(a.createdAt) || "—";
3970
4162
  const pinBtn = `
3971
- <button type="button" class="pin-toggle" title="${a.isPinned ? "Unpin" : "Pin"}" data-pin-toggle>${PIN_GLYPH}</button>
4163
+ <button type="button" class="pin-toggle" title="${this.escape(a.isPinned ? this._t("sidebar_unpin") : this._t("sidebar_pin"))}" data-pin-toggle>${PIN_GLYPH}</button>
3972
4164
  `;
3973
4165
  // Delete moved off the sidebar row · it now lives inside the
3974
4166
  // agent profile's ⋯ overflow menu where it's protected by the
@@ -3981,13 +4173,12 @@
3981
4173
  <div class="agent-row-content">
3982
4174
  <div class="agent-row-top-line">
3983
4175
  <span class="agent-row-title">${this.escape(a.name)}</span>
3984
- <span class="agent-row-time">${this.escape(time)}</span>
3985
4176
  ${pinBtn}
3986
4177
  </div>
3987
4178
  <div class="agent-row-subtitle">
3988
- <span>${this.escape(a.roleTag || "director")}</span>
4179
+ <span>${this.escape(a.roleTag || this._t("sidebar_role_director"))}</span>
3989
4180
  <span class="agent-row-sep">·</span>
3990
- <span class="agent-row-status">${this.escape(status)}</span>
4181
+ <span class="agent-row-status">${this.escape(this._t("sidebar_status_active"))}</span>
3991
4182
  </div>
3992
4183
  </div>
3993
4184
  </a>
@@ -4006,12 +4197,10 @@
4006
4197
  <div class="agent-row-content">
4007
4198
  <div class="agent-row-top-line">
4008
4199
  <span class="agent-row-title">${this.escape(a.name)}</span>
4009
- <span class="agent-row-chair-badge" title="Moderator · structural agent, not user-managed">CHAIR</span>
4200
+ <span class="agent-row-chair-badge" title="${this.escape(this._t("sidebar_chair_badge_title"))}">${this.escape(this._t("sidebar_chair_badge"))}</span>
4010
4201
  </div>
4011
4202
  <div class="agent-row-subtitle">
4012
- <span class="agent-row-chair-role">${this.escape(a.roleTag || "moderator")}</span>
4013
- <span class="agent-row-sep">·</span>
4014
- <span class="agent-row-chair-note">in every room</span>
4203
+ <span class="agent-row-chair-role">${this.escape((a.roleTag && String(a.roleTag).toLowerCase() === "moderator") ? this._t("agent_role_tag_moderator") : (a.roleTag || this._t("sidebar_chair_role_fallback")))}</span>
4015
4204
  </div>
4016
4205
  </div>
4017
4206
  </a>
@@ -4040,22 +4229,30 @@
4040
4229
  // user immediately sees the orchestrator, and so it can't get
4041
4230
  // grouped with directors they pin or create.
4042
4231
  if (this.currentChair) {
4043
- parts.push(sectionHeader("Chair", 1, "chair"));
4232
+ parts.push(sectionHeader(this._t("sidebar_sec_chair"), 1, "chair"));
4044
4233
  parts.push(renderChairRow(this.currentChair));
4045
4234
  }
4046
4235
  if (pinned.length) {
4047
- parts.push(sectionHeader("Pinned", pinned.length, "pinned"));
4236
+ parts.push(sectionHeader(this._t("sidebar_sec_pinned"), pinned.length, "pinned"));
4048
4237
  parts.push(pinned.map((a) => renderRow(a)).join(""));
4049
4238
  }
4050
4239
  if (custom.length) {
4051
- parts.push(sectionHeader("Custom", custom.length));
4240
+ parts.push(sectionHeader(this._t("sidebar_sec_custom"), custom.length));
4052
4241
  parts.push(custom.map((a) => renderRow(a)).join(""));
4053
4242
  }
4054
4243
  if (core.length) {
4055
- parts.push(sectionHeader("Core", core.length));
4244
+ parts.push(sectionHeader(this._t("sidebar_sec_core"), core.length));
4056
4245
  parts.push(core.map((a) => renderRow(a)).join(""));
4057
4246
  }
4058
- list.innerHTML = parts.join("");
4247
+ // Same idempotency guard as renderSidebarRooms · skip the
4248
+ // innerHTML wipe when nothing has actually changed, so a
4249
+ // background SSE re-render doesn't drop in-flight clicks on
4250
+ // agent rows.
4251
+ const html = parts.join("");
4252
+ if (this._sidebarAgentsHtml !== html) {
4253
+ this._sidebarAgentsHtml = html;
4254
+ list.innerHTML = html;
4255
+ }
4059
4256
  },
4060
4257
 
4061
4258
  renderUserBlock() {
@@ -4068,7 +4265,7 @@
4068
4265
  const firstLine = intro.split(/[\n.·]/)[0].trim();
4069
4266
  meta = firstLine.length > 32 ? firstLine.slice(0, 30) + "…" : firstLine;
4070
4267
  } else {
4071
- meta = "// host";
4268
+ meta = this._t("sidebar_host");
4072
4269
  }
4073
4270
 
4074
4271
  // Avatar source-of-truth · prefs.avatarSeed (set by the
@@ -4281,23 +4478,19 @@
4281
4478
  const briefCard = document.querySelector("[data-brief-card]");
4282
4479
  if (!briefCard) return;
4283
4480
 
4284
- // System UI · always English. Session-analytics tile chrome
4285
- // (banner, metric labels, section heads, chip text) is part of
4286
- // the app shell and doesn't follow the brief language.
4287
4481
  const t = {
4288
- head: "// session analytics",
4289
- stamp: "closed",
4290
- tokens: "tokens",
4291
- messages: "msgs",
4292
- rounds: "rounds",
4293
- minutes: "min",
4294
- modelHead: "Model usage",
4295
- valueHead: "What you valued",
4296
- valueEmpty: "No ▲ key-point votes, probes, or seconds in this session.",
4297
- voted: "▲ voted",
4298
- seconded: "★ seconded",
4299
- probed: "✎ probed",
4300
- };
4482
+ head: this._t("sa_head"),
4483
+ stamp: this._t("sa_stamp"),
4484
+ tokens: this._t("sa_tokens"),
4485
+ messages: this._t("sa_messages"),
4486
+ rounds: this._t("sa_rounds"),
4487
+ minutes: this._t("sa_minutes"),
4488
+ modelHead: this._t("sa_model_head"),
4489
+ valueHead: this._t("sa_value_head"),
4490
+ valueEmpty: this._t("sa_value_empty"),
4491
+ voted: this._t("sa_voted"),
4492
+ seconded: this._t("sa_seconded"),
4493
+ probed: this._t("sa_probed"), };
4301
4494
 
4302
4495
  const fmtTokens = (n) => {
4303
4496
  if (!Number.isFinite(n) || n <= 0) return "0";
@@ -4336,11 +4529,11 @@
4336
4529
  // base provider colour. Append-only — adding a new variant just
4337
4530
  // requires its modelV at the appropriate position.
4338
4531
  const MODEL_SHADE_ORDER = {
4339
- anthropic: ["opus-4-7", "sonnet-4-6", "haiku-4-5"],
4532
+ anthropic: ["opus-4-7", "opus-4-6", "sonnet-4-6", "haiku-4-5"],
4340
4533
  openai: ["gpt-5-5", "gpt-5-4-mini", "codex-5-4"],
4341
4534
  google: ["gemini-3-1", "gemini-3-flash", "gemini-3-1-flash"],
4342
4535
  xai: ["grok-4", "grok-4-3", "grok-4-mini"],
4343
- deepseek: ["deepseek-v4-pro", "deepseek-v4"],
4536
+ deepseek: ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4"],
4344
4537
  };
4345
4538
  const providerOf = (modelV) => {
4346
4539
  const hit = reachable.find((m) => m.modelV === modelV);
@@ -4371,7 +4564,7 @@
4371
4564
  const barSegments = stats.modelBreakdown.map((row) => {
4372
4565
  const color = colorForModel(row.modelV);
4373
4566
  const widthPct = (row.pct * 100).toFixed(2);
4374
- return `<span class="sa-bar-seg" style="width: ${widthPct}%; background: ${color};" title="${this.escape(modelLabel(row.modelV) + " · " + fmtTokens(row.tokens) + " tokens")}"></span>`;
4567
+ return `<span class="sa-bar-seg" style="width: ${widthPct}%; background: ${color};" title="${this.escape(this._t("sa_seg_title", { model: modelLabel(row.modelV), tokens: fmtTokens(row.tokens) }))}"></span>`;
4375
4568
  }).join("");
4376
4569
  const barHtml = stats.modelBreakdown.length > 0
4377
4570
  ? `<div class="sa-bar" role="img" aria-label="${this.escape(t.modelHead)}">${barSegments}</div>`
@@ -4520,13 +4713,11 @@
4520
4713
  ? this.escape(nextSpeaker.handle.replace(/^\//, ""))
4521
4714
  : "";
4522
4715
 
4523
- // System UI · always English. App chrome (paused bar, action
4524
- // buttons, status labels) doesn't follow the brief language.
4525
- const addInputLabel = "[ + Add input ]";
4526
- const adjournLabel = "[ ▸ Adjourn & File Brief ]";
4527
- const resumeLabel = "[ ▶ Resume Discussion ]";
4528
- const pausedLabel = "paused";
4529
- const nextLabel = "next";
4716
+ const addInputLabel = this._t("pause_bar_add_input");
4717
+ const adjournLabel = this._t("pause_bar_adjourn");
4718
+ const resumeLabel = this._t("pause_bar_resume");
4719
+ const pausedLabel = this._t("pause_bar_paused");
4720
+ const nextLabel = this._t("pause_bar_next");
4530
4721
  const nextChunk = nextHandle
4531
4722
  ? ` · ${nextLabel} → <span class="lime">${nextHandle}</span>`
4532
4723
  : "";
@@ -4551,6 +4742,7 @@
4551
4742
  directorIds: [], // populated lazily from the chair's roster
4552
4743
  mode: "constructive",
4553
4744
  intensity: "sharp",
4745
+ deliveryMode: "text",
4554
4746
  },
4555
4747
 
4556
4748
  loadComposerState() {
@@ -4571,10 +4763,10 @@
4571
4763
  saveComposerState() {
4572
4764
  if (!this.composerState) return;
4573
4765
  try {
4574
- const { directorIds, mode, intensity, autoPickDirectors, subject } = this.composerState;
4766
+ const { directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject } = this.composerState;
4575
4767
  localStorage.setItem(
4576
4768
  "boardroom.composer",
4577
- JSON.stringify({ directorIds, mode, intensity, autoPickDirectors, subject }),
4769
+ JSON.stringify({ directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject }),
4578
4770
  );
4579
4771
  } catch { /* ignore */ }
4580
4772
  },
@@ -4635,6 +4827,13 @@
4635
4827
  setTimeout(() => {
4636
4828
  const ta = chat.querySelector("[data-agent-composer-desc]");
4637
4829
  if (ta) {
4830
+ // Set the multi-line placeholder via DOM property · the
4831
+ // HTML attribute path strips newlines in some parsers,
4832
+ // so the second line of the pitch wouldn't render. The
4833
+ // DOM `placeholder` property reflects `\n` as a literal
4834
+ // line break in the textarea's empty state, which is
4835
+ // exactly what we want.
4836
+ ta.placeholder = this._t("ag_cmp_placeholder");
4638
4837
  ta.focus();
4639
4838
  this.autosizeAgentComposerTextarea();
4640
4839
  }
@@ -4656,7 +4855,13 @@
4656
4855
  chat.innerHTML = this.renderComposerHtml(state);
4657
4856
  setTimeout(() => {
4658
4857
  const ta = chat.querySelector("[data-composer-subject]");
4659
- if (ta) ta.focus();
4858
+ if (ta) {
4859
+ // Same multi-line placeholder pattern as the agent
4860
+ // composer above · DOM property preserves the `\n` the
4861
+ // HTML attribute path can drop.
4862
+ ta.placeholder = this._composerSubjectPlaceholder();
4863
+ ta.focus();
4864
+ }
4660
4865
  this.autosizeComposerTextarea();
4661
4866
  }, 30);
4662
4867
  }
@@ -4821,7 +5026,12 @@
4821
5026
  // to All.
4822
5027
  this._reportsCache = briefs;
4823
5028
  const activeFilter = this._reportsFilter || "all";
4824
- const filterLabels = { all: "the archive", today: "Today", week: "This week", earlier: "Earlier" };
5029
+ const filterLabels = {
5030
+ all: this._t("rep_filter_archive_label"),
5031
+ today: this._t("rep_filter_today"),
5032
+ week: this._t("rep_filter_week"),
5033
+ earlier: this._t("rep_filter_earlier"),
5034
+ };
4825
5035
 
4826
5036
  const emptyChip = (key, label) => {
4827
5037
  const on = key === activeFilter ? " on" : "";
@@ -4834,39 +5044,39 @@
4834
5044
  };
4835
5045
 
4836
5046
  const isAll = activeFilter === "all";
4837
- const cardKicker = isAll ? "// archive empty" : "// window empty";
5047
+ const cardKicker = isAll ? this._t("rep_empty_kicker_archive") : this._t("rep_empty_kicker_window");
4838
5048
  const cardTitle = isAll
4839
- ? "No reports filed yet"
4840
- : `No reports in ${filterLabels[activeFilter]}`;
5049
+ ? this._t("rep_empty_title_none")
5050
+ : this._t("rep_empty_title_window", { window: filterLabels[activeFilter] });
4841
5051
  const cardDeck = isAll
4842
- ? "Run a session and adjourn — once the chair files, every brief across every room lands here."
4843
- : "Run a session and adjourn — once the chair files, briefs in this window land here. Or jump back to the full archive.";
5052
+ ? this._t("rep_empty_deck_all")
5053
+ : this._t("rep_empty_deck_window");
4844
5054
  const cardCtaHtml = isAll
4845
5055
  ? `
4846
5056
  <button type="button" class="reports-list-empty-cta" data-convene-trigger>
4847
5057
  <span class="reports-list-empty-cta-arrow">→</span>
4848
- <span>Convene a new room</span>
5058
+ <span>${this.escape(this._t("rep_cta_convene"))}</span>
4849
5059
  </button>`
4850
5060
  : `
4851
5061
  <button type="button" class="reports-list-empty-cta" data-reports-filter="all">
4852
5062
  <span class="reports-list-empty-cta-arrow">←</span>
4853
- <span>Show all reports</span>
5063
+ <span>${this.escape(this._t("rep_cta_show_all"))}</span>
4854
5064
  </button>`;
4855
5065
 
4856
5066
  page.innerHTML = `
4857
5067
  <div class="reports-page-head">
4858
5068
  <div>
4859
- <div class="reports-page-kicker">// archive</div>
4860
- <h1 class="reports-page-title">All Reports</h1>
5069
+ <div class="reports-page-kicker">${this.escape(this._t("rep_kicker"))}</div>
5070
+ <h1 class="reports-page-title">${this.escape(this._t("rep_title"))}</h1>
4861
5071
  </div>
4862
- <div class="reports-page-meta">0 reports</div>
5072
+ <div class="reports-page-meta">${this.escape(this._t("rep_meta", { n: 0 }))}</div>
4863
5073
  </div>
4864
5074
 
4865
- <div class="reports-filters" role="tablist" aria-label="Filter reports by recency">
4866
- ${emptyChip("all", "All")}
4867
- ${emptyChip("today", "Today")}
4868
- ${emptyChip("week", "This week")}
4869
- ${emptyChip("earlier", "Earlier")}
5075
+ <div class="reports-filters" role="tablist" aria-label="${this.escape(this._t("rep_aria_filters"))}">
5076
+ ${emptyChip("all", this._t("rep_filter_all"))}
5077
+ ${emptyChip("today", this._t("rep_filter_today"))}
5078
+ ${emptyChip("week", this._t("rep_filter_week"))}
5079
+ ${emptyChip("earlier", this._t("rep_filter_earlier"))}
4870
5080
  </div>
4871
5081
 
4872
5082
  <div class="reports-list-wrap">
@@ -4933,21 +5143,43 @@
4933
5143
  `;
4934
5144
  };
4935
5145
 
4936
- // Flat list under the filter chips · earlier iterations grouped
4937
- // by Today / Yesterday / This week / Earlier, but the user
4938
- // wanted a single divider followed by the items. The filter
4939
- // chips above already disclose the recency dimension; redundant
4940
- // group headers were just visual chrome.
4941
- const filterLabels = { all: "the archive", today: "Today", week: "This week", earlier: "Earlier" };
4942
- const filterCopyTitle = filterLabels[activeFilter] || "this window";
4943
- const groupsHtml = visibleFiltered.length === 0
4944
- ? `
5146
+ // Group filtered items by date label (Today / Yesterday / This
5147
+ // week / Earlier) so the list still has rhythm without splitting
5148
+ // into multiple sections each with its own header chrome.
5149
+ // Iterates the *visible* slice; the load-more sentinel below
5150
+ // tops the list up by 20 each time it crosses the viewport.
5151
+ const groups = [];
5152
+ const yesterdayStart = todayStart - 86400_000;
5153
+ let currentGroup = null;
5154
+ const groupLabelFor = (ts) => {
5155
+ if (ts >= todayStart) return this._t("rep_group_today");
5156
+ if (ts >= yesterdayStart) return this._t("rep_group_yesterday");
5157
+ if (ts >= weekStart) return this._t("rep_group_week");
5158
+ return this._t("rep_group_earlier");
5159
+ };
5160
+ for (const b of visibleFiltered) {
5161
+ const label = groupLabelFor(b.createdAt);
5162
+ if (!currentGroup || currentGroup.label !== label) {
5163
+ currentGroup = { label, items: [] };
5164
+ groups.push(currentGroup);
5165
+ }
5166
+ currentGroup.items.push(b);
5167
+ }
5168
+
5169
+ const filterLabels = {
5170
+ all: this._t("rep_filter_archive_label"),
5171
+ today: this._t("rep_filter_today"),
5172
+ week: this._t("rep_filter_week"),
5173
+ earlier: this._t("rep_filter_earlier"),
5174
+ };
5175
+ const filterCopyTitle = filterLabels[activeFilter] || filterLabels.all;
5176
+ const groupsHtml = groups.length === 0 ? `
4945
5177
  <div class="reports-list-empty">
4946
5178
  <!-- Notice text · explains the empty window. -->
4947
5179
  <div class="reports-list-empty-text">
4948
- <div class="reports-list-empty-kicker">// window empty</div>
4949
- <h3 class="reports-list-empty-title">No reports in ${this.escape(filterCopyTitle)}</h3>
4950
- <p class="reports-list-empty-deck">Pick a different filter, or jump back to the full archive.</p>
5180
+ <div class="reports-list-empty-kicker">${this.escape(this._t("rep_empty_kicker_window"))}</div>
5181
+ <h3 class="reports-list-empty-title">${this.escape(this._t("rep_empty_title_window", { window: filterCopyTitle }))}</h3>
5182
+ <p class="reports-list-empty-deck">${this.escape(this._t("rep_empty_deck_filter"))}</p>
4951
5183
  </div>
4952
5184
  <!-- Static skeleton silhouette · three quiet bars
4953
5185
  suggesting "title · judgement · meta," paired with
@@ -4960,7 +5192,7 @@
4960
5192
  ${activeFilter !== "all" ? `
4961
5193
  <button type="button" class="reports-list-empty-cta" data-reports-filter="all">
4962
5194
  <span class="reports-list-empty-cta-arrow">←</span>
4963
- <span>Show all reports</span>
5195
+ <span>${this.escape(this._t("rep_cta_show_all"))}</span>
4964
5196
  </button>
4965
5197
  ` : ""}
4966
5198
  </div>
@@ -4975,12 +5207,21 @@
4975
5207
  // when there's more to load. The "+ N more" hint doubles as a
4976
5208
  // click target if the user prefers explicit paging over scroll.
4977
5209
  const remaining = filtered.length - visibleCount;
5210
+ const loadN = Math.min(20, remaining);
5211
+ const reportsMetaLine =
5212
+ (total === 1 ? this._t("rep_total_one") : this._t("rep_total_n", { n: total })) +
5213
+ (distinctRooms > 0
5214
+ ? (distinctRooms === 1
5215
+ ? this._t("rep_meta_room_one")
5216
+ : this._t("rep_meta_room_n", { n: distinctRooms }))
5217
+ : "") +
5218
+ (hasMore ? this._t("rep_showing", { n: visibleCount }) : "");
4978
5219
  const sentinelHtml = hasMore
4979
5220
  ? `
4980
5221
  <div class="reports-load-sentinel" data-reports-load-sentinel>
4981
5222
  <button type="button" class="reports-load-more" data-reports-load-more>
4982
5223
  <span class="reports-load-more-arrow">▾</span>
4983
- <span class="reports-load-more-text">Load ${Math.min(20, remaining)} more · ${remaining} remaining</span>
5224
+ <span class="reports-load-more-text">${this.escape(this._t("rep_load_more", { load: loadN, remaining }))}</span>
4984
5225
  </button>
4985
5226
  </div>
4986
5227
  `
@@ -4989,17 +5230,17 @@
4989
5230
  page.innerHTML = `
4990
5231
  <div class="reports-page-head">
4991
5232
  <div>
4992
- <div class="reports-page-kicker">// archive</div>
4993
- <h1 class="reports-page-title">All Reports</h1>
5233
+ <div class="reports-page-kicker">${this.escape(this._t("rep_kicker"))}</div>
5234
+ <h1 class="reports-page-title">${this.escape(this._t("rep_title"))}</h1>
4994
5235
  </div>
4995
- <div class="reports-page-meta">${total} ${total === 1 ? "report" : "reports"} · ${distinctRooms} ${distinctRooms === 1 ? "room" : "rooms"}${hasMore ? ` · showing ${visibleCount}` : ""}</div>
5236
+ <div class="reports-page-meta">${this.escape(reportsMetaLine)}</div>
4996
5237
  </div>
4997
5238
 
4998
- <div class="reports-filters" role="tablist" aria-label="Filter reports by recency">
4999
- ${filterChip("all", "All", total)}
5000
- ${filterChip("today", "Today", todayCount)}
5001
- ${filterChip("week", "This week", weekCount)}
5002
- ${filterChip("earlier", "Earlier", earlierCount)}
5239
+ <div class="reports-filters" role="tablist" aria-label="${this.escape(this._t("rep_aria_filters"))}">
5240
+ ${filterChip("all", this._t("rep_filter_all"), total)}
5241
+ ${filterChip("today", this._t("rep_filter_today"), todayCount)}
5242
+ ${filterChip("week", this._t("rep_filter_week"), weekCount)}
5243
+ ${filterChip("earlier", this._t("rep_filter_earlier"), earlierCount)}
5003
5244
  </div>
5004
5245
 
5005
5246
  <div class="reports-list-wrap">${groupsHtml}${sentinelHtml}</div>
@@ -5050,11 +5291,16 @@
5050
5291
  }
5051
5292
 
5052
5293
  // Scroll path · IntersectionObserver fires when the sentinel
5053
- // crosses the viewport. `rootMargin: 200px` triggers slightly
5054
- // before the actual edge so the next batch is rendered before
5055
- // the user reaches the bottom feels seamless rather than
5056
- // chunked.
5294
+ // crosses the viewport. The All Reports view uses an inner
5295
+ // scroll container (`.main-view[data-main-view="reports"]` has
5296
+ // `overflow-y: auto`), so the document viewport itself never
5297
+ // scrolls. The observer's `root` MUST point at the inner
5298
+ // scroller — otherwise the sentinel never intersects and
5299
+ // infinite scroll silently does nothing. `rootMargin: 200px`
5300
+ // triggers slightly before the actual edge so the next batch
5301
+ // is rendered before the user reaches the bottom.
5057
5302
  try {
5303
+ const scrollRoot = document.querySelector('.main-view[data-main-view="reports"]') || null;
5058
5304
  this._reportsLoadObserver = new IntersectionObserver(
5059
5305
  (entries) => {
5060
5306
  for (const entry of entries) {
@@ -5064,7 +5310,7 @@
5064
5310
  }
5065
5311
  }
5066
5312
  },
5067
- { rootMargin: "200px 0px 200px 0px", threshold: 0.01 },
5313
+ { root: scrollRoot, rootMargin: "200px 0px 200px 0px", threshold: 0.01 },
5068
5314
  );
5069
5315
  this._reportsLoadObserver.observe(sentinel);
5070
5316
  } catch { /* IntersectionObserver unavailable · click path remains */ }
@@ -5277,11 +5523,11 @@
5277
5523
  `;
5278
5524
  };
5279
5525
  const filtersHtml = `
5280
- <div class="notes-filters" role="tablist" aria-label="Filter notes by recency">
5281
- ${filterChip("all", "All", total)}
5282
- ${filterChip("today", "Today", todayCount)}
5283
- ${filterChip("week", "This week", weekCount)}
5284
- ${filterChip("earlier", "Earlier", earlierCount)}
5526
+ <div class="notes-filters" role="tablist" aria-label="${this.escape(this._t("notes_aria_filters"))}">
5527
+ ${filterChip("all", this._t("notes_filter_all"), total)}
5528
+ ${filterChip("today", this._t("rep_filter_today"), todayCount)}
5529
+ ${filterChip("week", this._t("rep_filter_week"), weekCount)}
5530
+ ${filterChip("earlier", this._t("rep_filter_earlier"), earlierCount)}
5285
5531
  </div>
5286
5532
  `;
5287
5533
 
@@ -5292,41 +5538,17 @@
5292
5538
  page.innerHTML = `
5293
5539
  <div class="notes-page-head">
5294
5540
  <div>
5295
- <div class="notes-page-kicker">// chairman · saved excerpts</div>
5296
- <h1 class="notes-page-title">All Notes</h1>
5541
+ <div class="notes-page-kicker">${this.escape(this._t("notes_kicker"))}</div>
5542
+ <h1 class="notes-page-title">${this.escape(this._t("notes_title"))}</h1>
5297
5543
  </div>
5298
- <div class="notes-page-meta">0 notes</div>
5544
+ <div class="notes-page-meta">${this.escape(this._t("notes_meta", { n: 0 }))}</div>
5299
5545
  </div>
5300
5546
  ${filtersHtml}
5301
5547
  <div class="notes-list-empty">
5302
5548
  <div class="notes-empty-mark">○</div>
5303
- <div class="notes-empty-title">no saved notes yet</div>
5549
+ <div class="notes-empty-title">${this.escape(this._t("notes_empty_title"))}</div>
5304
5550
  <div class="notes-empty-deck">
5305
- Capture a director's line as a note:
5306
- </div>
5307
- <ol class="notes-empty-steps">
5308
- <li>
5309
- <span class="notes-empty-step-num">1</span>
5310
- <span class="notes-empty-step-text">
5311
- <strong>Open a room.</strong> Any director's reply can be saved.
5312
- </span>
5313
- </li>
5314
- <li>
5315
- <span class="notes-empty-step-num">2</span>
5316
- <span class="notes-empty-step-text">
5317
- <strong>Select a passage.</strong> Drag-highlight the sentence or paragraph you want to keep.
5318
- </span>
5319
- </li>
5320
- <li>
5321
- <span class="notes-empty-step-num">3</span>
5322
- <span class="notes-empty-step-text">
5323
- <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.
5324
- </span>
5325
- </li>
5326
- </ol>
5327
- <div class="notes-empty-foot">
5328
- Each saved excerpt links back to the original room + speaker, so you can jump back any time.
5329
- </div>
5551
+ ${this._t("notes_empty_deck")} </div>
5330
5552
  </div>
5331
5553
  `;
5332
5554
  return;
@@ -5340,21 +5562,40 @@
5340
5562
  return true;
5341
5563
  });
5342
5564
 
5343
- // Flat list under the filter chips. Earlier iterations grouped
5344
- // by Today / This week / Earlier headers; the user wanted a
5345
- // single divider followed by the items, since the chips above
5346
- // already disclose the recency dimension.
5347
- const filterLabels = { all: "the archive", today: "Today", week: "This week", earlier: "Earlier" };
5348
- const groupsHtml = filtered.length === 0
5349
- ? `
5565
+ // Group filtered items by date label so even an "All" view has
5566
+ // visual rhythm. When a recency filter narrows to a single
5567
+ // bucket, only that bucket's section renders no empty headers.
5568
+ const groupLabelFor = (ts) => {
5569
+ if (ts >= todayStart) return this._t("rep_group_today");
5570
+ if (ts >= weekStart) return this._t("rep_group_week");
5571
+ return this._t("rep_group_earlier");
5572
+ };
5573
+ const groups = [];
5574
+ let currentGroup = null;
5575
+ for (const n of filtered) {
5576
+ const label = groupLabelFor(n.createdAt || 0);
5577
+ if (!currentGroup || currentGroup.label !== label) {
5578
+ currentGroup = { label, items: [] };
5579
+ groups.push(currentGroup);
5580
+ }
5581
+ currentGroup.items.push(n);
5582
+ }
5583
+
5584
+ const filterLabels = {
5585
+ all: this._t("rep_filter_archive_label"),
5586
+ today: this._t("rep_filter_today"),
5587
+ week: this._t("rep_filter_week"),
5588
+ earlier: this._t("rep_filter_earlier"),
5589
+ };
5590
+ const groupsHtml = groups.length === 0 ? `
5350
5591
  <div class="notes-list-empty">
5351
5592
  <div class="notes-empty-mark">○</div>
5352
- <div class="notes-empty-title">No notes in ${this.escape(filterLabels[activeFilter] || "this window")}</div>
5353
- <div class="notes-empty-deck">Pick a different filter, or jump back to the full archive.</div>
5593
+ <div class="notes-empty-title">${this.escape(this._t("notes_empty_window", { window: filterLabels[activeFilter] || filterLabels.all }))}</div>
5594
+ <div class="notes-empty-deck">${this.escape(this._t("notes_empty_filter_deck"))}</div>
5354
5595
  ${activeFilter !== "all" ? `
5355
5596
  <button type="button" class="notes-empty-cta" data-notes-filter="all">
5356
5597
  <span class="notes-empty-cta-arrow">←</span>
5357
- <span>Show all notes</span>
5598
+ <span>${this.escape(this._t("notes_cta_show_all"))}</span>
5358
5599
  </button>
5359
5600
  ` : ""}
5360
5601
  </div>
@@ -5365,18 +5606,19 @@
5365
5606
  </ul>
5366
5607
  `;
5367
5608
 
5368
- const totalLabel = `${total} ${total === 1 ? "note" : "notes"}`;
5609
+ const totalLabel =
5610
+ total === 1 ? this._t("notes_one") : this._t("notes_many", { n: total });
5369
5611
  const roomLabel = distinctRooms > 0
5370
- ? ` · ${distinctRooms} ${distinctRooms === 1 ? "room" : "rooms"}`
5612
+ ? (distinctRooms === 1 ? this._t("rooms_one") : this._t("rooms_many", { n: distinctRooms }))
5371
5613
  : "";
5372
5614
 
5373
5615
  page.innerHTML = `
5374
5616
  <div class="notes-page-head">
5375
5617
  <div>
5376
- <div class="notes-page-kicker">// chairman · saved excerpts</div>
5377
- <h1 class="notes-page-title">All Notes</h1>
5618
+ <div class="notes-page-kicker">${this.escape(this._t("notes_kicker"))}</div>
5619
+ <h1 class="notes-page-title">${this.escape(this._t("notes_title"))}</h1>
5378
5620
  </div>
5379
- <div class="notes-page-meta">${this.escape(totalLabel)}${this.escape(roomLabel)}</div>
5621
+ <div class="notes-page-meta">${this.escape(totalLabel)}${roomLabel ? this.escape(" · " + roomLabel) : ""}</div>
5380
5622
  </div>
5381
5623
  ${filtersHtml}
5382
5624
  <div class="notes-list-wrap">${groupsHtml}</div>
@@ -5400,7 +5642,7 @@
5400
5642
  const time = this.relTime(n.createdAt) || "";
5401
5643
  const roomNum = n.roomNumber != null ? `#${String(n.roomNumber).padStart(3, "0")}` : "";
5402
5644
  const roomSubject = (n.roomSubject || "").slice(0, 100);
5403
- const author = n.authorName || "Director";
5645
+ const author = n.authorName || this._t("notes_director_fallback");
5404
5646
  // The jump link uses the room's hash route + the note id as a
5405
5647
  // fragment-style query so openRoom can scroll to + flash the
5406
5648
  // matching span. The delete button sits as a SIBLING of the
@@ -5413,7 +5655,7 @@
5413
5655
  <li class="notes-item" data-note-id="${this.escape(n.id)}">
5414
5656
  <a class="notes-item-link" href="${href}" data-note-jump="${this.escape(n.id)}" data-note-room="${this.escape(n.roomId)}">
5415
5657
  <div class="notes-item-meta">
5416
- <span class="notes-item-room">ROOM ${this.escape(roomNum)}</span>
5658
+ <span class="notes-item-room">${this.escape(this._t("notes_item_room"))} ${this.escape(roomNum)}</span>
5417
5659
  ${roomSubject ? `<span class="notes-item-sep">·</span><span class="notes-item-subject">${this.escape(roomSubject)}</span>` : ""}
5418
5660
  <span class="notes-item-sep">·</span>
5419
5661
  <span class="notes-item-director">${this.escape(author)}</span>
@@ -5660,7 +5902,7 @@
5660
5902
  // tooltip's 1–2s delay competing with the custom `.note-tip`
5661
5903
  // popover wired up in init(); aria-label still surfaces the
5662
5904
  // affordance for screen readers.
5663
- span.setAttribute("aria-label", "Saved to Notes");
5905
+ span.setAttribute("aria-label", this._t("note_saved"));
5664
5906
  // Text-only ranges always satisfy surroundContents' "no
5665
5907
  // partial non-Text node" precondition · the catch is just
5666
5908
  // a safety belt for browsers that surprise us.
@@ -5701,7 +5943,7 @@
5701
5943
  const time = note ? this.relTime(note.createdAt) : "";
5702
5944
  tip.innerHTML = `
5703
5945
  <span class="note-tip-mark">✓</span>
5704
- <span class="note-tip-label">Saved to Notes</span>
5946
+ <span class="note-tip-label">${this.escape(this._t("note_saved"))}</span>
5705
5947
  ${time ? `<span class="note-tip-sep">·</span><span class="note-tip-meta">${this.escape(time)}</span>` : ""}
5706
5948
  `;
5707
5949
  const rect = span.getBoundingClientRect();
@@ -5871,6 +6113,19 @@
5871
6113
  }
5872
6114
  },
5873
6115
 
6116
+ /** Pitch copy for the new-room composer's textarea placeholder.
6117
+ * Centralised so renderComposerHtml AND the post-render DOM
6118
+ * `.placeholder` set in renderEmptyState share one source. The
6119
+ * DOM property path is the load-bearing one — setting it via
6120
+ * the HTML attribute strips the `\n` in some parsers, so a
6121
+ * multi-line pitch never renders the second line. The earlier
6122
+ * trailing "Drop in what's on your mind in a sentence or two."
6123
+ * was retired at the user's request — it was a CTA-style nudge
6124
+ * redundant with the heading question above the textarea. */
6125
+ _composerSubjectPlaceholder() {
6126
+ return "Convening a room can stress-test a thesis, force a decision you've been avoiding, or surface the angle nobody on your real team brings.";
6127
+ },
6128
+
5874
6129
  /** Build the composer HTML. Centred hero composition · Claude /
5875
6130
  * ChatGPT-style new-chat landing, but tuned to our boardroom
5876
6131
  * language. The single input block IS the focal point — cast +
@@ -5889,7 +6144,7 @@
5889
6144
  const t = {
5890
6145
  greet: greeting,
5891
6146
  prompt: "What's on your mind today?",
5892
- placeholder: "An idea you're not sure about, a decision you keep avoiding, or a thesis you want stress-tested. e.g. Should we lean into mid-market or stay enterprise-only? Or: What's the strongest argument against shipping the self-serve tier next quarter?",
6147
+ placeholder: this._composerSubjectPlaceholder(),
5893
6148
  convene: "Convene",
5894
6149
  tuneLabel: "tune",
5895
6150
  starterLabel: "starter",
@@ -5949,10 +6204,8 @@
5949
6204
  // Tune dropdowns · two trigger buttons that open option popovers
5950
6205
  // on click. Discoverable (label + value + chevron pattern is
5951
6206
  // unmistakeably a select) without taking up visual real estate.
5952
- // System UI · always English (tune dropdown labels).
5953
- const toneLbl = "tone";
5954
- const intensityLbl = "intensity";
5955
-
6207
+ const toneLbl = this._t("cmp_tone_label");
6208
+ const intensityLbl = this._t("cmp_intensity_label");
5956
6209
  // Starter grid · 2-col responsive cards.
5957
6210
  const starters = Array.isArray(window.BOARDROOM_STARTERS) ? window.BOARDROOM_STARTERS : [];
5958
6211
  const starterCards = starters.map((q, idx) => {
@@ -5994,10 +6247,47 @@
5994
6247
  <span class="cmp-dd-value" data-cmp-dd-value="intensity">${this.escape(state.intensity)}</span>
5995
6248
  <span class="cmp-dd-chevron">▾</span>
5996
6249
  </button>
6250
+ ${(() => {
6251
+ // Reuse the same toggle vocabulary as the new-agent
6252
+ // composer's web-search toggle: `.ap-skill-row-toggle`
6253
+ // (track + knob + text) plus `.cmp-ws-toggle` for
6254
+ // toolbar fitting. `needs-key` modifier renders the
6255
+ // dashed "configure first" treatment when no voice
6256
+ // provider is set, matching the WS toggle pattern
6257
+ // exactly. Class set: `on` / `off` / `needs-key`.
6258
+ const voiceConfigured = this.hasAnyVoiceKey();
6259
+ const voiceOn = voiceConfigured && state.deliveryMode === "voice";
6260
+ const voiceLabel = this._t("cmp_voice_label");
6261
+ const voiceTitle = !voiceConfigured
6262
+ ? "Configure a voice provider to enable voice mode"
6263
+ : (voiceOn
6264
+ ? "Voice mode on · directors speak aloud during the room"
6265
+ : "Voice mode off · click to enable");
6266
+ const voiceCls = [
6267
+ "ap-skill-row-toggle",
6268
+ "cmp-ws-toggle",
6269
+ voiceOn ? "on" : "off",
6270
+ voiceConfigured ? "" : "needs-key",
6271
+ ].filter(Boolean).join(" ");
6272
+ return `
6273
+ <button type="button" class="${voiceCls}"
6274
+ data-composer-voice-toggle
6275
+ data-configured="${voiceConfigured ? "1" : "0"}"
6276
+ data-on="${voiceOn ? "1" : "0"}"
6277
+ aria-pressed="${voiceOn ? "true" : "false"}"
6278
+ title="${this.escape(voiceTitle)}">
6279
+ <span class="ap-skill-row-toggle-track"><span class="ap-skill-row-toggle-knob"></span></span>
6280
+ <span class="ap-skill-row-toggle-text">${this.escape(voiceLabel)}</span>
6281
+ </button>
6282
+ `;
6283
+ })()}
5997
6284
  </div>
5998
6285
 
5999
6286
  <button type="button" class="cmp-go" data-composer-go title="${this.escape(t.convene)} (⏎)">
6000
- <span class="cmp-go-arrow">→</span>
6287
+ <svg class="cmp-go-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
6288
+ <path d="M5 12h14"/>
6289
+ <path d="m13 5 7 7-7 7"/>
6290
+ </svg>
6001
6291
  </button>
6002
6292
  </div>
6003
6293
  </div>
@@ -6017,24 +6307,44 @@
6017
6307
  },
6018
6308
 
6019
6309
  /** Time-of-day greeting like "// good evening, Kay" / "// 晚上好,Kay". */
6020
- composerGreeting(_lang, name) {
6021
- // System UI · always English. Greeting is part of the composer
6022
- // chrome; the brief language doesn't change the app's voice.
6310
+ composerGreeting(lang, name) {
6311
+ // Follows composer chrome locale (`lang` · en or zh via I18n).
6023
6312
  const h = new Date().getHours();
6024
- const part = h < 5 ? "Up late" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
6025
- return `// ${part}, ${name}`;
6026
- },
6313
+ const isZh = lang === "zh";
6314
+ let key;
6315
+ if (isZh) {
6316
+ if (h < 5) key = "greet_zh_0";
6317
+ else if (h < 12) key = "greet_zh_1";
6318
+ else if (h < 14) key = "greet_zh_2";
6319
+ else if (h < 18) key = "greet_zh_3";
6320
+ else if (h < 23) key = "greet_zh_4";
6321
+ else key = "greet_zh_5";
6322
+ } else if (h < 5) key = "greet_en_0";
6323
+ else if (h < 12) key = "greet_en_1";
6324
+ else if (h < 18) key = "greet_en_2";
6325
+ else key = "greet_en_3";
6326
+ return this._t(key, { name }); },
6027
6327
 
6028
6328
  composerLanguage() {
6029
- // Match interface language to user prefs / browser locale; CJK
6030
- // browsers see Chinese composer copy.
6031
6329
  try {
6032
- const lang = (this.prefs?.locale || navigator.language || "").toLowerCase();
6033
- if (lang.startsWith("zh") || lang.includes("cn") || lang.includes("hans") || lang.includes("hant")) return "zh";
6330
+ if (window.I18n && typeof window.I18n.getLocale === "function") {
6331
+ return window.I18n.getLocale() === "zh" ? "zh" : "en";
6332
+ }
6333
+ } catch { /* ignore */ }
6334
+ try {
6335
+ const lang = (navigator.language || "").toLowerCase();
6336
+ if (lang.startsWith("zh") || lang.includes("cn") || lang.includes("hans") || lang.includes("hant")) {
6337
+ return "zh";
6338
+ }
6034
6339
  } catch { /* ignore */ }
6035
6340
  return "en";
6036
6341
  },
6037
6342
 
6343
+ /** UI copy from i18n.js · falls back to key if I18n not loaded. */
6344
+ _t(key, vars) {
6345
+ return (window.I18n && window.I18n.t(key, vars)) || key;
6346
+ },
6347
+
6038
6348
  autosizeComposerTextarea() {
6039
6349
  const ta = document.querySelector("[data-composer-subject]");
6040
6350
  if (!ta) return;
@@ -6135,15 +6445,14 @@
6135
6445
  } catch { /* ignore */ }
6136
6446
  },
6137
6447
 
6138
- /** True when the global Brave Search key is configured · gates the
6139
- * websearch toggle's "on" state. Reads through window.boardroomKeys
6140
- * (cached on boot, refetched after any /api/keys mutation), so the
6141
- * composer reflects the current key state without its own fetch. */
6142
- agentComposerBraveConfigured() {
6448
+ /** True when a Web Search backend key is configured · gates the
6449
+ * websearch toggle's "on" state (Brave Search and/or Tavily). Reads
6450
+ * through window.boardroomKeys · refetched after /api/keys mutations. */
6451
+ agentComposerWebSearchConfigured() {
6143
6452
  try {
6144
6453
  if (typeof window.boardroomKeys !== "function") return false;
6145
6454
  const k = window.boardroomKeys();
6146
- return !!(k && k.brave);
6455
+ return !!(k && (k.brave || k.tavily));
6147
6456
  } catch { return false; }
6148
6457
  },
6149
6458
 
@@ -6152,7 +6461,7 @@
6152
6461
  * configuring the key, no reason to default OFF) and FALSE when
6153
6462
  * not configured (no point pretending it's on). */
6154
6463
  loadAgentComposerWebSearch() {
6155
- const configured = this.agentComposerBraveConfigured();
6464
+ const configured = this.agentComposerWebSearchConfigured();
6156
6465
  if (!configured) return false;
6157
6466
  try {
6158
6467
  const raw = localStorage.getItem("boardroom.composer.agent.websearch");
@@ -6168,6 +6477,15 @@
6168
6477
  } catch { /* ignore */ }
6169
6478
  },
6170
6479
 
6480
+ /** Public lookup for the human-friendly label of a model id.
6481
+ * Mirrors `MODEL_LABELS[modelV] || modelV`, exposed so callers
6482
+ * outside this IIFE (e.g. new-agent.js's overlay) don't have to
6483
+ * reach into module-scoped state. */
6484
+ modelLabel(modelV) {
6485
+ if (!modelV) return "";
6486
+ return MODEL_LABELS[modelV] || modelV;
6487
+ },
6488
+
6171
6489
  setAgentComposerModel(modelV) {
6172
6490
  if (!modelV) return;
6173
6491
  // Accept any modelV the registry knows about (MODEL_LABELS) so
@@ -6177,9 +6495,16 @@
6177
6495
  if (!MODEL_LABELS[modelV]) return;
6178
6496
  this.agentComposerModel = modelV;
6179
6497
  this.saveAgentComposerModel();
6180
- // Update the dropdown trigger label in place.
6181
- const v = document.querySelector('[data-cmp-dd-value="agent-model"]');
6182
- if (v) v.textContent = MODEL_LABELS[modelV] || modelV;
6498
+ // Update every dropdown trigger label in place. There can be
6499
+ // more than one mounted at once (the AI composer's toolbar
6500
+ // chip + the manual new-agent overlay's foot chip), and they
6501
+ // share the same model state, so all instances should reflect
6502
+ // the new pick. Picking one out via querySelector() updated
6503
+ // only the first; querySelectorAll keeps them in sync.
6504
+ const label = MODEL_LABELS[modelV] || modelV;
6505
+ document.querySelectorAll('[data-cmp-dd-value="agent-model"]').forEach((v) => {
6506
+ v.textContent = label;
6507
+ });
6183
6508
  },
6184
6509
 
6185
6510
  /** Click handler for the agent-composer starter list. Drops the
@@ -6285,8 +6610,8 @@
6285
6610
  const stages = this.AGENT_GEN_STAGES_EN;
6286
6611
  const active = this.agentGenStageIndex;
6287
6612
  const elapsed = Math.max(0, (Date.now() - this.agentGenStartedAt) / 1000);
6288
- const elapsedLabel = `${Math.round(elapsed)} s elapsed`;
6289
- const headerLabel = "Summoning director";
6613
+ const elapsedLabel = this._t("ag_gen_elapsed", { n: Math.round(elapsed) });
6614
+ const headerLabel = this._t("ag_gen_header");
6290
6615
  const sigilSvg = this.renderAgentGenSigilSvg(stages, active, elapsed);
6291
6616
  // The active stage's headline pulse — lifted out from the list so
6292
6617
  // it reads as the focal "what's happening RIGHT NOW" line.
@@ -6300,11 +6625,12 @@
6300
6625
  <span class="ag-gen-mark"><span class="ag-gen-pulse"></span></span>
6301
6626
  <span class="ag-gen-title">${this.escape(headerLabel)}</span>
6302
6627
  <span class="ag-gen-elapsed">${this.escape(elapsedLabel)}</span>
6628
+ <button type="button" class="ag-gen-stop" data-agent-spec-stop title="Stop generating">[ Stop ]</button>
6303
6629
  </div>
6304
6630
  <div class="ag-gen-stage-area">
6305
6631
  <div class="ag-gen-sigil" aria-hidden="true">${sigilSvg}</div>
6306
6632
  <div class="ag-gen-active-block">
6307
- <div class="ag-gen-active-kicker">${this.escape(`step ${active + 1} of ${stages.length}`)}</div>
6633
+ <div class="ag-gen-active-kicker">${this.escape(this._t("ag_gen_step", { current: active + 1, total: stages.length }))}</div>
6308
6634
  <div class="ag-gen-active-label">${this.escape(activeStage ? activeStage.label : "")}</div>
6309
6635
  ${activeSubText ? `<div class="ag-gen-active-sub">${this.escape(activeSubText)}</div>` : ""}
6310
6636
  </div>
@@ -6430,21 +6756,18 @@
6430
6756
  const userName = (this.prefs?.name || "you").trim() || "you";
6431
6757
  const lang = this.composerLanguage();
6432
6758
  const greeting = this.composerGreeting(lang, userName);
6433
- // System UI · always English (new-agent composer chrome).
6434
6759
  const t = {
6435
6760
  greet: greeting,
6436
- prompt: "What kind of director do you want?",
6437
- 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.",
6438
- cta: "Generate",
6439
- ctaHint: "AI drafts the full spec — you'll edit before saving",
6440
- manual: "Configure manually",
6441
- generating: "Generating…",
6442
- modelLabel: "model",
6443
- starterCaption: "or start from an archetype",
6444
- };
6761
+ prompt: this._t("ag_cmp_prompt"),
6762
+ placeholder: this._t("ag_cmp_placeholder"),
6763
+ cta: this._t("ag_cmp_cta"),
6764
+ ctaHint: this._t("ag_cmp_cta_hint"),
6765
+ manual: this._t("ag_cmp_manual"),
6766
+ generating: this._t("ag_cmp_generating"),
6767
+ starterCaption: this._t("ag_cmp_starter_caption"), };
6445
6768
  // If we already have a spec preview, render that instead of the input.
6446
6769
  if (this.agentSpec) {
6447
- return this.renderAgentSpecPreviewHtml(this.agentSpec, lang);
6770
+ return this.renderAgentSpecPreviewHtml(this.agentSpec);
6448
6771
  }
6449
6772
  // If the last attempt failed (timeout or other), render the
6450
6773
  // recovery card with [Retry] / [Discard] · keeps the description
@@ -6453,8 +6776,6 @@
6453
6776
  return this.renderAgentSpecErrorHtml(this.agentSpecError, lang);
6454
6777
  }
6455
6778
  const generating = this.agentSpecGenerating;
6456
- const currentModel = this.loadAgentComposerModel();
6457
- const modelDisplay = MODEL_LABELS[currentModel] || currentModel;
6458
6779
  const starters = this.AGENT_STARTERS_EN;
6459
6780
  const starterCards = starters.map((q, idx) => `
6460
6781
  <button type="button" class="cmp-starter" data-agent-starter="${idx}">
@@ -6474,11 +6795,13 @@
6474
6795
  <textarea class="cmp-input" data-agent-composer-desc rows="1" placeholder="${this.escape(t.placeholder)}" ${generating ? "disabled" : ""}>${this.escape(this.loadAgentComposerDraft())}</textarea>
6475
6796
 
6476
6797
  <div class="cmp-toolbar">
6477
- <button type="button" class="cmp-dd" data-cmp-dropdown="agent-model" title="${this.escape(t.modelLabel)}">
6478
- <span class="cmp-dd-label">${this.escape(t.modelLabel)}</span>
6479
- <span class="cmp-dd-value" data-cmp-dd-value="agent-model">${this.escape(modelDisplay)}</span>
6480
- <span class="cmp-dd-chevron">▾</span>
6481
- </button>
6798
+ <!-- Model selection lives downstream now: the post-
6799
+ generation spec preview card has a model <select>
6800
+ (renderAgentSpecPreviewHtml), the manual new-agent
6801
+ overlay has its own model picker (new-agent.js), and
6802
+ the agent profile page can change the model at any
6803
+ time. Putting it in the composer toolbar too was
6804
+ redundant and made the bar visually noisy. -->
6482
6805
  <button type="button" class="ag-cmp-manual" data-agent-composer-manual>
6483
6806
  <span class="ag-cmp-manual-mark">⚙</span>
6484
6807
  <span class="ag-cmp-manual-label">${this.escape(t.manual)}</span>
@@ -6489,16 +6812,19 @@
6489
6812
  // same control. Class set: `ap-skill-row-toggle` +
6490
6813
  // `on` / `off` / `needs-key` modifiers, mirroring the
6491
6814
  // skill row at agent-profile.js:1411.
6492
- const configured = this.agentComposerBraveConfigured();
6815
+ const configured = this.agentComposerWebSearchConfigured();
6493
6816
  const on = configured && this.loadAgentComposerWebSearch();
6494
- // System UI · always English (web-search toggle chrome).
6495
- const stateLabel = !configured ? "needs key" : on ? "enabled" : "disabled";
6817
+ const stateLabel = !configured
6818
+ ? this._t("ag_ws_needs_key")
6819
+ : on
6820
+ ? this._t("ag_ws_enabled")
6821
+ : this._t("ag_ws_disabled");
6496
6822
  const titleText = !configured
6497
- ? "Web search needs a Brave Search API key · click to configure"
6823
+ ? this._t("ag_ws_title_needs")
6498
6824
  : on
6499
- ? "Search the web for real domain references during generation · click to disable"
6500
- : "Generation runs offline · click to enable web search";
6501
- const wsLabel = "web search";
6825
+ ? this._t("ag_ws_title_on")
6826
+ : this._t("ag_ws_title_off");
6827
+ const wsLabel = this._t("ag_ws_label");
6502
6828
  const cls = [
6503
6829
  "ap-skill-row-toggle",
6504
6830
  "cmp-ws-toggle",
@@ -6518,7 +6844,12 @@
6518
6844
  `;
6519
6845
  })()}
6520
6846
  <button type="button" class="cmp-go ${generating ? "busy" : ""}" data-agent-composer-go title="${this.escape(t.cta)} (⏎)" ${generating ? "disabled" : ""}>
6521
- <span class="cmp-go-arrow">${generating ? "…" : "→"}</span>
6847
+ ${generating
6848
+ ? `<span class="cmp-go-arrow">…</span>`
6849
+ : `<svg class="cmp-go-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
6850
+ <path d="M5 12h14"/>
6851
+ <path d="m13 5 7 7-7 7"/>
6852
+ </svg>`}
6522
6853
  </button>
6523
6854
  </div>
6524
6855
  </div>
@@ -6544,24 +6875,7 @@
6544
6875
  },
6545
6876
 
6546
6877
  /** Preview card · all generated fields editable inline. */
6547
- renderAgentSpecPreviewHtml(spec, _lang) {
6548
- // System UI · always English (agent-spec preview card chrome).
6549
- const t = {
6550
- kicker: "// generated director · edit and save",
6551
- avatar: "Avatar",
6552
- reroll: "Reroll",
6553
- name: "Name",
6554
- handle: "Handle",
6555
- role: "Role tag",
6556
- bio: "Bio",
6557
- quote: "Cover quote",
6558
- instruction: "Instruction",
6559
- model: "Model",
6560
- save: "Save director",
6561
- discard: "Discard",
6562
- redo: "Regenerate",
6563
- };
6564
- const seed = this.agentSpecAvatarSeed;
6878
+ renderAgentSpecPreviewHtml(spec) { const seed = this.agentSpecAvatarSeed;
6565
6879
  const avatarSvg = (window.AvatarSkill && seed)
6566
6880
  ? window.AvatarSkill.generate(seed, { size: 96 })
6567
6881
  : `<div class="ag-prev-av-empty">—</div>`;
@@ -6590,19 +6904,19 @@
6590
6904
  const abilitySvg = this.renderAgentSpecRadarSvg(spec.ability || {});
6591
6905
  return `
6592
6906
  <section class="cmp ag-cmp ag-prev-mode">
6593
- <div class="ag-prev-kicker">${this.escape(t.kicker)}</div>
6907
+ <div class="ag-prev-kicker">${this.escape(this._t("ag_preview_kicker"))}</div>
6594
6908
 
6595
6909
  <div class="ag-prev-card">
6596
6910
  <header class="ag-prev-head">
6597
6911
  <div class="ag-prev-identity">
6598
- <button type="button" class="ag-prev-av" data-agent-spec-reroll title="${this.escape(t.reroll)}">
6912
+ <button type="button" class="ag-prev-av" data-agent-spec-reroll title="${this.escape(this._t("ag_preview_reroll"))}">
6599
6913
  <div class="ag-prev-av-frame">${avatarSvg}</div>
6600
6914
  <span class="ag-prev-av-reroll-mark">↻</span>
6601
6915
  </button>
6602
6916
  <div class="ag-prev-id-fields">
6603
- <input type="text" class="ag-prev-name" data-agent-spec-field="name" maxlength="32" value="${this.escape(spec.name)}" placeholder="${this.escape(t.name)}">
6917
+ <input type="text" class="ag-prev-name" data-agent-spec-field="name" maxlength="32" value="${this.escape(spec.name)}" placeholder="${this.escape(this._t("ag_preview_name"))}">
6604
6918
  <div class="ag-prev-id-meta">
6605
- <input type="text" class="ag-prev-roletag" data-agent-spec-field="roleTag" maxlength="32" value="${this.escape(spec.roleTag)}" placeholder="${this.escape(t.role)}">
6919
+ <input type="text" class="ag-prev-roletag" data-agent-spec-field="roleTag" maxlength="32" value="${this.escape(spec.roleTag)}" placeholder="${this.escape(this._t("ag_preview_role"))}">
6606
6920
  <span class="ag-prev-meta-sep">·</span>
6607
6921
  <select class="ag-prev-model" data-agent-spec-field="modelV">${modelOpts}</select>
6608
6922
  </div>
@@ -6613,27 +6927,27 @@
6613
6927
 
6614
6928
  <div class="ag-prev-body">
6615
6929
  <label class="ag-prev-field">
6616
- <span class="ag-prev-label">${this.escape(t.bio)}</span>
6930
+ <span class="ag-prev-label">${this.escape(this._t("ag_preview_bio"))}</span>
6617
6931
  <textarea class="ag-prev-input ag-prev-textarea" data-agent-spec-field="bio" maxlength="280" rows="2">${this.escape(spec.bio)}</textarea>
6618
6932
  </label>
6619
6933
 
6620
6934
  <label class="ag-prev-field">
6621
- <span class="ag-prev-label">${this.escape(t.quote)}</span>
6935
+ <span class="ag-prev-label">${this.escape(this._t("ag_preview_quote"))}</span>
6622
6936
  <textarea class="ag-prev-input ag-prev-textarea" data-agent-spec-field="coverQuote" maxlength="200" rows="2">${this.escape(spec.coverQuote || "")}</textarea>
6623
6937
  </label>
6624
6938
 
6625
6939
  <label class="ag-prev-field">
6626
- <span class="ag-prev-label">${this.escape(t.instruction)}</span>
6940
+ <span class="ag-prev-label">${this.escape(this._t("ag_preview_instruction"))}</span>
6627
6941
  <textarea class="ag-prev-input ag-prev-textarea ag-prev-instr" data-agent-spec-field="instruction" maxlength="4000" rows="10">${this.escape(spec.instruction)}</textarea>
6628
6942
  </label>
6629
6943
  </div>
6630
6944
 
6631
6945
  <footer class="ag-prev-foot">
6632
- <button type="button" class="ag-prev-discard" data-agent-spec-discard>${this.escape(t.discard)}</button>
6633
- <button type="button" class="ag-prev-redo" data-agent-spec-redo>↻ ${this.escape(t.redo)}</button>
6946
+ <button type="button" class="ag-prev-discard" data-agent-spec-discard>${this.escape(this._t("ag_preview_discard"))}</button>
6947
+ <button type="button" class="ag-prev-redo" data-agent-spec-redo>↻ ${this.escape(this._t("ag_preview_redo"))}</button>
6634
6948
  <button type="button" class="ag-prev-save" data-agent-spec-save>
6635
6949
  <span class="ag-prev-save-mark">◆</span>
6636
- <span>${this.escape(t.save)}</span>
6950
+ <span>${this.escape(this._t("ag_preview_save"))}</span>
6637
6951
  </button>
6638
6952
  </footer>
6639
6953
  </div>
@@ -6647,43 +6961,35 @@
6647
6961
  * composer back to its input state). The description text is
6648
6962
  * surfaced read-only so the user can copy it before discard. */
6649
6963
  renderAgentSpecErrorHtml(err, lang) {
6650
- // System UI · always English (agent-spec error card chrome).
6651
- const t = {
6652
- kicker: err.kind === "timeout" ? "// generation timed out" : "// generation failed",
6653
- title: err.kind === "timeout" ? "Generation didn't complete after 5 minutes" : "Generation failed",
6654
- hintTimeout: "The model may be slow, the network flaky, or the backend pipeline stalled. Click retry to start a fresh run.",
6655
- hintFailed: "Check your API key configuration and model reachability. Retry often clears transient failures.",
6656
- descLabel: "Your description (re-used on retry)",
6657
- retry: "Retry",
6658
- discard: "Discard",
6659
- };
6964
+ const kicker = err.kind === "timeout" ? this._t("ag_err_kicker_timeout") : this._t("ag_err_kicker_fail");
6965
+ const title = err.kind === "timeout" ? this._t("ag_err_title_timeout") : this._t("ag_err_title_fail");
6966
+ const hint = err.kind === "timeout" ? this._t("ag_err_hint_timeout") : this._t("ag_err_hint_fail");
6660
6967
  const desc = this._agentComposerLastDesc || "";
6661
- const hint = err.kind === "timeout" ? t.hintTimeout : t.hintFailed;
6662
6968
  const detail = err.message ? `<div class="ag-gen-error-detail">${this.escape(err.message)}</div>` : "";
6663
6969
  return `
6664
6970
  <section class="cmp ag-cmp">
6665
6971
  <header class="cmp-hero">
6666
6972
  <div class="cmp-greet">${this.escape(this.composerGreeting(lang, (this.prefs?.name || "you").trim() || "you"))}</div>
6667
- <h1 class="cmp-prompt">${this.escape("What kind of director do you want?")}</h1>
6973
+ <h1 class="cmp-prompt">${this.escape(this._t("ag_err_prompt"))}</h1>
6668
6974
  </header>
6669
6975
  <div class="ag-gen-error-card">
6670
- <div class="ag-gen-error-kicker">${this.escape(t.kicker)}</div>
6671
- <h2 class="ag-gen-error-title">${this.escape(t.title)}</h2>
6976
+ <div class="ag-gen-error-kicker">${this.escape(kicker)}</div>
6977
+ <h2 class="ag-gen-error-title">${this.escape(title)}</h2>
6672
6978
  <p class="ag-gen-error-hint">${this.escape(hint)}</p>
6673
6979
  ${detail}
6674
6980
  ${desc ? `
6675
6981
  <div class="ag-gen-error-desc">
6676
- <div class="ag-gen-error-desc-label">${this.escape(t.descLabel)}</div>
6982
+ <div class="ag-gen-error-desc-label">${this.escape(this._t("ag_err_desc"))}</div>
6677
6983
  <div class="ag-gen-error-desc-body">${this.escape(desc)}</div>
6678
6984
  </div>
6679
6985
  ` : ""}
6680
6986
  <div class="ag-gen-error-actions">
6681
6987
  <button type="button" class="ag-gen-error-retry" data-agent-spec-retry>
6682
6988
  <span class="ag-gen-error-retry-mark">↻</span>
6683
- <span>${this.escape(t.retry)}</span>
6989
+ <span>${this.escape(this._t("ag_err_retry"))}</span>
6684
6990
  </button>
6685
6991
  <button type="button" class="ag-gen-error-discard" data-agent-spec-error-discard>
6686
- ${this.escape(t.discard)}
6992
+ ${this.escape(this._t("ag_err_discard"))}
6687
6993
  </button>
6688
6994
  </div>
6689
6995
  </div>
@@ -6726,7 +7032,7 @@
6726
7032
  return `<text x="${lx.toFixed(1)}" y="${(ly + 2.5).toFixed(1)}" text-anchor="${anchor}" class="ag-prev-radar-axis-label">${labels[a]}</text>`;
6727
7033
  }).join("");
6728
7034
  return `
6729
- <svg class="ag-prev-radar-svg" viewBox="0 0 ${vbW} ${vbH}" xmlns="http://www.w3.org/2000/svg" aria-label="Ability radar">
7035
+ <svg class="ag-prev-radar-svg" viewBox="0 0 ${vbW} ${vbH}" xmlns="http://www.w3.org/2000/svg" aria-label="${this.escape(this._t("ag_preview_radar_aria"))}">
6730
7036
  ${grid}
6731
7037
  ${spokes}
6732
7038
  <polygon points="${curPoly}" class="ag-prev-radar-current"/>
@@ -7136,7 +7442,7 @@
7136
7442
  <span class="composer-pick-title">${this.escape(t.title)}</span>
7137
7443
  <span class="composer-pick-hint">${this.escape(t.hint)}</span>
7138
7444
  </div>
7139
- <div class="composer-pick-list">${rows || `<div class="composer-pick-empty">no directors</div>`}</div>
7445
+ <div class="composer-pick-list">${rows || `<div class="composer-pick-empty">${this.escape(this._t("picker_no_directors"))}</div>`}</div>
7140
7446
  <div class="composer-pick-foot">
7141
7447
  <button type="button" class="composer-pick-done" data-composer-pick-done>${this.escape(t.done)}</button>
7142
7448
  </div>
@@ -7294,6 +7600,26 @@
7294
7600
  if (v) v.textContent = intensity;
7295
7601
  },
7296
7602
 
7603
+ setComposerDeliveryMode(deliveryMode) {
7604
+ const state = this.loadComposerState();
7605
+ state.deliveryMode = deliveryMode === "voice" ? "voice" : "text";
7606
+ this.saveComposerState();
7607
+ // Sync the button-style toggle's classes / aria / data attrs /
7608
+ // visible text. Was a checkbox previously (cb.checked = ...);
7609
+ // the new register uses .ap-skill-row-toggle so we mirror what
7610
+ // the click handler does for the in-place mutation case.
7611
+ const btn = document.querySelector("[data-composer-voice-toggle]");
7612
+ if (btn) {
7613
+ const on = state.deliveryMode === "voice";
7614
+ btn.classList.toggle("on", on);
7615
+ btn.classList.toggle("off", !on);
7616
+ btn.setAttribute("data-on", on ? "1" : "0");
7617
+ btn.setAttribute("aria-pressed", on ? "true" : "false");
7618
+ const txt = btn.querySelector(".ap-skill-row-toggle-text");
7619
+ if (txt) txt.textContent = this._t("cmp_voice_label");
7620
+ }
7621
+ },
7622
+
7297
7623
  /** Generic option-list dropdown anchored under a tune trigger
7298
7624
  * button. Used for both tone and intensity. Each option is a
7299
7625
  * full-text row with a short hint (current/calmer/etc). Click an
@@ -7345,6 +7671,28 @@
7345
7671
  } else {
7346
7672
  current = state.intensity;
7347
7673
  }
7674
+ } else if (kind === "delivery") {
7675
+ opts = lang === "zh"
7676
+ ? [
7677
+ { v: "text", label: "Text", hint: "最快的文字会议" },
7678
+ { v: "voice", label: "Voice", hint: "口语化 · 逐位播放" },
7679
+ ]
7680
+ : [
7681
+ { v: "text", label: "Text", hint: "fast written meeting" },
7682
+ { v: "voice", label: "Voice", hint: "spoken · paced turns" },
7683
+ ];
7684
+ current = state.deliveryMode === "voice" ? "voice" : "text";
7685
+ } else if (kind === "locale") {
7686
+ // Interface language picker · routes through the shared
7687
+ // .cmp-dd dropdown vocabulary so the User-settings pane
7688
+ // doesn't ship a separate two-button toggle. Hints describe
7689
+ // what the locale switch affects so the user knows it's the
7690
+ // chrome language, not the brief language.
7691
+ opts = [
7692
+ { v: "en", label: "EN", hint: "interface in english" },
7693
+ { v: "zh", label: "中文", hint: "界面语言切到中文" },
7694
+ ];
7695
+ current = (window.I18n && window.I18n.getLocale && window.I18n.getLocale()) || "en";
7348
7696
  } else if (kind === "agent-model") {
7349
7697
  // Reachable-only model catalog · pulls from the shared
7350
7698
  // /api/models cache so the picker reflects the user's
@@ -7471,6 +7819,11 @@
7471
7819
  if (ta) ta.focus();
7472
7820
  return;
7473
7821
  }
7822
+ // Unlock audio playback on this user gesture — required by
7823
+ // browser autoplay policies before we can play TTS audio later.
7824
+ if (state.deliveryMode === "voice") {
7825
+ this.unlockAudioPlayback();
7826
+ }
7474
7827
  // Pre-flight · convening triggers chair convening + auto-pick +
7475
7828
  // clarify, all of which need a model key. Bail early and prompt
7476
7829
  // the user to configure one if missing.
@@ -7492,6 +7845,7 @@
7492
7845
  agentIds: useAutoPick ? [] : state.directorIds.slice(),
7493
7846
  mode: state.mode,
7494
7847
  intensity: state.intensity,
7848
+ deliveryMode: state.deliveryMode,
7495
7849
  autoPick: useAutoPick,
7496
7850
  });
7497
7851
  // Clear the saved draft now that the room is convened — next
@@ -7612,6 +7966,15 @@
7612
7966
  }
7613
7967
  },
7614
7968
 
7969
+ /** Tone hover copy · i18n keyed by mode, falls back to TONE_TIPS. */
7970
+ toneTipFor(mode) {
7971
+ const m = mode || "";
7972
+ const k = "tone_tip_" + m;
7973
+ const tr = this._t(k);
7974
+ if (tr !== k) return tr;
7975
+ return TONE_TIPS[m] || "";
7976
+ },
7977
+
7615
7978
  renderHeader() {
7616
7979
  const head = document.querySelector("[data-room-head]");
7617
7980
  if (!head || !this.currentRoom) return;
@@ -7627,23 +7990,33 @@
7627
7990
  .map((a) => `<img data-agent="${this.escape(a.id)}" src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" title="${this.escape(a.name)}">`)
7628
7991
  .join("");
7629
7992
  const castCount = this.currentMembers.length;
7993
+ const castTitle =
7994
+ castCount <= 1
7995
+ ? this._t("room_cast_title_1")
7996
+ : this._t("room_cast_n", { n: castCount });
7630
7997
  const castHtml = castImgs +
7631
7998
  (castCount > 0
7632
- ? `<span class="cast-count" title="${castCount} director${castCount === 1 ? "" : "s"}">${castCount}</span>`
7999
+ ? `<span class="cast-count" title="${this.escape(castTitle)}">${castCount}</span>`
7633
8000
  : "");
7634
8001
 
7635
8002
  const tone = r.mode || "constructive";
7636
8003
  const intensity = r.intensity || "sharp";
7637
-
7638
- // Status word for the kicker · the coloured glyph (●/❚❚/▣) is
7639
- // drawn via CSS `::before` on `.kicker-status.status-{state}`
7640
- // so all the styling lives next to the colour rule.
7641
- const statusWord = (
7642
- r.status === "paused" ? "PAUSED" :
7643
- r.status === "adjourned" ? "ADJOURNED" :
7644
- r.status === "live" ? "LIVE" :
7645
- ""
7646
- );
8004
+ const briefStyle =
8005
+ typeof r.briefStyle === "string" && r.briefStyle.trim()
8006
+ ? r.briefStyle.trim()
8007
+ : "auto";
8008
+
8009
+ // Status timestamp — what was on the right (paused-stamp / stamp) now
8010
+ // lives inline in the meta row.
8011
+ let stamp = "";
8012
+ if (r.status === "paused" && r.pausedAt) {
8013
+ stamp = this._t("room_stamp_paused", { ago: this.relTime(r.pausedAt) });
8014
+ } else if (r.status === "adjourned" && r.adjournedAt) {
8015
+ stamp = this._t("room_stamp_adjourned", { ago: this.relTime(r.adjournedAt) });
8016
+ } else if (r.status === "live" && r.createdAt) {
8017
+ stamp = this._t("room_stamp_opened", { ago: this.relTime(r.createdAt) });
8018
+ }
8019
+ const toneTip = this.toneTipFor(tone);
7647
8020
 
7648
8021
  // Three primary actions, one per state — CSS hides the wrong ones based
7649
8022
  // on html[data-status]. Per PRD §5.2.3:
@@ -7662,13 +8035,23 @@
7662
8035
  // tagged-meta-pills) — net height reduction ~150 → ~85px.
7663
8036
  // Tone / intensity / brief-style remain editable in the room
7664
8037
  // settings overlay; only their on-header surface is collapsed.
8038
+ // Compact two-row layout · kicker carries all the meta (room
8039
+ // number / tone / intensity / status) on a single mono line
8040
+ // above the subject. Replaces the prior three-row stack of
8041
+ // `.room-id` + subject + `.room-meta` tagged pills which the
8042
+ // CSS in index.html no longer styles. Tone / intensity stay
8043
+ // editable in the room-settings overlay.
8044
+ // System chrome is English-only per the project rule (only
8045
+ // user / LLM content follows query language), so the kicker
8046
+ // tokens are not routed through `_t()`.
8047
+ const statusWord = r.status !== "live" ? String(r.status).toUpperCase() : "";
7665
8048
  head.innerHTML = `
7666
- <button type="button" class="room-head-expand" data-sidebar-expand title="Expand sidebar" aria-label="Expand sidebar"></button>
8049
+ <button type="button" class="room-head-expand" data-sidebar-expand title="${this.escape(this._t("sidebar_expand"))}" aria-label="${this.escape(this._t("sidebar_expand"))}"></button>
7667
8050
  <div class="room-info">
7668
8051
  <div class="room-kicker">
7669
8052
  <span class="kicker-num">// ROOM #${r.number}</span>
7670
8053
  <span class="kicker-sep">·</span>
7671
- <span class="kicker-tone" data-tone-tip="${this.escape(TONE_TIPS[tone] || "")}">${this.escape(tone.toUpperCase())}</span>
8054
+ <span class="kicker-tone" data-tone-tip="${this.escape(toneTip)}">${this.escape(tone.toUpperCase())}</span>
7672
8055
  <span class="kicker-sep">·</span>
7673
8056
  <span class="kicker-intensity">${this.escape(intensity.toUpperCase())}</span>
7674
8057
  ${statusWord ? `<span class="kicker-sep">·</span><span class="kicker-status status-${this.escape(r.status)}">${statusWord}</span>` : ""}
@@ -7677,9 +8060,9 @@
7677
8060
  </div>
7678
8061
  <div class="head-actions">
7679
8062
  <div class="head-cast">${castHtml}</div>
7680
- <a href="#" class="room-settings-trigger" data-room-settings-trigger title="Room settings" aria-label="Room settings">⚙</a>
7681
- <a href="#" class="pause-btn" data-pause>[ <span class="pause-icon">❚❚</span> Pause ]</a>
7682
- <a href="#" class="resume-btn" data-resume>[ ▶ Resume ]</a>
8063
+ <a href="#" class="room-settings-trigger" data-room-settings-trigger title="${this.escape(this._t("room_settings"))}" aria-label="${this.escape(this._t("room_settings"))}">⚙</a>
8064
+ <a href="#" class="pause-btn" data-pause>[ <span class="pause-icon">❚❚</span> ${this.escape(this._t("room_pause_verb"))} ]</a>
8065
+ <a href="#" class="resume-btn" data-resume>[ ▶ ${this.escape(this._t("room_resume_verb"))} ]</a>
7683
8066
  ${this.currentBrief
7684
8067
  ? (() => {
7685
8068
  // Multiple briefs · render the View Report button as a
@@ -7692,13 +8075,12 @@
7692
8075
  const multi = briefs.length > 1;
7693
8076
  const directHref = this.briefViewerHref(this.currentBrief, r.id) || `/report.html?r=${this.escape(r.id)}`;
7694
8077
  if (!multi) {
7695
- return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report>[ View Report ]</a>`;
8078
+ return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report>${this.escape(this._t("room_view_report"))}</a>`;
7696
8079
  }
7697
- 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>`;
8080
+ return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report data-view-report-trigger title="${this.escape(this._t("room_view_report_title_multi", { n: briefs.length }))}">${this.escape(this._t("room_view_report_multi", { n: briefs.length }))}</a>`;
7698
8081
  })()
7699
8082
  : (r.status === "adjourned"
7700
- ? `<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>`
7701
- : "")}
8083
+ ? `<a href="#" class="view-report-btn generate-report" data-generate-brief title="${this.escape(this._t("room_generate_report_title"))}"><span class="vr-mark">▸</span> ${this.escape(this._t("room_generate_report"))}</a>` : "")}
7702
8084
  </div>
7703
8085
  `;
7704
8086
  // Wire the tone-tag hover tip. Pure-CSS ::after tooltips were
@@ -7763,8 +8145,13 @@
7763
8145
  }
7764
8146
  const messages = this.currentMessages.slice();
7765
8147
  const r = this.currentRoom;
8148
+ const tBanner = this._t("chat_banner", {
8149
+ when: new Date(r.createdAt).toLocaleString(),
8150
+ n: this.currentMembers.length,
8151
+ mode: this.escape(r.mode),
8152
+ });
7766
8153
  const banner = r
7767
- ? `<div class="chat-banner"><span class="chat-banner-chip"><span class="cb-mark">▸</span><span class="cb-text">room opened · ${new Date(r.createdAt).toLocaleString()} · ${this.currentMembers.length} directors · ${this.escape(r.mode)}</span></span></div>`
8154
+ ? `<div class="chat-banner"><span class="chat-banner-chip"><span class="cb-mark">▸</span><span class="cb-text">${this.escape(tBanner)}</span></span></div>`
7768
8155
  : "";
7769
8156
  const openerId = this.firstUserMessageId();
7770
8157
  // Convening card · appended at the tail of the chat while the
@@ -7795,9 +8182,18 @@
7795
8182
  ? ["analyzing", "seating", "preparing"]
7796
8183
  : ["preparing"];
7797
8184
  const STAGE_LABELS = {
7798
- analyzing: { title: "Analyzing topic", deck: "Routing your topic to the right perspectives" },
7799
- seating: { title: "Seating directors", deck: "Picking the right perspectives for this question" },
7800
- preparing: { title: "Chair preparing remarks", deck: "Drafting the convening speech" },
8185
+ analyzing: {
8186
+ title: this._t("conv_stage_analyzing_title"),
8187
+ deck: this._t("conv_stage_analyzing_deck"),
8188
+ },
8189
+ seating: {
8190
+ title: this._t("conv_stage_seating_title"),
8191
+ deck: this._t("conv_stage_seating_deck"),
8192
+ },
8193
+ preparing: {
8194
+ title: this._t("conv_stage_preparing_title"),
8195
+ deck: this._t("conv_stage_preparing_deck"),
8196
+ },
7801
8197
  };
7802
8198
 
7803
8199
  const currentIdx = stageOrder.indexOf(s.stage);
@@ -7829,8 +8225,7 @@
7829
8225
  // already in currentMembers; rendering it here would be redundant).
7830
8226
  const seatedRow = (s.autoPicked && s.seated.length > 0) ? `
7831
8227
  <div class="conv-seated">
7832
- <div class="conv-seated-label">seated · ${s.seated.length}</div>
7833
- <div class="conv-seated-list">
8228
+ <div class="conv-seated-label">${this.escape(this._t("conv_seated_label"))} · ${s.seated.length}</div> <div class="conv-seated-list">
7834
8229
  ${s.seated.map((a) => `
7835
8230
  <div class="conv-seated-item" data-agent-id="${this.escape(a.id)}">
7836
8231
  <img class="conv-seated-av" src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}">
@@ -7846,8 +8241,7 @@
7846
8241
 
7847
8242
  return `
7848
8243
  <article class="convening-card" data-convene-card>
7849
- <div class="conv-eyebrow">▸ CONVENING</div>
7850
- ${s.subject ? `<blockquote class="conv-subject">${this.escape(s.subject)}</blockquote>` : ""}
8244
+ <div class="conv-eyebrow">${this.escape(this._t("conv_banner_convening"))}</div> ${s.subject ? `<blockquote class="conv-subject">${this.escape(s.subject)}</blockquote>` : ""}
7851
8245
  <ul class="conv-stages">${stagesHtml}</ul>
7852
8246
  ${seatedRow}
7853
8247
  </article>
@@ -7883,11 +8277,16 @@
7883
8277
  const chat = document.querySelector("[data-chat-messages]");
7884
8278
  if (!chat) return;
7885
8279
  const existing = chat.querySelector("[data-chair-pending]");
7886
- // System UI · always English (chair-pending placeholder chrome).
7887
- const chairName = (this.currentChair?.name) || "Chair";
7888
- 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" };
7889
- const phraseRaw = labelMap[phase] || labelMap.chair;
7890
- const phrase = `${chairName} ${phraseRaw}…`;
8280
+ const chairName = (this.currentChair?.name) || this._t("msg_chair_display_fallback");
8281
+ const phaseKeys = {
8282
+ clarify: "chair_pend_clarify",
8283
+ "chair-direct": "chair_pend_chair_direct",
8284
+ "round-end": "chair_pend_round_end",
8285
+ convening: "chair_pend_convening",
8286
+ "next-speaker": "chair_pend_next_speaker",
8287
+ };
8288
+ const pendKey = phaseKeys[phase] || "chair_pend_default";
8289
+ const phrase = this._t(pendKey, { name: chairName });
7891
8290
  if (existing) {
7892
8291
  const span = existing.querySelector(".cp-text");
7893
8292
  if (span) span.textContent = phrase;
@@ -7898,7 +8297,7 @@
7898
8297
  node.setAttribute("data-chair-pending", "");
7899
8298
  node.innerHTML = `
7900
8299
  <div class="cp-rule" aria-hidden="true"></div>
7901
- <div class="cp-kicker">▸ chair</div>
8300
+ <div class="cp-kicker">${this.escape(this._t("chair_pend_banner"))}</div>
7902
8301
  <div class="cp-body">
7903
8302
  <span class="cp-text">${this.escape(phrase)}</span>
7904
8303
  <span class="thinking-dots"><span></span><span></span><span></span></span>
@@ -7925,6 +8324,145 @@
7925
8324
  if (existing) existing.remove();
7926
8325
  },
7927
8326
 
8327
+ enqueueVoiceChunk(roomId, chunk) {
8328
+ if (!chunk || !chunk.messageId || !chunk.audioBase64 || !chunk.mimeType) return;
8329
+ let q = this.voiceQueues[chunk.messageId];
8330
+ if (!q) {
8331
+ q = this._createVoiceStream(roomId, chunk.messageId);
8332
+ this.voiceQueues[chunk.messageId] = q;
8333
+ }
8334
+ // Convert base64 to Uint8Array and append to the MSE SourceBuffer
8335
+ const binary = atob(chunk.audioBase64);
8336
+ const bytes = new Uint8Array(binary.length);
8337
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
8338
+ q.pendingBuffers.push(bytes);
8339
+ this._flushVoiceBuffer(q);
8340
+ },
8341
+
8342
+ /** Create a MediaSource-backed audio stream for one speaker's turn. */
8343
+ _createVoiceStream(roomId, messageId) {
8344
+ const audio = new Audio();
8345
+ const ms = new MediaSource();
8346
+ audio.src = URL.createObjectURL(ms);
8347
+
8348
+ const q = {
8349
+ roomId,
8350
+ messageId,
8351
+ audio,
8352
+ mediaSource: ms,
8353
+ sourceBuffer: null,
8354
+ pendingBuffers: [],
8355
+ final: false,
8356
+ doneSent: false,
8357
+ ready: false,
8358
+ };
8359
+
8360
+ ms.addEventListener("sourceopen", () => {
8361
+ try {
8362
+ q.sourceBuffer = ms.addSourceBuffer("audio/mpeg");
8363
+ q.sourceBuffer.addEventListener("updateend", () => this._flushVoiceBuffer(q));
8364
+ q.ready = true;
8365
+ this._flushVoiceBuffer(q);
8366
+ } catch (e) {
8367
+ console.warn("[voice] addSourceBuffer failed:", e);
8368
+ }
8369
+ });
8370
+
8371
+ // Start playing as soon as we have some data
8372
+ audio.play().catch(() => {});
8373
+
8374
+ return q;
8375
+ },
8376
+
8377
+ /** Flush pending buffers into the SourceBuffer one at a time. */
8378
+ _flushVoiceBuffer(q) {
8379
+ if (!q.ready || !q.sourceBuffer || q.sourceBuffer.updating) return;
8380
+ if (q.pendingBuffers.length > 0) {
8381
+ const buf = q.pendingBuffers.shift();
8382
+ try {
8383
+ q.sourceBuffer.appendBuffer(buf);
8384
+ } catch (e) {
8385
+ console.warn("[voice] appendBuffer failed:", e);
8386
+ }
8387
+ return;
8388
+ }
8389
+ // No more pending buffers. If final, signal end of stream.
8390
+ if (q.final && !q.doneSent) {
8391
+ q.doneSent = true;
8392
+ try {
8393
+ if (q.mediaSource.readyState === "open") {
8394
+ q.mediaSource.endOfStream();
8395
+ }
8396
+ } catch (_) {}
8397
+ // Wait for audio to finish playing, then fire voice-done
8398
+ q.audio.addEventListener("ended", () => this._fireVoiceDone(q));
8399
+ // Safety timeout in case 'ended' doesn't fire (e.g. empty stream)
8400
+ const duration = q.audio.duration;
8401
+ const remaining = isFinite(duration) ? (duration - q.audio.currentTime) : 0;
8402
+ setTimeout(() => {
8403
+ if (!this.voiceQueues[q.messageId]) return; // already fired
8404
+ this._fireVoiceDone(q);
8405
+ }, (remaining * 1000) + 2000);
8406
+ }
8407
+ },
8408
+
8409
+ _scheduleVoiceDone(q) {
8410
+ // Called from voice-final handler — trigger flush check which will
8411
+ // endOfStream + wait for playback to finish.
8412
+ this._flushVoiceBuffer(q);
8413
+ },
8414
+
8415
+ _fireVoiceDone(q) {
8416
+ if (!this.voiceQueues[q.messageId]) return; // already fired
8417
+ delete this.voiceQueues[q.messageId];
8418
+ // Clean up audio element
8419
+ try { q.audio.pause(); } catch (_) {}
8420
+ try { URL.revokeObjectURL(q.audio.src); } catch (_) {}
8421
+ fetch(`/api/rooms/${encodeURIComponent(q.roomId)}/messages/${encodeURIComponent(q.messageId)}/voice-done`, {
8422
+ method: "POST",
8423
+ }).catch(() => {});
8424
+ },
8425
+
8426
+ async drainVoiceQueue(roomId, messageId) {
8427
+ // Called from voice-final handler — mark final and flush
8428
+ const q = this.voiceQueues[messageId];
8429
+ if (!q) return;
8430
+ if (q.final && !q.doneSent) {
8431
+ this._flushVoiceBuffer(q);
8432
+ }
8433
+ },
8434
+
8435
+ /** Immediately stop all voice playback and discard pending queues.
8436
+ * Used on hard-pause to silence mid-stream audio. */
8437
+ stopVoicePlayback() {
8438
+ for (const messageId of Object.keys(this.voiceQueues)) {
8439
+ const q = this.voiceQueues[messageId];
8440
+ if (q && q.audio) {
8441
+ try { q.audio.pause(); } catch (_) {}
8442
+ try { URL.revokeObjectURL(q.audio.src); } catch (_) {}
8443
+ }
8444
+ }
8445
+ this.voiceQueues = {};
8446
+ this._voiceCurrentMessageId = null;
8447
+ },
8448
+
8449
+ /** Unlock HTMLAudioElement autoplay policy.
8450
+ * Must be called inside a user-gesture handler (click/keydown). */
8451
+ unlockAudioPlayback() {
8452
+ if (this._audioUnlocked) return;
8453
+ try {
8454
+ // Play a silent audio to satisfy autoplay policy
8455
+ const silence = new Audio("data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=");
8456
+ silence.volume = 0;
8457
+ const p = silence.play();
8458
+ if (p) p.then(() => silence.pause()).catch(() => {});
8459
+ this._audioUnlocked = true;
8460
+ console.log("[voice] audio playback unlocked via user gesture");
8461
+ } catch (e) {
8462
+ console.warn("[voice] unlock failed:", e);
8463
+ }
8464
+ },
8465
+
7928
8466
  updateMessageBodyDom(messageId, body, streaming) {
7929
8467
  const article = document.querySelector(`[data-message-id="${messageId}"]`);
7930
8468
  if (!article) return;
@@ -8050,14 +8588,14 @@
8050
8588
  const m = this.currentMessages.find((x) => x.id === messageId);
8051
8589
  const n = (m && m.meta && typeof m.meta.roundNum === "number") ? m.meta.roundNum : null;
8052
8590
  const prefix = n != null
8053
- ? `<span class="rp-spent-round">round #${n}</span><span class="rp-spent-sep">·</span>`
8591
+ ? `<span class="rp-spent-round">${this.escape(this._t("rp_spent_round", { n }))}</span><span class="rp-spent-sep">·</span>`
8054
8592
  : "";
8055
8593
  return `
8056
8594
  <div class="round-prompt-card spent" data-round-prompt-card="${this.escape(messageId)}">
8057
8595
  <span class="rp-spent-chip">
8058
8596
  <span class="rp-spent-mark">◇</span>
8059
8597
  ${prefix}
8060
- <span class="rp-spent-label">closed · room moved on</span>
8598
+ <span class="rp-spent-label">${this.escape(this._t("rp_spent_label"))}</span>
8061
8599
  </span>
8062
8600
  </div>
8063
8601
  `;
@@ -8076,16 +8614,11 @@
8076
8614
  const recKind = rec && (rec.kind === "end" || rec.kind === "continue") ? rec.kind : null;
8077
8615
  const endClass = recKind === "end" ? " recommended" : "";
8078
8616
  const contClass = recKind === "continue" ? " recommended" : "";
8079
- // Detect language for the indicator label · Chinese rooms get
8080
- // 中文 copy. Reads from the chair's prompt body which lands in
8081
- // the message body when a recommendation is present.
8082
- const lang = (promptMsg && promptMsg.body && /[一-鿿]/.test(promptMsg.body)) ? "zh" : "en";
8083
- const recLabel = "chair recommends";
8084
8617
  const recIndicator = recKind
8085
8618
  ? `
8086
8619
  <div class="rp-rec-line rp-rec-line-${this.escape(recKind)}">
8087
8620
  <span class="rp-rec-arrow" aria-hidden="true">↑</span>
8088
- <span class="rp-rec-text">${this.escape(recLabel)}</span>
8621
+ <span class="rp-rec-text">${this.escape(this._t("rp_chair_recommends"))}</span>
8089
8622
  </div>
8090
8623
  `
8091
8624
  : "";
@@ -8094,17 +8627,17 @@
8094
8627
  <div class="rp-primary">
8095
8628
  <button type="button" class="rp-btn vote${endClass}" data-round-end>
8096
8629
  <span class="rp-mark">▣</span>
8097
- <span class="rp-label">End round · open key-point vote</span>
8630
+ <span class="rp-label">${this.escape(this._t("round_end_open_vote"))}</span>
8098
8631
  </button>
8099
8632
  <button type="button" class="rp-btn continue${contClass}" data-continue-auto>
8100
8633
  <span class="rp-mark">▶</span>
8101
- <span class="rp-label">Continue · next round</span>
8634
+ <span class="rp-label">${this.escape(this._t("rp_continue_next"))}</span>
8102
8635
  <span class="rp-timer" data-continue-timer></span>
8103
8636
  </button>
8104
8637
  </div>
8105
8638
  ${recIndicator}
8106
8639
  <button type="button" class="rp-adjourn" data-adjourn-from-chair>
8107
- ⊘ Adjourn the room &amp; file the brief
8640
+ ${this.escape(this._t("rp_adjourn_room_file"))}
8108
8641
  </button>
8109
8642
  </div>
8110
8643
  `;
@@ -8176,7 +8709,7 @@
8176
8709
  if (isStreaming) {
8177
8710
  return `
8178
8711
  <div class="round-end-card pending" data-round-end-card="${this.escape(messageId)}">
8179
- <div class="kp-eyebrow kp-eyebrow-pending">▸ key points · drafting</div>
8712
+ <div class="kp-eyebrow kp-eyebrow-pending">${this.escape(this._t("kp_eyebrow_drafting"))}</div>
8180
8713
  <div class="kp-list">
8181
8714
  <div class="kp-row kp-skeleton" aria-hidden="true">
8182
8715
  <div class="kp-skeleton-bar"></div>
@@ -8193,7 +8726,7 @@
8193
8726
  </div>
8194
8727
  <div class="kp-ctas-pending">
8195
8728
  <span class="kp-pending-dot"></span>
8196
- <span class="kp-pending-text">Chair is drafting key points…</span>
8729
+ <span class="kp-pending-text">${this.escape(this._t("kp_pending_drafting"))}</span>
8197
8730
  </div>
8198
8731
  </div>
8199
8732
  `;
@@ -8203,14 +8736,14 @@
8203
8736
  const ctasDegraded = awaiting
8204
8737
  ? `
8205
8738
  <div class="kp-ctas">
8206
- <button type="button" class="kp-cta primary" data-continue>[ ▶ Continue · next round ]</button>
8207
- <button type="button" class="kp-cta ghost" data-adjourn-from-chair>[ ⊘ Adjourn &amp; file brief ]</button>
8739
+ <button type="button" class="kp-cta primary" data-continue>${this.escape(this._t("kp_btn_continue_next"))}</button>
8740
+ <button type="button" class="kp-cta ghost" data-adjourn-from-chair>${this.escape(this._t("kp_btn_adjourn_brief"))}</button>
8208
8741
  </div>
8209
8742
  `
8210
- : `<div class="kp-ctas-spent">// continued</div>`;
8743
+ : `<div class="kp-ctas-spent">${this.escape(this._t("kp_ctas_spent"))}</div>`;
8211
8744
  return `
8212
8745
  <div class="round-end-card" data-round-end-card="${this.escape(messageId)}">
8213
- <div class="kp-eyebrow kp-eyebrow-degraded">▸ key points · couldn't be parsed from this round</div>
8746
+ <div class="kp-eyebrow kp-eyebrow-degraded">${this.escape(this._t("kp_eyebrow_degraded"))}</div>
8214
8747
  ${ctasDegraded}
8215
8748
  </div>
8216
8749
  `;
@@ -8219,11 +8752,11 @@
8219
8752
  <div class="kp-row" data-kp-id="${this.escape(p.id)}">
8220
8753
  <div class="kp-body">${this.escape(p.body)}</div>
8221
8754
  <div class="kp-actions">
8222
- <button type="button" class="kp-vote up ${p.vote === "up" ? "active" : ""}" data-kp-vote="up" data-kp-id="${this.escape(p.id)}" aria-label="Interested">
8223
- <span>▲</span><span>more</span>
8755
+ <button type="button" class="kp-vote up ${p.vote === "up" ? "active" : ""}" data-kp-vote="up" data-kp-id="${this.escape(p.id)}" aria-label="${this.escape(this._t("kp_vote_aria_up"))}">
8756
+ <span>▲</span><span>${this.escape(this._t("kp_vote_more"))}</span>
8224
8757
  </button>
8225
- <button type="button" class="kp-vote down ${p.vote === "down" ? "active" : ""}" data-kp-vote="down" data-kp-id="${this.escape(p.id)}" aria-label="Not interested">
8226
- <span>▼</span><span>drop</span>
8758
+ <button type="button" class="kp-vote down ${p.vote === "down" ? "active" : ""}" data-kp-vote="down" data-kp-id="${this.escape(p.id)}" aria-label="${this.escape(this._t("kp_vote_aria_down"))}">
8759
+ <span>▼</span><span>${this.escape(this._t("kp_vote_drop"))}</span>
8227
8760
  </button>
8228
8761
  </div>
8229
8762
  </div>
@@ -8237,14 +8770,16 @@
8237
8770
  const shiftCallout = (shift && awaiting)
8238
8771
  ? `
8239
8772
  <div class="kp-mode-shift">
8240
- <div class="kp-shift-eyebrow">▸ chair suggests · switch tone to <strong>${this.escape(shift.to)}</strong></div>
8773
+ <div class="kp-shift-eyebrow">${this.escape(this._t("kp_shift_eyebrow_prefix"))}<strong>${this.escape(shift.to)}</strong></div>
8241
8774
  <div class="kp-shift-because">${this.escape(shift.because)}</div>
8242
8775
  </div>
8243
8776
  `
8244
8777
  : "";
8245
8778
  let ctas;
8779
+ const modeRaw = (this.currentRoom?.mode || "").toLowerCase();
8780
+ const modeKeep = modeRaw || this._t("kp_mode_current_fallback");
8246
8781
  if (!awaiting) {
8247
- ctas = `<div class="kp-ctas-spent">// continued</div>`;
8782
+ ctas = `<div class="kp-ctas-spent">${this.escape(this._t("kp_ctas_spent"))}</div>`;
8248
8783
  } else if (shift) {
8249
8784
  // 3-button layout · primary takes ~50% of the row, the two
8250
8785
  // secondaries split the rest. Without `kp-ctas-shift`, three
@@ -8252,25 +8787,24 @@
8252
8787
  // longer "switch to constructive" label wraps to two lines.
8253
8788
  // Labels stripped of "& continue" — the action is implicit
8254
8789
  // in this round-end context.
8255
- const currentMode = (this.currentRoom?.mode || "").toLowerCase();
8256
8790
  ctas = `
8257
8791
  <div class="kp-ctas kp-ctas-shift">
8258
- <button type="button" class="kp-cta primary" data-shift-accept data-shift-to="${this.escape(shift.to)}">[ ↻ switch to ${this.escape(shift.to)} ]</button>
8259
- <button type="button" class="kp-cta ghost" data-continue>[ keep ${this.escape(currentMode || "current")} ]</button>
8260
- <button type="button" class="kp-cta ghost" data-adjourn-from-chair>[ ⊘ adjourn ]</button>
8792
+ <button type="button" class="kp-cta primary" data-shift-accept data-shift-to="${this.escape(shift.to)}">${this.escape(this._t("kp_switch_to", { mode: shift.to }))}</button>
8793
+ <button type="button" class="kp-cta ghost" data-continue>${this.escape(this._t("kp_keep_mode", { mode: modeKeep }))}</button>
8794
+ <button type="button" class="kp-cta ghost" data-adjourn-from-chair>${this.escape(this._t("kp_btn_adjourn"))}</button>
8261
8795
  </div>
8262
8796
  `;
8263
8797
  } else {
8264
8798
  ctas = `
8265
8799
  <div class="kp-ctas">
8266
- <button type="button" class="kp-cta primary" data-continue>[ ▶ Continue · next round ]</button>
8267
- <button type="button" class="kp-cta ghost" data-adjourn-from-chair>[ ⊘ Adjourn &amp; file brief ]</button>
8800
+ <button type="button" class="kp-cta primary" data-continue>${this.escape(this._t("kp_btn_continue_next"))}</button>
8801
+ <button type="button" class="kp-cta ghost" data-adjourn-from-chair>${this.escape(this._t("kp_btn_adjourn_brief"))}</button>
8268
8802
  </div>
8269
8803
  `;
8270
8804
  }
8271
8805
  return `
8272
8806
  <div class="round-end-card" data-round-end-card="${this.escape(messageId)}">
8273
- <div class="kp-eyebrow">▸ key points · vote what you want pursued</div>
8807
+ <div class="kp-eyebrow">${this.escape(this._t("kp_eyebrow_vote"))}</div>
8274
8808
  <div class="kp-list">${items}</div>
8275
8809
  ${shiftCallout}
8276
8810
  ${ctas}
@@ -8308,14 +8842,14 @@
8308
8842
  const truncated = parentSubject.length > 70
8309
8843
  ? parentSubject.slice(0, 70) + "…"
8310
8844
  : parentSubject;
8311
- // System UI · always English (follow-up origin link chrome).
8312
- const label = "Following up on";
8313
- const roomTag = "Room #";
8845
+ const label = this._t("convene_followup_label");
8846
+ const roomTag = this._t("convene_room_label");
8847
+ const parentTitle = this._t("convene_parent_session_title", { subject: parentSubject || parentId });
8314
8848
  const subjectChunk = truncated
8315
8849
  ? `<span class="convene-origin-sep">·</span><span class="convene-origin-subject">${this.escape(truncated)}</span>`
8316
8850
  : "";
8317
8851
  originHtml = `
8318
- <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))}">
8852
+ <a class="convene-origin" href="#/r/${this.escape(parentId)}" data-parent-room-id="${this.escape(parentId)}" title="${this.escape(parentTitle)}">
8319
8853
  <span class="convene-origin-arrow">↩</span>
8320
8854
  <span class="convene-origin-label">${this.escape(label)}</span>
8321
8855
  <span class="convene-origin-room">${this.escape(roomTag)}${this.escape(String(parentNum))}</span>
@@ -8328,24 +8862,21 @@
8328
8862
  parentId ? "convene-opener-followup" : "",
8329
8863
  isLongOpener ? "convene-opener-clamped" : "",
8330
8864
  ].filter(Boolean).join(" ");
8331
- // System UI · always English. Earlier this followed the brief
8332
- // language; now fixed-string per the rule that app chrome stays
8333
- // English regardless of the user's query language.
8334
- const moreLabel = "Show more ↓";
8335
- const lessLabel = "Show less ↑";
8865
+ const moreLabel = this._t("convene_show_more");
8866
+ const lessLabel = this._t("convene_show_less");
8336
8867
  const toggleHtml = isLongOpener
8337
8868
  ? `<button type="button" class="convene-toggle" data-convene-toggle data-more="${this.escape(moreLabel)}" data-less="${this.escape(lessLabel)}">${moreLabel}</button>`
8338
8869
  : "";
8339
8870
  return `
8340
8871
  <article class="${articleCls}" data-message-id="${this.escape(m.id)}">
8341
- <div class="convene-eyebrow">▸ Convene · Initial Question</div>
8872
+ <div class="convene-eyebrow">${this.escape(this._t("convene_eyebrow"))}</div>
8342
8873
  ${originHtml}
8343
8874
  <h2 class="convene-body">${this.renderBody(m.body)}</h2>
8344
8875
  ${toggleHtml}
8345
8876
  <div class="convene-meta">
8346
8877
  <span class="convene-by">${who}</span>
8347
8878
  <span class="convene-time">· ${this.timeFmt(m.createdAt)}</span>
8348
- <span class="convene-cast">· to ${this.currentMembers.map((a) => this.escape(a.handle)).join(" ")}</span>
8879
+ <span class="convene-cast">· ${this.escape(this._t("convene_meta_to"))} ${this.currentMembers.map((a) => this.escape(a.handle)).join(" ")}</span>
8349
8880
  </div>
8350
8881
  </article>
8351
8882
  `;
@@ -8373,9 +8904,10 @@
8373
8904
  const status = meta.status === "done" || meta.status === "failed" ? meta.status : "running";
8374
8905
  const tool = (meta.tool || "tool").toString();
8375
8906
  const target = meta.target ? String(meta.target) : "";
8376
- const elapsed = typeof meta.elapsedMs === "number" && status !== "running"
8377
- ? `${(meta.elapsedMs / 1000).toFixed(1)}s`
8907
+ const elapsedSecs = typeof meta.elapsedMs === "number" && status !== "running"
8908
+ ? this._t("msg_ws_secs", { n: (meta.elapsedMs / 1000).toFixed(1) })
8378
8909
  : "";
8910
+ const elapsedTail = elapsedSecs ? ` · ${elapsedSecs}` : "";
8379
8911
  const sources = Array.isArray(meta.sources) ? meta.sources : [];
8380
8912
 
8381
8913
  // web-search renders as a full-width card mirroring `.brief-card`
@@ -8386,10 +8918,16 @@
8386
8918
  if (tool === "web-search") {
8387
8919
  const hasSources = status === "done" && sources.length > 0;
8388
8920
  let stamp;
8389
- if (status === "running") stamp = "searching…";
8390
- else if (status === "done")
8391
- stamp = `${sources.length} source${sources.length === 1 ? "" : "s"}${elapsed ? " · " + elapsed : ""}`;
8392
- else stamp = elapsed ? `failed · ${elapsed}` : "failed";
8921
+ if (status === "running") stamp = this._t("msg_ws_searching");
8922
+ else if (status === "done") {
8923
+ stamp = sources.length === 1
8924
+ ? this._t("msg_ws_done_one", { tail: elapsedTail })
8925
+ : this._t("msg_ws_done", { n: sources.length, tail: elapsedTail });
8926
+ } else {
8927
+ stamp = elapsedSecs
8928
+ ? this._t("msg_ws_failed_elapsed", { elapsed: elapsedSecs })
8929
+ : this._t("msg_ws_failed");
8930
+ }
8393
8931
 
8394
8932
  const mark = status === "running"
8395
8933
  ? `<span class="msg-tool-pulse"></span>`
@@ -8417,22 +8955,22 @@
8417
8955
  `;
8418
8956
  }).join("")}
8419
8957
  </ol>
8420
- <button type="button" class="msg-tool-sources-expand" data-msg-ws-toggle data-message-id="${this.escape(m.id)}" aria-label="toggle sources">
8958
+ <button type="button" class="msg-tool-sources-expand" data-msg-ws-toggle data-message-id="${this.escape(m.id)}" aria-label="${this.escape(this._t("msg_ws_toggle"))}">
8421
8959
  <span class="msg-tool-sources-expand-icon" aria-hidden="true">▾</span>
8422
- <span class="msg-tool-sources-expand-show">Show all ${sources.length} sources</span>
8423
- <span class="msg-tool-sources-expand-hide">Collapse</span>
8960
+ <span class="msg-tool-sources-expand-show">${this.escape(this._t("msg_ws_expand_show", { n: sources.length }))}</span>
8961
+ <span class="msg-tool-sources-expand-hide">${this.escape(this._t("msg_ws_expand_hide"))}</span>
8424
8962
  </button>`
8425
8963
  : "";
8426
8964
 
8427
8965
  const bodyToggleAttrs = hasSources
8428
- ? ` data-msg-ws-toggle data-message-id="${this.escape(m.id)}" role="button" tabindex="0" aria-label="toggle sources"`
8966
+ ? ` data-msg-ws-toggle data-message-id="${this.escape(m.id)}" role="button" tabindex="0" aria-label="${this.escape(this._t("msg_ws_toggle"))}"`
8429
8967
  : "";
8430
8968
  const caret = hasSources ? `<span class="msg-tool-caret" aria-hidden="true">▸</span>` : "";
8431
8969
 
8432
8970
  return `
8433
8971
  <div class="msg-tool-card status-${this.escape(status)}" data-message-id="${this.escape(m.id)}">
8434
8972
  <div class="msg-tool-banner">
8435
- <span class="msg-tool-banner-tag">// web-search</span>
8973
+ <span class="msg-tool-banner-tag">${this.escape(this._t("msg_ws_banner"))}</span>
8436
8974
  <span class="msg-tool-banner-stamp">${this.escape(stamp)}</span>
8437
8975
  </div>
8438
8976
  <div class="msg-tool-card-body${hasSources ? " is-toggle" : ""}"${bodyToggleAttrs}>
@@ -8458,7 +8996,7 @@
8458
8996
  <span class="msg-tool-name">${this.escape(tool)}</span>
8459
8997
  <span class="msg-tool-sep">·</span>
8460
8998
  <span class="msg-tool-body">${this.escape(m.body || "")}</span>
8461
- ${elapsed ? `<span class="msg-tool-elapsed">${this.escape(elapsed)}</span>` : ""}
8999
+ ${elapsedSecs ? `<span class="msg-tool-elapsed">${this.escape(elapsedSecs)}</span>` : ""}
8462
9000
  ${isUrlTarget ? `<a href="${this.escape(target)}" target="_blank" rel="noopener noreferrer" class="msg-tool-link" title="${this.escape(target)}">↗</a>` : ""}
8463
9001
  </div>
8464
9002
  </div>
@@ -8481,10 +9019,10 @@
8481
9019
  return `
8482
9020
  <div class="chair-direct${streaming ? " is-streaming" : ""}" data-message-id="${this.escape(m.id)}">
8483
9021
  <div class="cd-rule" aria-hidden="true"></div>
8484
- <div class="cd-kicker">▸ chair · responding to you</div>
9022
+ <div class="cd-kicker">${this.escape(this._t("msg_chair_direct_kicker"))}</div>
8485
9023
  <div class="cd-body">${bodyHtml}</div>
8486
9024
  <div class="cd-meta">
8487
- <span class="cd-author">${this.escape(this.currentChair?.name || "Chair")}</span>
9025
+ <span class="cd-author">${this.escape(this.currentChair?.name || this._t("msg_chair_display_fallback"))}</span>
8488
9026
  <span class="cd-time">· ${this.timeFmt(m.createdAt)}</span>
8489
9027
  </div>
8490
9028
  </div>
@@ -8504,7 +9042,7 @@
8504
9042
  return `
8505
9043
  <div class="chair-intervention" data-message-id="${this.escape(m.id)}">
8506
9044
  <div class="ci-rule" aria-hidden="true"></div>
8507
- <div class="ci-kicker">▸ chair note</div>
9045
+ <div class="ci-kicker">${this.escape(this._t("msg_chair_intervention_kicker"))}</div>
8508
9046
  <div class="ci-body">${this.renderBody(m.body)}</div>
8509
9047
  </div>
8510
9048
  `;
@@ -8519,7 +9057,7 @@
8519
9057
  return `
8520
9058
  <div class="chair-billing-notice" data-message-id="${this.escape(m.id)}">
8521
9059
  <div class="cb-rule" aria-hidden="true"></div>
8522
- <div class="cb-kicker">▸ billing · attention needed</div>
9060
+ <div class="cb-kicker">${this.escape(this._t("msg_chair_billing_kicker"))}</div>
8523
9061
  <div class="cb-body">${this.renderBody(m.body)}</div>
8524
9062
  </div>
8525
9063
  `;
@@ -8537,9 +9075,9 @@
8537
9075
  <div class="round-open-card ${isOpening ? "is-opening" : "is-reactive"}" data-message-id="${this.escape(m.id)}">
8538
9076
  <span class="ro-chip">
8539
9077
  <span class="ro-mark">${isOpening ? "◆" : "◇"}</span>
8540
- <span class="ro-round">round #${this.escape(String(roundNum))}</span>
9078
+ <span class="ro-round">${this.escape(this._t("ro_round", { n: roundNum }))}</span>
8541
9079
  <span class="ro-sep">·</span>
8542
- <span class="ro-mode">${isOpening ? "parallel · independent perspectives" : "reactive · directors react to one another"}</span>
9080
+ <span class="ro-mode">${this.escape(isOpening ? this._t("ro_mode_parallel") : this._t("ro_mode_reactive"))}</span>
8543
9081
  </span>
8544
9082
  </div>
8545
9083
  `;
@@ -8558,27 +9096,25 @@
8558
9096
  // as a historical marker only.
8559
9097
  if (isChair && metaKind === "no-brief") {
8560
9098
  const ts = this.timeFmt(m.createdAt);
8561
- // System UI · always English (no-brief card chrome).
8562
- const hasBrief = !!this.currentBrief;
9099
+ // System UI · always English (no-brief card chrome). const hasBrief = !!this.currentBrief;
9100
+ const chairName = (this.prefs?.name || "").trim() || this._t("nb_chair_fallback");
8563
9101
  const cta = hasBrief
8564
9102
  ? ""
8565
9103
  : `
8566
9104
  <div class="nb-actions">
8567
9105
  <button type="button" class="nb-cta" data-generate-brief>
8568
9106
  <span class="nb-cta-mark">▸</span>
8569
- <span class="nb-cta-text">Generate report now</span>
8570
- </button>
9107
+ <span class="nb-cta-text">${this.escape(this._t("nb_cta"))}</span> </button>
8571
9108
  </div>
8572
9109
  `;
8573
9110
  return `
8574
9111
  <div class="no-brief-card" data-message-id="${this.escape(m.id)}">
8575
9112
  <span class="nb-chip">
8576
9113
  <span class="nb-mark">⊘</span>
8577
- <span class="nb-eyebrow">adjourned · no brief filed</span>
9114
+ <span class="nb-eyebrow">${this.escape(this._t("nb_eyebrow"))}</span>
8578
9115
  </span>
8579
9116
  <div class="nb-body">
8580
- <strong>${this.escape(this.prefs?.name || "The chair")}</strong> declared no report is needed for this session.
8581
- </div>
9117
+ <strong>${this.escape(chairName)}</strong> ${this.escape(this._t("nb_body"))} </div>
8582
9118
  ${cta}
8583
9119
  <div class="nb-meta">${this.escape(ts)}</div>
8584
9120
  </div>
@@ -8592,9 +9128,9 @@
8592
9128
  : (author ? this.escape(author.id) : "agent");
8593
9129
  const name = isUser ? (this.prefs?.name || "You") : (author?.name || "Agent");
8594
9130
  const tag = isUser
8595
- ? "// you"
9131
+ ? this._t("msg_tag_you")
8596
9132
  : isChair
8597
- ? "// chair"
9133
+ ? this._t("msg_tag_chair")
8598
9134
  : (author ? `// ${author.roleTag}` : "");
8599
9135
  // Model badge · only shown for non-user messages. Falls back to
8600
9136
  // the raw modelV string if the registry lookup misses (e.g. a
@@ -8648,7 +9184,7 @@
8648
9184
  // is procedural; ctx count would be misleading).
8649
9185
  const ctxCount = !isUser && !isChair ? this.contextCountAt(m.id) : 0;
8650
9186
  const ctxBadge = ctxCount > 0
8651
- ? `<span class="msg-context" title="${ctxCount} prior turns sent as context to ${this.escape(name)}" ${ctxCount} ctx</span>`
9187
+ ? `<span class="msg-context" title="${this.escape(this._t("msg_ctx_title", { n: ctxCount, name }))}">${this.escape(this._t("msg_ctx_inline", { n: ctxCount }))}</span>`
8652
9188
  : "";
8653
9189
 
8654
9190
  // Skills badge · the orchestrator's Pass-1 router stamps which
@@ -8658,7 +9194,7 @@
8658
9194
  const skillsUsed = (m.meta && Array.isArray(m.meta.skillsUsed)) ? m.meta.skillsUsed : [];
8659
9195
  const skillsReason = (m.meta && typeof m.meta.skillsReason === "string") ? m.meta.skillsReason : "";
8660
9196
  const skillsBadge = skillsUsed.length > 0
8661
- ? `<span class="msg-skills" title="${this.escape(skillsReason || ("skills used: " + skillsUsed.join(", ")))}">🛠 ${skillsUsed.map((s) => this.escape(s)).join(", ")}</span>`
9197
+ ? `<span class="msg-skills" title="${this.escape(skillsReason || this._t("msg_skills_used", { list: skillsUsed.join(", ") }))}">🛠 ${skillsUsed.map((s) => this.escape(s)).join(", ")}</span>`
8662
9198
  : "";
8663
9199
 
8664
9200
  // Web-search badge · meta is set by the orchestrator when the
@@ -8669,11 +9205,18 @@
8669
9205
  const webSearchQuery = (m.meta && typeof m.meta.webSearchQuery === "string") ? m.meta.webSearchQuery : "";
8670
9206
  const webSearchSources = (m.meta && Array.isArray(m.meta.webSearchSources)) ? m.meta.webSearchSources : [];
8671
9207
  const webSearchBadge = webSearchUsed
8672
- ? `<button type="button" class="msg-web-search" data-msg-ws-toggle data-message-id="${this.escape(m.id)}" title="${this.escape(`web search: ${webSearchQuery} · ${webSearchSources.length} source${webSearchSources.length === 1 ? "" : "s"}`)}">🔍 web search · ${webSearchSources.length} source${webSearchSources.length === 1 ? "" : "s"}</button>`
9208
+ ? (() => {
9209
+ const n = webSearchSources.length;
9210
+ const title = this.escape(this._t("msg_ws_title", { query: webSearchQuery, n }));
9211
+ const label = n === 1
9212
+ ? this.escape(this._t("msg_ws_btn_one"))
9213
+ : this.escape(this._t("msg_ws_btn", { n }));
9214
+ return `<button type="button" class="msg-web-search" data-msg-ws-toggle data-message-id="${this.escape(m.id)}" title="${title}">🔍 ${label}</button>`;
9215
+ })()
8673
9216
  : "";
8674
9217
  const webSearchSourcesPanel = webSearchUsed && webSearchSources.length > 0
8675
9218
  ? `<div class="msg-web-search-sources" data-msg-ws-sources data-message-id="${this.escape(m.id)}" hidden>
8676
- <div class="msg-web-search-query"><span class="msg-web-search-query-label">query</span><span class="msg-web-search-query-text">${this.escape(webSearchQuery)}</span></div>
9219
+ <div class="msg-web-search-query"><span class="msg-web-search-query-label">${this.escape(this._t("msg_ws_query_label"))}</span><span class="msg-web-search-query-text">${this.escape(webSearchQuery)}</span></div>
8677
9220
  <ol class="msg-web-search-list">
8678
9221
  ${webSearchSources.map((s, i) => `
8679
9222
  <li>
@@ -8720,7 +9263,7 @@
8720
9263
  ? m.meta.chairPick.rationale.trim()
8721
9264
  : "";
8722
9265
  const chairPickKicker = (chairPick && !isUser && !isChair)
8723
- ? `<div class="msg-chair-pick" title="Chair picked ${this.escape(name)} for this turn">▸ chair · ${this.escape(chairPick)}</div>`
9266
+ ? `<div class="msg-chair-pick" title="${this.escape(this._t("room_chair_pick_title", { name: this.escape(name) }))}">${this.escape(this._t("msg_chair_pick", { body: chairPick }))}</div>`
8724
9267
  : "";
8725
9268
 
8726
9269
  // data-author-id is only attached for director messages (not user
@@ -8736,7 +9279,7 @@
8736
9279
  ${chairPickKicker}
8737
9280
  <div class="msg-meta">
8738
9281
  <span class="msg-name">${this.escape(name)}</span>
8739
- ${modelLabel ? `<span class="msg-model" title="model · ${this.escape(modelLabel)}">${this.escape(modelLabel)}</span>` : ""}
9282
+ ${modelLabel ? `<span class="msg-model" title="${this.escape(this._t("msg_model_title", { label: modelLabel }))}">${this.escape(modelLabel)}</span>` : ""}
8740
9283
  <span class="msg-tag">${tag}</span>
8741
9284
  ${skillsBadge}
8742
9285
  ${webSearchBadge}
@@ -8789,9 +9332,9 @@
8789
9332
  // Old queue strip used .qw-label; new in-chat card uses .rp-label.
8790
9333
  const label = btn.querySelector(".rp-label, .qw-label");
8791
9334
  if (label) {
8792
- if (this.currentRoom?.awaitingContinue) label.textContent = "Round wrapped · vote above";
8793
- else if (this.currentRoom?.awaitingClarify) label.textContent = "Clarifying — wait for chair";
8794
- else label.textContent = "End round · open key-point vote";
9335
+ if (this.currentRoom?.awaitingContinue) label.textContent = this._t("round_end_vote_above");
9336
+ else if (this.currentRoom?.awaitingClarify) label.textContent = this._t("round_clarifying");
9337
+ else label.textContent = this._t("round_end_open_vote");
8795
9338
  }
8796
9339
  },
8797
9340
 
@@ -8801,7 +9344,7 @@
8801
9344
  const slot = document.querySelector("[data-queue-collapsed]");
8802
9345
  if (!slot) return;
8803
9346
  if (!items || items.length === 0) {
8804
- slot.innerHTML = `<span class="sum-marker">·</span><span class="sum-state">queue idle</span>`;
9347
+ slot.innerHTML = `<span class="sum-marker">·</span><span class="sum-state">${this.escape(this._t("q_idle"))}</span>`;
8805
9348
  return;
8806
9349
  }
8807
9350
  const head = items[0];
@@ -8810,14 +9353,14 @@
8810
9353
  const speaking = head.status === "speaking";
8811
9354
  const pending = head.status === "pending";
8812
9355
  const stateLabel = speaking
8813
- ? "●●● speaking"
9356
+ ? this._t("q_speaking")
8814
9357
  : pending
8815
9358
  ? (this.currentRoom?.awaitingContinue
8816
- ? "pending · waits for vote"
8817
- : "pending · waits for chair")
8818
- : "queued";
9359
+ ? this._t("q_pending_vote")
9360
+ : this._t("q_pending_chair"))
9361
+ : this._t("q_queued");
8819
9362
  const next = items[1] ? this.agentsById[items[1].agentId] : null;
8820
- const rest = items.length > 2 ? `+${items.length - 2} queued` : "";
9363
+ const rest = items.length > 2 ? this._t("q_more_queued", { n: items.length - 2 }) : "";
8821
9364
 
8822
9365
  const parts = [];
8823
9366
  parts.push(`<span class="sum-marker">${speaking ? "▶" : pending ? "◇" : "·"}</span>`);
@@ -8904,9 +9447,9 @@
8904
9447
  <li class="queue-row user-queued" data-user-queued>
8905
9448
  <span class="pos">↪</span>
8906
9449
  <span class="who">${this.escape(userName)}</span>
8907
- <span class="state">queued · "${this.escape(preview)}"</span>
9450
+ <span class="state">${this._t("q_user_queued", { preview: this.escape(preview) })}</span>
8908
9451
  <span class="actions">
8909
- <button type="button" class="user-queued-cancel" data-cancel-user-queued title="Cancel">✕</button>
9452
+ <button type="button" class="user-queued-cancel" data-cancel-user-queued title="${this.escape(this._t("q_cancel"))}">✕</button>
8910
9453
  </span>
8911
9454
  </li>
8912
9455
  `;
@@ -8923,16 +9466,16 @@
8923
9466
  const pos = speaking ? "▶" : (POS[i - (firstSpeaking ? 1 : 0)] || (i + 1));
8924
9467
  let stateLabel;
8925
9468
  if (speaking) {
8926
- stateLabel = `speaking · reading ${ctxTotal} ${ctxTotal === 1 ? "turn" : "turns"}`;
9469
+ stateLabel = this._t("q_state_speaking", { n: ctxTotal });
8927
9470
  } else if (pending) {
8928
9471
  // Pending means "lined up, waiting on something off-queue".
8929
9472
  // Phrasing matches the room's current pause kind so the
8930
9473
  // user knows what they're waiting on.
8931
9474
  stateLabel = this.currentRoom?.awaitingContinue
8932
- ? "pending · waits for your vote"
9475
+ ? this._t("q_pending_your_vote")
8933
9476
  : this.currentRoom?.awaitingClarify
8934
- ? "pending · waits for chair"
8935
- : "pending";
9477
+ ? this._t("q_pending_waits_chair")
9478
+ : this._t("q_pending_chair");
8936
9479
  } else {
8937
9480
  stateLabel = q.status;
8938
9481
  }
@@ -8973,73 +9516,20 @@
8973
9516
  .replace(/&[a-z]+;/gi, " "); // HTML entities
8974
9517
  const cjk = stripped.match(/[一-鿿㐀-䶿豈-﫿぀-ゟ゠-ヿ]/g);
8975
9518
  const cjkCount = cjk ? cjk.length : 0;
8976
- const isCjk = cjkCount >= stripped.length * 0.3 && cjkCount > 80;
8977
- let count;
8978
- let label;
8979
- if (isCjk) {
8980
- count = cjkCount;
8981
- label = `~${count.toLocaleString("en-US")} 字`;
8982
- } else {
8983
- const words = stripped.trim().split(/\s+/).filter((w) => w.length > 0);
8984
- count = words.length;
8985
- if (count === 0) return null;
8986
- label = count === 1 ? "1 word" : `${count.toLocaleString("en-US")} words`;
8987
- }
8988
- // Tone-aware sweet band · brainstorm recaps run lean (concrete
8989
- // ideas, not deep analysis); standard rooms (constructive /
8990
- // debate) land in the middle; research / critique rooms shoulder
8991
- // a denser shape (assumptions + scenarios + indicators + threats
8992
- // to validity all naturally lengthen the body).
8993
- const tone = (this.currentRoom?.mode || "constructive").toLowerCase();
8994
- const bandKind = tone === "brainstorm"
8995
- ? "lean"
8996
- : (tone === "research" || tone === "critique" ? "dense" : "standard");
8997
- // Brainstorm `lean` was originally 600-1500 zh / 400-1000 en —
8998
- // calibrated against "quick recap" briefs with 1-2 directors.
8999
- // That undercounted real brainstorm rooms: 3-4 directors × 2-3
9000
- // rounds × 2-3 ideas-per-turn easily produces 12-20 ideas, each
9001
- // worth 80-150 words (concept + why-it-matters + what-it-opens).
9002
- // 1500-2200 words / 2000-3000 字 is a healthy, idea-dense
9003
- // brainstorm — bumping the band so that lands in `sweet`, not
9004
- // `dense`. Standard / dense bands unchanged.
9005
- const bands = isCjk
9006
- ? ({
9007
- lean: { sweetLo: 1000, sweetHi: 2200, denseHi: 3800, longHi: 5500 },
9008
- standard: { sweetLo: 1500, sweetHi: 2800, denseHi: 4500, longHi: 6500 },
9009
- dense: { sweetLo: 2500, sweetHi: 4500, denseHi: 6500, longHi: 8000 },
9010
- })[bandKind]
9011
- : ({
9012
- lean: { sweetLo: 800, sweetHi: 1600, denseHi: 2800, longHi: 4000 },
9013
- standard: { sweetLo: 1000, sweetHi: 1800, denseHi: 3000, longHi: 4500 },
9014
- dense: { sweetLo: 1800, sweetHi: 3000, denseHi: 4500, longHi: 5500 },
9015
- })[bandKind];
9016
- let tier;
9017
- if (count < bands.sweetLo) tier = "thin";
9018
- else if (count <= bands.sweetHi) tier = "sweet";
9019
- else if (count <= bands.denseHi) tier = "dense";
9020
- else if (count <= bands.longHi) tier = "long";
9021
- else tier = "too-long";
9022
- return { label, tier, count, isCjk, tone, bands };
9023
- },
9024
-
9025
- /** Tooltip explaining what the brief-card word-count chip's colour
9026
- * means · tone-aware so the user understands why a 2,500-word
9027
- * brainstorm recap reads "dense" while the same length in a
9028
- * research note reads "sweet." */
9029
- _briefWordCountTip(wc) {
9030
- if (!wc) return "";
9031
- // System UI · always English (word-count chip tooltip).
9032
- const toneLabel = wc.tone;
9033
- const range = `${wc.bands.sweetLo.toLocaleString("en-US")}-${wc.bands.sweetHi.toLocaleString("en-US")}`;
9034
- const unit = "words";
9035
- const copy = {
9036
- "thin": `Lean · ${toneLabel} sweet zone is ${range} ${unit}`,
9037
- "sweet": `Sweet zone for ${toneLabel} · most read-through-able length`,
9038
- "dense": `Dense · past the ${toneLabel} sweet zone, still readable`,
9039
- "long": `Long · approaching "filed instead of read"`,
9040
- "too-long": `Too long · likely to be skimmed, not read`,
9041
- };
9042
- return copy[wc.tier] || "";
9519
+ // CJK-dominant docs: count chars (the "字数" the user reads off
9520
+ // a manuscript). The 30% threshold catches mixed zh-en docs
9521
+ // where the body is mostly Chinese with English brand names —
9522
+ // the natural unit there is still characters, not words.
9523
+ if (cjkCount >= stripped.length * 0.3 && cjkCount > 80) {
9524
+ const fmt = cjkCount.toLocaleString("en-US");
9525
+ return this._t("brief_wc_approx_chars", { n: fmt });
9526
+ }
9527
+ // English / Latin: whitespace-split, ignore empty tokens.
9528
+ const words = stripped.trim().split(/\s+/).filter((w) => w.length > 0);
9529
+ const n = words.length;
9530
+ if (n === 0) return null;
9531
+ const fmt = n.toLocaleString("en-US");
9532
+ return n === 1 ? this._t("brief_wc_one_word") : this._t("brief_wc_words", { n: fmt });
9043
9533
  },
9044
9534
 
9045
9535
  /** Render the brief version tab strip · shared by both the error
@@ -9060,11 +9550,11 @@
9060
9550
  const isInitial = i === 0;
9061
9551
  const supp = bf.supplement && bf.supplement.trim()
9062
9552
  ? bf.supplement.trim()
9063
- : (isInitial ? "Initial" : "");
9553
+ : (isInitial ? this._t("brief_tab_initial") : "");
9064
9554
  const tooltip = isInitial
9065
- ? `Initial brief · generated from the session`
9066
- : `Supplement: ${supp || "—"}`;
9067
- const closeTitle = "Delete this report";
9555
+ ? this._t("brief_tab_tooltip_initial")
9556
+ : `${this._t("brief_tab_supplement_prefix")}${supp || "—"}`;
9557
+ const closeTitle = this._t("brief_tab_delete_title");
9068
9558
  // Errored / interrupted / timed-out tabs get a small
9069
9559
  // visual marker so the user can spot which one needs
9070
9560
  // attention without entering it. The full retry UI is
@@ -9078,7 +9568,7 @@
9078
9568
  <span class="brief-version-num">${num}</span>
9079
9569
  ${stateMark}
9080
9570
  ${isInitial
9081
- ? `<span class="brief-version-label">Initial</span>`
9571
+ ? `<span class="brief-version-label">${this.escape(this._t("brief_tab_initial"))}</span>`
9082
9572
  : `<span class="brief-version-label">${this.escape((supp || "").slice(0, 20))}${(supp || "").length > 20 ? "…" : ""}</span>`}
9083
9573
  </button>
9084
9574
  <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>
@@ -9151,14 +9641,13 @@
9151
9641
  finally { this.currentBrief = prevCurrent; }
9152
9642
  // Prepend the compact retry banner to the rendered card so
9153
9643
  // the existing report stays fully visible below it.
9154
- // System UI · always English (error banner chrome).
9155
9644
  const detail = failed.timedOut
9156
- ? "regeneration timed out"
9645
+ ? this._t("brief_regen_timeout")
9157
9646
  : failed.interrupted
9158
- ? "regeneration interrupted"
9159
- : (failed.error || "regeneration failed");
9160
- const cta = "Retry";
9161
- const dismiss = "Dismiss";
9647
+ ? this._t("brief_regen_interrupted")
9648
+ : (failed.error || this._t("brief_regen_failed"));
9649
+ const cta = this._t("brief_retry");
9650
+ const dismiss = this._t("brief_dismiss");
9162
9651
  card.insertAdjacentHTML("afterbegin", `
9163
9652
  <div class="brief-retry-banner" data-brief-retry-banner data-failed-brief-id="${this.escape(failed.id)}">
9164
9653
  <span class="brb-mark">⚠</span>
@@ -9172,40 +9661,34 @@
9172
9661
  `);
9173
9662
  return;
9174
9663
  }
9175
- // System UI · always English (full error-card copy).
9176
9664
  const copy = b.timedOut
9177
9665
  ? {
9178
- stamp: "timed out",
9179
- kicker: "// generation timed out",
9180
- 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.",
9666
+ stamp: this._t("brief_err_stamp_timeout"),
9667
+ kicker: this._t("brief_err_kicker_timeout"),
9668
+ detail: this._t("brief_err_detail_timeout"),
9181
9669
  hint: "",
9182
- cta: "Retry",
9670
+ cta: this._t("brief_retry"),
9183
9671
  }
9184
9672
  : b.interrupted
9185
- ? {
9186
- stamp: "interrupted",
9187
- kicker: "// generation interrupted",
9188
- detail: "The previous generation was cut short — likely by a browser refresh or a server restart. Click below to start a fresh report.",
9189
- hint: "",
9190
- cta: "Regenerate report",
9191
- }
9192
- : {
9193
- stamp: "failed",
9194
- kicker: "// brief generation failed",
9195
- detail: this.escape(b.error || ""),
9196
- 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.",
9197
- cta: "Retry",
9198
- };
9673
+ ? {
9674
+ stamp: this._t("brief_err_stamp_interrupted"),
9675
+ kicker: this._t("brief_err_kicker_interrupted"),
9676
+ detail: this._t("brief_err_detail_interrupted"),
9677
+ hint: "",
9678
+ cta: this._t("brief_err_cta_regenerate"),
9679
+ }
9680
+ : {
9681
+ stamp: this._t("brief_err_stamp_failed"),
9682
+ kicker: this._t("brief_err_kicker_failed"),
9683
+ detail: this.escape(b.error || ""),
9684
+ hint: this._briefSecondaryHintHtml(b.error || ""),
9685
+ cta: this._t("brief_retry"),
9686
+ };
9199
9687
  card.innerHTML = `
9200
9688
  <div class="brief-card">
9201
9689
  ${tabsStripHtml}
9202
- <div class="brief-banner">
9203
- <span class="brief-banner-tag" style="color: var(--red);">// report</span>
9204
- <span class="brief-banner-type" style="color: var(--red); border-color: var(--red);">${this.escape(this.briefModeLabel(b))}</span>
9205
- <span class="brief-banner-stamp" style="color: var(--red);">${this.escape(copy.stamp)}</span>
9206
- </div>
9207
9690
  <div class="brief-body brief-body-error">
9208
- <div class="brief-kicker" style="color: var(--red);">${this.escape(copy.kicker)}</div>
9691
+ <div class="brief-kicker" style="color: var(--red);">${this.escape(copy.kicker)} <span class="brief-kicker-sep" aria-hidden="true">·</span> ${this.escape(copy.stamp)} <span class="brief-kicker-sep" aria-hidden="true">·</span> <span class="brief-meta-type">${this.escape(this.briefModeLabel(b))}</span></div>
9209
9692
  <div class="brief-meta-line" style="color: var(--text-soft); text-transform: none; letter-spacing: 0;">
9210
9693
  ${(b.interrupted || b.timedOut) ? this.escape(copy.detail) : copy.detail}
9211
9694
  </div>
@@ -9230,8 +9713,10 @@
9230
9713
  .join("");
9231
9714
 
9232
9715
  const filedLabel = generating
9233
- ? "GENERATING…"
9234
- : "FILED · " + (b.createdAt ? this.timeFmt(b.createdAt) : this.timeFmt(Date.now()));
9716
+ ? this._t("brief_filed_generating")
9717
+ : this._t("brief_filed_stamp", {
9718
+ when: b.createdAt ? this.timeFmt(b.createdAt) : this.timeFmt(Date.now()),
9719
+ });
9235
9720
 
9236
9721
  // Open Report URL · routes to /magazine.html or /newspaper.html
9237
9722
  // for the structured modes and /report.html for research-note
@@ -9250,30 +9735,39 @@
9250
9735
  card.innerHTML = `
9251
9736
  <header class="ending-block-head">
9252
9737
  <span class="ending-block-line"></span>
9253
- <span class="ending-block-label">▼ session output ▼</span>
9738
+ <span class="ending-block-label">${this.escape(this._t("brief_output_head"))}</span>
9254
9739
  <span class="ending-block-line"></span>
9255
9740
  </header>
9256
9741
 
9257
9742
  <div class="brief-card">
9258
9743
  ${tabsHtml}
9259
- <div class="brief-banner">
9260
- <span class="brief-banner-tag">// report</span>
9261
- <span class="brief-banner-type">${this.escape(this.briefModeLabel(b))}</span>
9262
- <span class="brief-banner-stamp">${filedLabel}</span>
9263
- </div>
9264
9744
 
9265
9745
  <div class="brief-body">
9266
9746
  ${generating
9267
9747
  ? `<div class="brief-info brief-info-generating">${this.renderBriefStages(b)}</div>`
9268
9748
  : (() => {
9269
9749
  const wc = this._briefWordCount(b);
9270
- const tip = this._briefWordCountTip(wc);
9750
+ // Tucked the mode chip into the meta-row alongside
9751
+ // authors / word count. The earlier `.brief-banner`
9752
+ // strip (kicker + chip + stamp) was retired — the
9753
+ // user found it ate too much vertical space for
9754
+ // info that's already implied by the surrounding
9755
+ // adjourned-room context.
9756
+ const filedAgo = b.createdAt ? this.relTime(b.createdAt) : "";
9757
+ const filedKicker = this._t("brief_filed_by", {
9758
+ name: this.currentChair?.name ? this.escape(this.currentChair.name) : this._t("brief_chair_fallback"),
9759
+ });
9760
+ const kickerLine = filedAgo
9761
+ ? `${filedKicker} <span class="brief-kicker-sep" aria-hidden="true">·</span> ${this.escape(filedAgo)}`
9762
+ : filedKicker;
9271
9763
  return `<div class="brief-info">
9272
- <div class="brief-kicker">// filed by ${this.escape(this.currentChair?.name || "the chair")}</div>
9273
- <h2 class="brief-title" data-brief-title>${this.escape(b.title || "(untitled)")}</h2>
9764
+ <div class="brief-kicker">${kickerLine}</div>
9765
+ <h2 class="brief-title" data-brief-title>${this.escape(b.title || this._t("brief_untitled"))}</h2>
9274
9766
  <div class="brief-meta-row">
9275
- <span class="brief-meta-line">${this.currentMembers.length} authors</span>
9276
- ${wc ? `<span class="brief-meta-sep" aria-hidden="true">·</span><span class="brief-meta-line brief-meta-words is-${this.escape(wc.tier)}" title="${this.escape(tip)}">${this.escape(wc.label)}</span>` : ""}
9767
+ <span class="brief-meta-line">${this.escape(this._t("brief_meta_authors", { n: this.currentMembers.length }))}</span>
9768
+ ${wc ? `<span class="brief-meta-sep" aria-hidden="true">·</span><span class="brief-meta-line brief-meta-words">${this.escape(wc)}</span>` : ""}
9769
+ <span class="brief-meta-sep" aria-hidden="true">·</span>
9770
+ <span class="brief-meta-type">${this.escape(this.briefModeLabel(b))}</span>
9277
9771
  <div class="brief-signed">
9278
9772
  <div class="brief-signed-avatars">${signed}</div>
9279
9773
  </div>
@@ -9285,7 +9779,7 @@
9285
9779
  ${reportHref && !generating ? `
9286
9780
  <a href="${reportHref}" class="brief-open" target="_blank" rel="noopener">
9287
9781
  <span class="brief-open-icon">▸</span>
9288
- <span class="brief-open-label">open report</span>
9782
+ <span class="brief-open-label">${this.escape(this._t("brief_open_report"))}</span>
9289
9783
  <span class="brief-open-arrow">→</span>
9290
9784
  </a>
9291
9785
  ` : ""}
@@ -9295,19 +9789,18 @@
9295
9789
  <div class="brief-supplement-row">
9296
9790
  <button type="button" class="brief-supplement-btn" data-brief-supplement>
9297
9791
  <span class="brief-supplement-mark">+</span>
9298
- <span class="brief-supplement-label">Add a perspective · regenerate</span>
9792
+ <span class="brief-supplement-label">${this.escape(this._t("brief_supplement_btn"))}</span>
9299
9793
  </button>
9300
- <button type="button" class="brief-delete-btn" data-brief-delete data-brief-id="${this.escape(b.id)}" title="Delete this report">
9794
+ <button type="button" class="brief-delete-btn" data-brief-delete data-brief-id="${this.escape(b.id)}" title="${this.escape(this._t("brief_tab_delete_title"))}">
9301
9795
  <span class="brief-delete-mark">⌫</span>
9302
- <span class="brief-delete-label">Delete report</span>
9303
- </button>
9796
+ <span class="brief-delete-label">${this.escape(this._t("brief_delete_label"))}</span> </button>
9304
9797
  </div>
9305
9798
  ` : ""}
9306
9799
  </div>
9307
9800
 
9308
9801
  <footer class="ending-block-foot">
9309
9802
  <span class="ending-block-foot-line"></span>
9310
- <span class="ending-block-foot-label">// end of session</span>
9803
+ <span class="ending-block-foot-label">${this.escape(this._t("ending_session_foot"))}</span>
9311
9804
  <span class="ending-block-foot-line"></span>
9312
9805
  </footer>
9313
9806
  `;
@@ -9425,6 +9918,67 @@
9425
9918
  get zh() { return this.en; },
9426
9919
  },
9427
9920
 
9921
+ renderBriefLlmTrace(b) {
9922
+ const logs = Array.isArray(b.llmLogs) ? b.llmLogs : [];
9923
+ const open = b.llmLogOpen === true;
9924
+ const running = logs.filter((l) => l.status === "running").length;
9925
+ const failed = logs.filter((l) => l.status === "failed").length;
9926
+ const label = open
9927
+ ? (b.language === "zh" ? "收起模型流水" : "Hide model stream")
9928
+ : (b.language === "zh" ? "查看模型流水" : "View model stream");
9929
+ const countText = logs.length
9930
+ ? `${logs.length} call${logs.length === 1 ? "" : "s"}${running ? ` · ${running} running` : ""}${failed ? ` · ${failed} failed` : ""}`
9931
+ : (b.language === "zh" ? "等待首个调用" : "waiting for first call");
9932
+ const button = `
9933
+ <button type="button" class="brief-llm-toggle" data-brief-llm-toggle data-brief-id="${this.escape(b.id || "")}" aria-expanded="${open ? "true" : "false"}">
9934
+ <span class="brief-llm-toggle-mark">${open ? "−" : "+"}</span>
9935
+ <span>${this.escape(label)}</span>
9936
+ <span class="brief-llm-toggle-meta">${this.escape(countText)}</span>
9937
+ </button>
9938
+ `;
9939
+ if (!open) return `<div class="brief-llm-trace">${button}</div>`;
9940
+ const fmt = (ms) => {
9941
+ if (!ms || ms < 0) return "";
9942
+ const s = Math.round(ms / 1000);
9943
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
9944
+ };
9945
+ const rows = logs.map((l) => {
9946
+ const elapsed = fmt((l.finishedAt || Date.now()) - (l.startedAt || Date.now()));
9947
+ const text = (l.text || "").trim();
9948
+ const preview = text
9949
+ ? this.escape(text)
9950
+ : `<span class="brief-llm-empty">${this.escape(b.language === "zh" ? "等待模型输出…" : "waiting for output…")}</span>`;
9951
+ const meta = [
9952
+ l.modelV || "",
9953
+ elapsed,
9954
+ typeof l.totalTokens === "number" ? `${l.totalTokens} tok` : "",
9955
+ ].filter(Boolean).join(" · ");
9956
+ return `
9957
+ <div class="brief-llm-row is-${this.escape(l.status || "running")}">
9958
+ <div class="brief-llm-row-head">
9959
+ <span class="brief-llm-row-title">${this.escape(l.label || l.stage || "LLM call")}</span>
9960
+ <span class="brief-llm-row-meta">${this.escape(meta)}</span>
9961
+ </div>
9962
+ ${l.error ? `<div class="brief-llm-error">${this.escape(l.error)}</div>` : ""}
9963
+ <pre class="brief-llm-output">${preview}</pre>
9964
+ </div>
9965
+ `;
9966
+ }).join("");
9967
+ return `<div class="brief-llm-trace">${button}<div class="brief-llm-panel">${rows || `<div class="brief-llm-empty">${this.escape(countText)}</div>`}</div></div>`;
9968
+ },
9969
+
9970
+ _briefSubList(stageKey) {
9971
+ const sk = String(stageKey).replace(/-/g, "_");
9972
+ const list = [];
9973
+ for (let i = 0; i < 24; i++) {
9974
+ const k = `brief_sub_${sk}_${i}`;
9975
+ const s = this._t(k);
9976
+ if (s === k) break;
9977
+ list.push(s);
9978
+ }
9979
+ return list;
9980
+ },
9981
+
9428
9982
  /** Set up a 1s tick that re-renders the brief stages while at least
9429
9983
  * one stage is active OR the writing stage is still streaming.
9430
9984
  * Idempotent — calling repeatedly is fine. */
@@ -9518,8 +10072,7 @@
9518
10072
  // Hard ceiling · regardless of server state, flip the card to
9519
10073
  // a timed-out error so the user always has a way out.
9520
10074
  if (now - startedAt > this.BRIEF_HARD_TIMEOUT_MS) {
9521
- // System UI · always English (timeout error message).
9522
- b.error = "Brief generation timed out (no completion after 8 minutes).";
10075
+ b.error = this._t("brief_hard_timeout_msg");
9523
10076
  b.timedOut = true;
9524
10077
  this.stopBriefStageTick();
9525
10078
  this.stopBriefStallWatch();
@@ -9563,53 +10116,52 @@
9563
10116
  "scaffold-actions": { status: "pending", detail: "", progress: null, startedAt: null },
9564
10117
  write: { status: "pending", detail: "", progress: null, startedAt: null },
9565
10118
  };
9566
- const lang = b.language === "zh" ? "zh" : "en";
9567
- const chairName = b.chairName || this.currentChair?.name || "Chair";
9568
-
10119
+ // Mode-aware stage rail. The wire format emitted by emitStage()
10120
+ // in src/orchestrator/brief.ts depends on which pipeline ran:
10121
+ //
10122
+ // research-note (default) · extract → compose →
10123
+ // scaffold-{anchor,findings,cluster,actions} → write
10124
+ // (7 stages · the 4 scaffold sub-stages are driven by
10125
+ // JSON-key arrival in the streaming buffer)
10126
+ //
10127
+ // magazine / newspaper / ppt · extract → write
10128
+ // (2 stages · runBentoStage runs ONE chair-LLM call that
10129
+ // produces the BentoScaffold; composer + scaffold sub-
10130
+ // stages are skipped entirely. Each mode customises only
10131
+ // the write label so the user sees what's actually
10132
+ // being composed.)
10133
+ const isStructured = this.isStructuredBriefMode(b.mode);
10134
+ const STAGE_ORDER = isStructured
10135
+ ? ["extract", "write"]
10136
+ : ["extract", "compose", "scaffold-anchor", "scaffold-findings", "scaffold-cluster", "scaffold-actions", "write"];
10137
+ // For structured modes the write key swaps to a per-mode label
10138
+ // (`brief_stage_magazine_write_label`, etc) so the user reads
10139
+ // "Composing the magazine" instead of the generic "Writing the
10140
+ // report" copy that suits research-note's chapter-by-chapter
10141
+ // pipeline.
10142
+ const STAGE_DEFS = STAGE_ORDER.map((key) => {
10143
+ if (isStructured && key === "write") {
10144
+ return {
10145
+ key,
10146
+ label: this._t(`brief_stage_${b.mode}_write_label`),
10147
+ pipShort: this._t(`brief_stage_${b.mode}_write_pip`),
10148
+ };
10149
+ }
10150
+ const sk = key.replace(/-/g, "_");
10151
+ return {
10152
+ key,
10153
+ label: this._t(`brief_stage_${sk}_label`),
10154
+ pipShort: this._t(`brief_stage_${sk}_pip`),
10155
+ };
10156
+ });
9569
10157
  const wordCount = b.bodyMd
9570
10158
  ? (b.bodyMd.trim().match(/\S+/g) || []).length
9571
10159
  : 0;
9572
10160
 
9573
- // Stage definitions · mode-aware. The wire format emitted by
9574
- // emitStage() in src/orchestrator/brief.ts depends on which
9575
- // pipeline ran:
9576
- //
9577
- // research-note (default) · extract → compose → scaffold-
9578
- // {anchor,findings,cluster,actions} → write (7 stages · the
9579
- // 4 scaffold sub-stages are driven by JSON-key arrival in
9580
- // the streaming buffer, see SCAFFOLD_TRIGGERS in brief.ts)
9581
- //
9582
- // bento + magazine · extract → write (2 stages · runBentoStage
9583
- // runs ONE chair-LLM call that produces the BentoScaffold;
9584
- // composer and the scaffold sub-stages are skipped entirely.
9585
- // Both modes share the same 2-stage rail · only the write
9586
- // label and renderer differ)
9587
- //
9588
- // System UI · always English regardless of brief language. The
9589
- // pipeline labels are the app's voice (chrome around the
9590
- // generation), not the report content itself.
9591
- const STRUCTURED_WRITE_LABELS = {
9592
- magazine: "Composing the magazine",
9593
- newspaper: "Composing the newspaper",
9594
- ppt: "Composing the deck",
9595
- };
9596
- const STAGE_DEFS = this.isStructuredBriefMode(b.mode)
9597
- ? [
9598
- { key: "extract", label: "Reading what each director said", pipShort: "read" },
9599
- { key: "write", label: STRUCTURED_WRITE_LABELS[b.mode] || "Composing the report", pipShort: "compose" },
9600
- ]
9601
- : [
9602
- { key: "extract", label: "Reading what each director said", pipShort: "read" },
9603
- { key: "compose", label: "Picking the report shape", pipShort: "pick" },
9604
- { key: "scaffold-anchor", label: "Setting the anchor", pipShort: "anchor" },
9605
- { key: "scaffold-findings", label: "Sketching findings", pipShort: "find" },
9606
- { key: "scaffold-cluster", label: "Mapping consensus + dissent", pipShort: "split" },
9607
- { key: "scaffold-actions", label: "Drafting actions + risks", pipShort: "act" },
9608
- { key: "write", label: "Writing the report", pipShort: "write" },
9609
- ];
9610
-
10161
+ const chairDisp = (b.chairName || this.currentChair?.name)
10162
+ ? this.escape(b.chairName || this.currentChair.name)
10163
+ : this._t("brief_chair_fallback");
9611
10164
  const meta = this.BRIEF_STAGE_META;
9612
- const substages = (this.BRIEF_SUBSTAGES[lang] || this.BRIEF_SUBSTAGES.en);
9613
10165
  const stageEta = (key) => {
9614
10166
  const st = stages[key];
9615
10167
  if (st?.etaSec && typeof st.etaSec.lo === "number") return [st.etaSec.lo, st.etaSec.hi];
@@ -9664,14 +10216,9 @@
9664
10216
  // Falls through to the regular stage key for every other
9665
10217
  // stage / mode.
9666
10218
  let substageText = "";
9667
- let subKey = activeDef.key;
9668
- if (activeDef.key === "write" && this.isStructuredBriefMode(b.mode)) {
9669
- subKey = `${b.mode}-write`;
9670
- }
9671
- if (activeStatus === "active" && substages[subKey]?.length) {
9672
- const list = substages[subKey];
9673
- substageText = list[Math.floor(activeElapsed / 3) % list.length];
9674
- }
10219
+ if (activeStatus === "active") {
10220
+ const list = this._briefSubList(activeDef.key);
10221
+ if (list.length) substageText = list[Math.floor(activeElapsed / 3) % list.length]; }
9675
10222
 
9676
10223
  // Detail · cur/total directors during extract, word count during
9677
10224
  // write, otherwise the server-supplied detail string.
@@ -9679,13 +10226,11 @@
9679
10226
  if (activeDef.key === "extract" && activeStage.progress?.total) {
9680
10227
  const cur = activeStage.progress.current;
9681
10228
  const tot = activeStage.progress.total;
9682
- detailParts.push(`${cur}/${tot} director${tot === 1 ? "" : "s"}`);
9683
- } else if (activeStage.detail) {
10229
+ detailParts.push(this._t(tot === 1 ? "brief_prog_directors_one" : "brief_prog_directors", { cur, tot })); } else if (activeStage.detail) {
9684
10230
  detailParts.push(activeStage.detail);
9685
10231
  }
9686
10232
  if (activeDef.key === "write" && activeStatus === "active" && wordCount > 0) {
9687
- detailParts.push(`${wordCount} word${wordCount === 1 ? "" : "s"}`);
9688
- }
10233
+ detailParts.push(this._t(wordCount === 1 ? "brief_prog_words_one" : "brief_prog_words", { n: wordCount })); }
9689
10234
  const detailLine = detailParts.join(" · ");
9690
10235
 
9691
10236
  // Timing for active stage · ETA range while in-band, elapsed once over.
@@ -9693,11 +10238,10 @@
9693
10238
  if (activeStatus === "active") {
9694
10239
  if (activeElapsed <= activeEta[1]) {
9695
10240
  timing = activeElapsed > 0
9696
- ? `${activeElapsed}s · ~${activeEta[0]}–${activeEta[1]}s`
9697
- : `~${activeEta[0]}–${activeEta[1]}s`;
10241
+ ? this._t("brief_timing_inband", { e: activeElapsed, lo: activeEta[0], hi: activeEta[1] })
10242
+ : this._t("brief_timing_eta", { lo: activeEta[0], hi: activeEta[1] });
9698
10243
  } else {
9699
- timing = `${activeElapsed}s elapsed`;
9700
- }
10244
+ timing = this._t("brief_timing_over", { n: activeElapsed }); }
9701
10245
  }
9702
10246
 
9703
10247
  // Active-card progress line · in-band 0→95% linear, then a Zeno
@@ -9746,22 +10290,24 @@
9746
10290
 
9747
10291
  // Total ETA formatting · "1m 12s · ~3-5m left" / "已 1m 12s · 还需约 3–5 分钟"
9748
10292
  const fmtSec = (s) => {
9749
- if (s < 60) return `${s}s`;
10293
+ if (s < 60) return this._t("brief_fmt_sec", { n: s });
9750
10294
  const m = Math.floor(s / 60);
9751
10295
  const r = s % 60;
9752
- return r === 0 ? `${m}m` : `${m}m ${r}s`;
10296
+ return r === 0 ? this._t("brief_fmt_min", { n: m }) : this._t("brief_fmt_min_sec", { m, r });
9753
10297
  };
9754
10298
  const fmtRange = (lo, hi) => {
9755
- if (hi < 60) return `~${lo}–${hi}s`;
10299
+ if (hi < 60) return this._t("brief_range_sec", { lo, hi });
9756
10300
  const loM = Math.max(1, Math.round(lo / 60));
9757
10301
  const hiM = Math.max(loM, Math.round(hi / 60));
9758
- return `~${loM}-${hiM}m`;
10302
+ return this._t("brief_range_min", { lo: loM, hi: hiM });
9759
10303
  };
9760
10304
 
9761
- // System UI · always English (brief-progress kicker copy).
9762
- const totalText = `${fmtSec(totalElapsed)} elapsed · ${fmtRange(totalLo, totalHi)} left`;
9763
- const kickerCore = `// ${chairName} is preparing the minutes · ${totalText}`;
10305
+ const totalText = this._t("brief_total_line", {
10306
+ elapsed: fmtSec(totalElapsed),
10307
+ range: fmtRange(totalLo, totalHi),
10308
+ });
9764
10309
 
10310
+ const kickerCore = this._t("brief_stage_kicker", { chair: chairDisp, total: totalText });
9765
10311
  const metaHtml = (detailLine || timing)
9766
10312
  ? `<span class="brief-active-meta">` +
9767
10313
  (detailLine ? `<span class="meta-detail">${this.escape(detailLine)}</span>` : "") +
@@ -9792,8 +10338,17 @@
9792
10338
  this._briefSeenHarvestKeys = this._briefSeenHarvestKeys || {};
9793
10339
  const seenH = this._briefSeenHarvestKeys[b.id] = this._briefSeenHarvestKeys[b.id] || new Set();
9794
10340
  const harvest = Array.isArray(b.extractHarvest) ? b.extractHarvest : [];
9795
- // System UI · always English (signal-kind chip labels).
9796
- const kindLabels = { claims: "claims", evidence: "evidence", tensions: "tensions", assumptions: "assumptions", risks: "risks", opportunities: "opportunities", actions: "actions", quotes: "quotes", openQuestions: "open-q" };
10341
+ const kindLabels = {
10342
+ claims: this._t("brief_harvest_claims"),
10343
+ evidence: this._t("brief_harvest_evidence"),
10344
+ tensions: this._t("brief_harvest_tensions"),
10345
+ assumptions: this._t("brief_harvest_assumptions"),
10346
+ risks: this._t("brief_harvest_risks"),
10347
+ opportunities: this._t("brief_harvest_opportunities"),
10348
+ actions: this._t("brief_harvest_actions"),
10349
+ quotes: this._t("brief_harvest_quotes"),
10350
+ openQuestions: this._t("brief_harvest_open_questions"),
10351
+ };
9797
10352
  if (harvest.length) {
9798
10353
  const chips = harvest.map((h) => {
9799
10354
  const isFresh = !seenH.has(h.directorId);
@@ -9818,7 +10373,7 @@
9818
10373
  // Live word count during write — large and visible since it's the
9819
10374
  // most engaging signal during the long write stage.
9820
10375
  if (writeActive && wordCount > 0) {
9821
- const w = `${wordCount} word${wordCount === 1 ? "" : "s"} · still writing`;
10376
+ const w = this._t("brief_stat_writing", { n: wordCount });
9822
10377
  stats.push(`<span class="brief-stat-fact brief-stat-live">${this.escape(w)}</span>`);
9823
10378
  }
9824
10379
  const statsHtml = stats.length
@@ -9838,6 +10393,7 @@
9838
10393
  </div>
9839
10394
  <div class="brief-pip-rail">${pipHtml}</div>
9840
10395
  ${statsHtml}
10396
+ ${this.renderBriefLlmTrace(b)}
9841
10397
  </div>
9842
10398
  `;
9843
10399
  },
@@ -9882,15 +10438,9 @@
9882
10438
 
9883
10439
  /** Map internal tag id → short visible label. */
9884
10440
  noteTagLabel(tag) {
9885
- return ({
9886
- origin: "input",
9887
- obs: "obs",
9888
- insight: "claim",
9889
- warn: "drop",
9890
- crux: "crux",
9891
- soln: "pursue",
9892
- open: "ask",
9893
- })[tag] || tag;
10441
+ const k = "note_tag_" + String(tag || "").replace(/[^a-z0-9_]/g, "");
10442
+ const tr = this._t(k);
10443
+ return tr === k ? String(tag || "") : tr;
9894
10444
  },
9895
10445
 
9896
10446
  /** True when the user is "stuck to bottom" — within a small margin
@@ -10021,6 +10571,8 @@
10021
10571
  }
10022
10572
  return;
10023
10573
  }
10574
+ // Toggle voice/text delivery mode — REMOVED (mid-session switching
10575
+ // causes too many timing issues; delivery mode is set at room creation).
10024
10576
  // Pause-choice modal buttons
10025
10577
  const choice = e.target.closest("[data-pause-choice]");
10026
10578
  if (choice) {
@@ -10050,6 +10602,23 @@
10050
10602
  window.location.href = "/api/rooms/" + encodeURIComponent(app.currentRoomId) + "/export.md";
10051
10603
  return;
10052
10604
  }
10605
+ // Voice Replay · adjourned-bar action. Plays the transcript
10606
+ // back via TTS in chronological order, each director in their
10607
+ // own voice. Routed through the standalone voice-replay module
10608
+ // so the playback state machine + overlay live in one place.
10609
+ if (e.target.closest("[data-room-replay]")) {
10610
+ e.preventDefault();
10611
+ if (!app.currentRoomId) return;
10612
+ if (window.boardroomVoiceReplay && typeof window.boardroomVoiceReplay.open === "function") {
10613
+ window.boardroomVoiceReplay.open({
10614
+ roomId: app.currentRoomId,
10615
+ messages: Array.isArray(app.currentMessages) ? app.currentMessages.slice() : [],
10616
+ members: Array.isArray(app.currentMembers) ? app.currentMembers.slice() : [],
10617
+ chair: app.currentChair || null,
10618
+ });
10619
+ }
10620
+ return;
10621
+ }
10053
10622
  // Convene Follow-up · adjourned-bar action. Opens the follow-up
10054
10623
  // overlay with parent reference + form for the new question.
10055
10624
  if (e.target.closest("[data-room-followup]")) {
@@ -10242,6 +10811,17 @@
10242
10811
  app.retryBriefGeneration(targetId);
10243
10812
  return;
10244
10813
  }
10814
+ const llmToggle = e.target.closest("[data-brief-llm-toggle]");
10815
+ if (llmToggle) {
10816
+ e.preventDefault();
10817
+ const id = llmToggle.getAttribute("data-brief-id");
10818
+ const brief = id ? app._briefById(id) : app.currentBrief;
10819
+ if (brief) {
10820
+ brief.llmLogOpen = !brief.llmLogOpen;
10821
+ app.renderBrief();
10822
+ }
10823
+ return;
10824
+ }
10245
10825
  // Salvage banner · dismiss button. Just removes the banner DOM —
10246
10826
  // the failed brief stays in currentBriefs so the user can still
10247
10827
  // retry from a brief tab if they change their mind.
@@ -10436,6 +11016,43 @@
10436
11016
  if (typeof window.openNewAgent === "function") window.openNewAgent();
10437
11017
  return;
10438
11018
  }
11019
+ // ─── New-room composer · voice-mode toggle. Same three paths
11020
+ // as the websearch toggle just below: unconfigured → open
11021
+ // keys panel; on → off; off → on. Done with in-place class /
11022
+ // text mutation so the composer textarea isn't blown away by
11023
+ // a full repaint on every click.
11024
+ const voiceToggle = e.target.closest("[data-composer-voice-toggle]");
11025
+ if (voiceToggle) {
11026
+ e.preventDefault();
11027
+ const configured = app.hasAnyVoiceKey();
11028
+ if (!configured) {
11029
+ // Stale `data-configured` may say 1 if the user added then
11030
+ // removed a key without a re-render; live cache wins.
11031
+ if (typeof window.openUserSettings === "function") {
11032
+ window.openUserSettings({ section: "keys", focusProvider: "minimax" });
11033
+ }
11034
+ return;
11035
+ }
11036
+ // Live cache says configured · sync the toggle's stale attrs
11037
+ // so the next click flips rather than re-prompts.
11038
+ if (voiceToggle.getAttribute("data-configured") !== "1") {
11039
+ voiceToggle.setAttribute("data-configured", "1");
11040
+ voiceToggle.classList.remove("needs-key");
11041
+ }
11042
+ const wasOn = voiceToggle.getAttribute("data-on") === "1";
11043
+ const next = !wasOn;
11044
+ app.setComposerDeliveryMode(next ? "voice" : "text");
11045
+ voiceToggle.classList.toggle("on", next);
11046
+ voiceToggle.classList.toggle("off", !next);
11047
+ voiceToggle.setAttribute("data-on", next ? "1" : "0");
11048
+ voiceToggle.setAttribute("aria-pressed", next ? "true" : "false");
11049
+ const txt = voiceToggle.querySelector(".ap-skill-row-toggle-text");
11050
+ if (txt) txt.textContent = app._t("cmp_voice_label");
11051
+ voiceToggle.title = next
11052
+ ? "Voice mode on · directors speak aloud during the room"
11053
+ : "Voice mode off · click to enable";
11054
+ return;
11055
+ }
10439
11056
  // ─── Agent composer · websearch toggle. Three paths:
10440
11057
  // · unconfigured → confirm + open Preferences → Brave row
10441
11058
  // · on → save off + flip toggle in place
@@ -10454,10 +11071,15 @@
10454
11071
  // has it. boardroomKeys() reads through the shared _keysMeta
10455
11072
  // map, which user-settings.js patches in place after every
10456
11073
  // setProviderKey, so we get fresh truth here.
10457
- const configured = !!(app.agentComposerBraveConfigured && app.agentComposerBraveConfigured());
11074
+ const configured = !!(app.agentComposerWebSearchConfigured && app.agentComposerWebSearchConfigured());
10458
11075
  if (!configured) {
10459
- // System UI · always English (Brave key prompt confirm dialog).
10460
- 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?");
11076
+ const fallback =
11077
+ "Web Search needs Brave Search or Tavily API credentials.\n\nOpen Preferences now?";
11078
+ const ok = confirm(
11079
+ (window.I18n && typeof window.I18n.t === "function")
11080
+ ? window.I18n.t("ag_ws_need_key_confirm")
11081
+ : fallback,
11082
+ );
10461
11083
  if (ok && typeof window.openUserSettings === "function") {
10462
11084
  window.openUserSettings({ section: "keys", focusProvider: "brave" });
10463
11085
  }
@@ -10500,6 +11122,14 @@
10500
11122
  app.discardAgentSpec();
10501
11123
  return;
10502
11124
  }
11125
+ if (e.target.closest("[data-agent-spec-stop]")) {
11126
+ // Stop · same effect as Discard while generation is in flight ·
11127
+ // aborts the AbortController, which propagates to the fetch
11128
+ // (cancels the LLM call server-side) and resets composer state.
11129
+ e.preventDefault();
11130
+ app.discardAgentSpec();
11131
+ return;
11132
+ }
10503
11133
  if (e.target.closest("[data-agent-spec-redo]")) {
10504
11134
  e.preventDefault();
10505
11135
  app.redoAgentSpec();
@@ -10585,7 +11215,25 @@
10585
11215
  } else {
10586
11216
  if (kind === "tone") app.setComposerTone(v);
10587
11217
  else if (kind === "intensity") app.setComposerIntensity(v);
11218
+ else if (kind === "delivery") app.setComposerDeliveryMode(v);
10588
11219
  else if (kind === "agent-model") app.setAgentComposerModel(v);
11220
+ else if (kind === "locale") {
11221
+ // Interface language · runs through the shared I18n
11222
+ // setter so document.documentElement.lang, the persisted
11223
+ // boardroom.uiLocale storage entry, applyDom, and the
11224
+ // boardroom:locale event all fire as a unit. The trigger's
11225
+ // value span is updated explicitly because applyDom won't
11226
+ // touch a `data-cmp-dd-value` (it has no data-i18n key).
11227
+ if (window.I18n && typeof window.I18n.setLocale === "function") {
11228
+ window.I18n.setLocale(v);
11229
+ }
11230
+ if (trigger) {
11231
+ const valSpan = trigger.querySelector("[data-cmp-dd-value]");
11232
+ if (valSpan) {
11233
+ valSpan.textContent = v === "zh" ? "中文" : "EN";
11234
+ }
11235
+ }
11236
+ }
10589
11237
  }
10590
11238
  app.closeComposerDropdown();
10591
11239
  return;
@@ -10836,6 +11484,18 @@
10836
11484
  }
10837
11485
  });
10838
11486
 
11487
+ // Global one-shot listener: unlock audio playback on first user
11488
+ // interaction so voice mode works regardless of which button was
11489
+ // clicked first.
11490
+ document.addEventListener("click", function _unlockAudio() {
11491
+ app.unlockAudioPlayback();
11492
+ document.removeEventListener("click", _unlockAudio);
11493
+ });
11494
+ document.addEventListener("keydown", function _unlockAudioKey() {
11495
+ app.unlockAudioPlayback();
11496
+ document.removeEventListener("keydown", _unlockAudioKey);
11497
+ });
11498
+
10839
11499
  window.app = app;
10840
11500
 
10841
11501
  if (document.readyState === "loading") {