@songsid/agend 2.3.3 → 2.3.4-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +3 -3
  2. package/README.zh-TW.md +3 -3
  3. package/dist/agent-cli-instructions.md +1 -1
  4. package/dist/auth-api.d.ts +16 -3
  5. package/dist/auth-api.js +22 -6
  6. package/dist/auth-api.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +15 -1
  8. package/dist/authority/web-authority.js +27 -10
  9. package/dist/authority/web-authority.js.map +1 -1
  10. package/dist/backend/claude-code.js +11 -1
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +12 -0
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/types.d.ts +7 -0
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/channel/adapters/discord.js +13 -0
  17. package/dist/channel/adapters/discord.js.map +1 -1
  18. package/dist/chat-log-ids.d.ts +48 -0
  19. package/dist/chat-log-ids.js +182 -0
  20. package/dist/chat-log-ids.js.map +1 -0
  21. package/dist/classic-channel-manager.d.ts +8 -1
  22. package/dist/classic-channel-manager.js +24 -2
  23. package/dist/classic-channel-manager.js.map +1 -1
  24. package/dist/config-validator.js +18 -1
  25. package/dist/config-validator.js.map +1 -1
  26. package/dist/daemon.js +8 -1
  27. package/dist/daemon.js.map +1 -1
  28. package/dist/env-file.d.ts +2 -0
  29. package/dist/env-file.js +13 -8
  30. package/dist/env-file.js.map +1 -1
  31. package/dist/fleet-manager.d.ts +42 -1
  32. package/dist/fleet-manager.js +206 -23
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -1
  35. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +4 -0
  36. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  37. package/dist/instance-config-impact.js +1 -0
  38. package/dist/instance-config-impact.js.map +1 -1
  39. package/dist/instance-lifecycle.d.ts +2 -0
  40. package/dist/instance-lifecycle.js +23 -7
  41. package/dist/instance-lifecycle.js.map +1 -1
  42. package/dist/instructions.d.ts +1 -1
  43. package/dist/instructions.js +1 -1
  44. package/dist/instructions.js.map +1 -1
  45. package/dist/interaction-observation.d.ts +1 -0
  46. package/dist/interaction-observation.js +1 -1
  47. package/dist/interaction-observation.js.map +1 -1
  48. package/dist/locale.js +48 -0
  49. package/dist/locale.js.map +1 -1
  50. package/dist/next-steps.d.ts +12 -2
  51. package/dist/next-steps.js +19 -3
  52. package/dist/next-steps.js.map +1 -1
  53. package/dist/office-share-service.d.ts +57 -0
  54. package/dist/office-share-service.js +76 -11
  55. package/dist/office-share-service.js.map +1 -1
  56. package/dist/office-share.d.ts +8 -0
  57. package/dist/office-share.js +34 -0
  58. package/dist/office-share.js.map +1 -1
  59. package/dist/outbound-schemas.js +1 -1
  60. package/dist/outbound-schemas.js.map +1 -1
  61. package/dist/page-share.d.ts +122 -0
  62. package/dist/page-share.js +199 -0
  63. package/dist/page-share.js.map +1 -0
  64. package/dist/public-preview.d.ts +4 -0
  65. package/dist/public-preview.js +5 -4
  66. package/dist/public-preview.js.map +1 -1
  67. package/dist/public-web-gateway.js +5 -0
  68. package/dist/public-web-gateway.js.map +1 -1
  69. package/dist/quickstart-api.d.ts +5 -0
  70. package/dist/quickstart-api.js +1 -1
  71. package/dist/quickstart-api.js.map +1 -1
  72. package/dist/quickstart.d.ts +40 -0
  73. package/dist/quickstart.js +174 -28
  74. package/dist/quickstart.js.map +1 -1
  75. package/dist/remote-card-answer.js +2 -0
  76. package/dist/remote-card-answer.js.map +1 -1
  77. package/dist/remote-card-prompt.js +4 -2
  78. package/dist/remote-card-prompt.js.map +1 -1
  79. package/dist/selection-card.d.ts +21 -0
  80. package/dist/selection-card.js +129 -0
  81. package/dist/selection-card.js.map +1 -0
  82. package/dist/settings-api.js +4 -0
  83. package/dist/settings-api.js.map +1 -1
  84. package/dist/settings-change.js +5 -0
  85. package/dist/settings-change.js.map +1 -1
  86. package/dist/settings-effect.js +2 -0
  87. package/dist/settings-effect.js.map +1 -1
  88. package/dist/setup-host.js +2 -2
  89. package/dist/setup-host.js.map +1 -1
  90. package/dist/setup-wizard.js +2 -2
  91. package/dist/setup-wizard.js.map +1 -1
  92. package/dist/types.d.ts +10 -0
  93. package/dist/ui/chat-store.js +8 -1
  94. package/dist/ui/chat-strings.js +38 -2
  95. package/dist/ui/chat-thread.js +52 -10
  96. package/dist/ui/page-share-dialog.js +191 -0
  97. package/dist/ui/panel-chat.js +14 -0
  98. package/dist/ui/panel-notices.js +98 -0
  99. package/dist/ui/panel-settings.js +9 -2
  100. package/dist/ui/preview-pages.js +41 -0
  101. package/dist/ui/preview-panel-actions.js +161 -0
  102. package/dist/ui/preview-panel.js +209 -28
  103. package/dist/ui/preview-versions.js +155 -0
  104. package/dist/ui/preview.js +88 -3
  105. package/dist/ui/settings-registry.js +1 -0
  106. package/dist/ui/settings-strings.js +4 -0
  107. package/dist/ui/shared/app-i18n.js +2 -2
  108. package/dist/ui/shared/app-route.js +4 -0
  109. package/dist/ui/shared/app-shell.js +5 -3
  110. package/dist/ui/shared/app-store.js +71 -1
  111. package/dist/ui/shared/app-stream.js +1 -1
  112. package/dist/ui/shared/app.css +57 -3
  113. package/dist/ui/shared/app.js +22 -1
  114. package/dist/ui/shared/signin.js +12 -1
  115. package/dist/ui/shared/ui-icons.js +3 -0
  116. package/dist/ui/shared/web-authorize.js +3 -1
  117. package/dist/web-api.d.ts +26 -0
  118. package/dist/web-api.js +180 -1
  119. package/dist/web-api.js.map +1 -1
  120. package/dist/web-auth.js +7 -1
  121. package/dist/web-auth.js.map +1 -1
  122. package/dist/web-login.d.ts +2 -0
  123. package/dist/web-login.js +3 -1
  124. package/dist/web-login.js.map +1 -1
  125. package/dist/web-notices.d.ts +51 -0
  126. package/dist/web-notices.js +118 -0
  127. package/dist/web-notices.js.map +1 -0
  128. package/dist/web-only-fleet.d.ts +22 -0
  129. package/dist/web-only-fleet.js +13 -0
  130. package/dist/web-only-fleet.js.map +1 -0
  131. package/dist/web-preview.d.ts +32 -3
  132. package/dist/web-preview.js +52 -19
  133. package/dist/web-preview.js.map +1 -1
  134. package/dist/web-session.d.ts +7 -0
  135. package/dist/web-session.js +2 -0
  136. package/dist/web-session.js.map +1 -1
  137. package/dist/web-settings-patch.d.ts +3 -1
  138. package/dist/web-settings-patch.js +4 -1
  139. package/dist/web-settings-patch.js.map +1 -1
  140. package/dist/web-shell-routes.d.ts +2 -0
  141. package/dist/web-shell-routes.js +3 -0
  142. package/dist/web-shell-routes.js.map +1 -1
  143. package/package.json +1 -1
@@ -0,0 +1,155 @@
1
+ // #2911 (1): the versions of a page in the preview panel, and the changes from one to the next. Pure functions: the
2
+ // panel (preview-panel.js) draws them. Nothing here runs a preview — a version is shown as any block is, waiting for
3
+ // Preview (#1481 §14).
4
+ import "./chat-render.js";
5
+
6
+ const R = () => globalThis.AgendChatRender;
7
+
8
+ /** The text of a block's <title>, or "" — what says two blocks are versions of one thing. */
9
+ export function htmlTitle(code) {
10
+ const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(String(code || ""));
11
+ return m ? m[1].replace(/\s+/g, " ").trim() : "";
12
+ }
13
+
14
+ /**
15
+ * Every version of the shown block in the loaded thread, oldest first, and where the shown one is in it.
16
+ * - An attached file: the agent's attachments of the same name (case-insensitive). Their HTML is read when picked (code: null).
17
+ * - A titled block: the agent's complete ```html blocks with the same <title>.
18
+ * - An untitled block: the agent's complete untitled blocks — a titled one is another page.
19
+ * Only agent replies count, and only complete blocks, as for a card. `index` is -1 (and `list` is just the shown block)
20
+ * when the block is no longer in the thread.
21
+ */
22
+ export function versionsOf(open, msgs, keyOf) {
23
+ if (!open) return { list: [], index: -1 };
24
+ const list = [];
25
+ const all = Array.isArray(msgs) ? msgs : [];
26
+ if (open.att) {
27
+ const name = String(open.att.name || "").trim().toLowerCase();
28
+ for (const x of all) {
29
+ if (x.role !== "agent" || !Array.isArray(x.attachments)) continue;
30
+ const msgKey = keyOf(x);
31
+ for (const a of x.attachments) {
32
+ if (!R().isHtmlAttachment(a) || a.name.trim().toLowerCase() !== name) continue;
33
+ list.push({ key: `${msgKey}:a${a.id}`, instance: open.instance, msgKey, att: { id: a.id, name: a.name, size: a.size }, code: null, sender: x.sender, ts: x.ts });
34
+ }
35
+ }
36
+ } else {
37
+ const title = htmlTitle(open.code);
38
+ for (const x of all) {
39
+ if (x.role !== "agent") continue;
40
+ const msgKey = keyOf(x);
41
+ R().htmlFences(x.text).forEach((f, n) => {
42
+ if (!f.terminated || htmlTitle(f.code) !== title) return;
43
+ list.push({ key: `${msgKey}:f${n}`, instance: open.instance, msgKey, n, code: f.code, sender: x.sender, ts: x.ts });
44
+ });
45
+ }
46
+ }
47
+ const index = list.findIndex(v => v.key === open.key);
48
+ return index < 0 ? { list: [open], index: -1 } : { list, index };
49
+ }
50
+
51
+ // ── Changes: a line diff (Myers), bounded ──
52
+
53
+ /** Past these, "Changes" says the versions are too large or too different to compare here. */
54
+ export const DIFF_LIMITS = { maxChars: 5 * 1024 * 1024, maxInputLines: 50000, maxEdits: 2000, context: 3, maxLines: 3000 };
55
+
56
+ /**
57
+ * How many lines `lines()` makes of `s`, counted without splitting it (#2915 review: a budget before anything is
58
+ * built) — LF, CRLF and a lone CR each end a line, and a last line without one counts; it stops counting past `max`
59
+ * (anything over `max` is then reported as max + 1).
60
+ */
61
+ export function lineCount(s, max = Infinity) {
62
+ if (!s) return 0;
63
+ let breaks = 0;
64
+ for (let i = 0; i < s.length; i++) {
65
+ const c = s.charCodeAt(i);
66
+ if (c === 10 || c === 13) {
67
+ if (c === 13 && s.charCodeAt(i + 1) === 10) i++;
68
+ if (++breaks > max) return max + 1;
69
+ }
70
+ }
71
+ const last = s.charCodeAt(s.length - 1);
72
+ return Math.min(breaks + (last === 10 || last === 13 ? 0 : 1), max + 1);
73
+ }
74
+
75
+ const lines = (s) => { if (!s) return []; const v = String(s).replace(/\r\n?/g, "\n").split("\n"); if (v.length > 1 && v[v.length - 1] === "") v.pop(); return v; };
76
+
77
+ /**
78
+ * The line edits from `a` to `b`: [{ t: " " | "-" | "+", text }] in order, or null when it cannot be done within
79
+ * DIFF_LIMITS: either side over maxChars or maxInputLines (checked before anything is split or built: an op per line
80
+ * is made only under that budget), or more than maxEdits lines differ after the common head and tail.
81
+ */
82
+ export function lineDiff(a, b, limits = DIFF_LIMITS) {
83
+ const sa = String(a ?? ""), sb = String(b ?? "");
84
+ if (sa.length > limits.maxChars || sb.length > limits.maxChars) return null;
85
+ if (lineCount(sa, limits.maxInputLines) > limits.maxInputLines || lineCount(sb, limits.maxInputLines) > limits.maxInputLines) return null;
86
+ a = sa; b = sb;
87
+ const A = lines(a), B = lines(b);
88
+ let head = 0;
89
+ while (head < A.length && head < B.length && A[head] === B[head]) head++;
90
+ let tail = 0;
91
+ while (tail < A.length - head && tail < B.length - head && A[A.length - 1 - tail] === B[B.length - 1 - tail]) tail++;
92
+ const x = A.slice(head, A.length - tail), y = B.slice(head, B.length - tail);
93
+ const mid = myers(x, y, limits.maxEdits);
94
+ if (!mid) return null;
95
+ return [...A.slice(0, head).map(text => ({ t: " ", text })), ...mid, ...A.slice(A.length - tail).map(text => ({ t: " ", text }))];
96
+ }
97
+
98
+ /** Myers' O((N+M)·D) shortest edit script, with D capped (the trace is kept for the walk back). */
99
+ function myers(a, b, maxD) {
100
+ const n = a.length, m = b.length, max = n + m;
101
+ if (max === 0) return [];
102
+ const off = max + 1, v = new Int32Array(2 * max + 3), trace = [];
103
+ for (let d = 0; d <= Math.min(max, maxD); d++) {
104
+ trace.push(v.slice(off - d - 1, off + d + 2));
105
+ for (let k = -d; k <= d; k += 2) {
106
+ let i = k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]) ? v[off + k + 1] : v[off + k - 1] + 1;
107
+ let j = i - k;
108
+ while (i < n && j < m && a[i] === b[j]) { i++; j++; }
109
+ v[off + k] = i;
110
+ if (i >= n && j >= m) return walkBack(a, b, trace, d);
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function walkBack(a, b, trace, dEnd) {
117
+ const out = [];
118
+ let i = a.length, j = b.length;
119
+ for (let d = dEnd; d >= 0; d--) {
120
+ const vd = trace[d], at = (k) => vd[k + d + 1]; // trace[d] holds v[-d-1 .. d+1] before step d
121
+ const k = i - j;
122
+ if (d === 0) { while (i > 0 && j > 0) { out.push({ t: " ", text: a[--i] }); j--; } break; }
123
+ const down = k === -d || (k !== d && at(k - 1) < at(k + 1));
124
+ const pk = down ? k + 1 : k - 1, pi = at(pk), pj = pi - pk;
125
+ while (i > pi && j > pj) { out.push({ t: " ", text: a[--i] }); j--; }
126
+ if (down) out.push({ t: "+", text: b[--j] }); else out.push({ t: "-", text: a[--i] });
127
+ }
128
+ return out.reverse();
129
+ }
130
+
131
+ /**
132
+ * The edits as hunks with `context` lines around each change, as a unified diff shows them:
133
+ * [{ oldStart, oldLines, newStart, newLines, lines: [{ t, text }] }] (1-based starts). [] when nothing changed.
134
+ */
135
+ export function diffHunks(ops, context = DIFF_LIMITS.context) {
136
+ // Each op's line numbers on either side (before it), then the changed spans widened by `context` and merged.
137
+ const oldAt = [], newAt = [];
138
+ let o = 1, n = 1;
139
+ for (const op of ops) { oldAt.push(o); newAt.push(n); if (op.t !== "+") o++; if (op.t !== "-") n++; }
140
+ const ranges = [];
141
+ ops.forEach((op, idx) => {
142
+ if (op.t === " ") return;
143
+ const from = Math.max(0, idx - context), to = Math.min(ops.length - 1, idx + context);
144
+ const last = ranges[ranges.length - 1];
145
+ if (last && from <= last.to + 1) last.to = Math.max(last.to, to); else ranges.push({ from, to });
146
+ });
147
+ return ranges.map(({ from, to }) => {
148
+ const part = ops.slice(from, to + 1);
149
+ return {
150
+ oldStart: oldAt[from], newStart: newAt[from],
151
+ oldLines: part.filter(x => x.t !== "+").length, newLines: part.filter(x => x.t !== "-").length,
152
+ lines: part,
153
+ };
154
+ });
155
+ }
@@ -249,6 +249,7 @@
249
249
  if (bytes > LIMITS.askBytes && !(opts && opts.anyway)) { ui.state("large", say("pvLarge", "This file is large ({0}). Preview anyway?", sizeLabel(bytes))); return false; }
250
250
  stopAll("another");
251
251
  starts++;
252
+ resetErrors(key);
252
253
  var doc = holder.ownerDocument;
253
254
  var iframe = mountPreview(doc);
254
255
  var card = {
@@ -285,6 +286,7 @@
285
286
  var card = live.get(key);
286
287
  if (!card) return false;
287
288
  live.delete(key);
289
+ if (card.picking) { card.picking = false; tellPick(key, null, false); }
288
290
  if (card.readyTimer) root.clearTimeout(card.readyTimer);
289
291
  if (card.watchdogTimer) root.clearTimeout(card.watchdogTimer);
290
292
  if (card.iframe.parentNode) card.iframe.parentNode.removeChild(card.iframe);
@@ -310,13 +312,93 @@
310
312
 
311
313
  // ── Messages from frames (design §4.2) ──────────────────
312
314
 
313
- var KEYS = { ready: "boot,ch,type,v", resize: "ch,height,type,v", heartbeat: "ch,type,v" };
315
+ var KEYS = { ready: "boot,ch,type,v", resize: "ch,height,type,v", heartbeat: "ch,type,v", error: "ch,col,kind,line,message,type,v",
316
+ pick: "ch,h,selector,text,type,v,w,x,y", pickend: "ch,type,v" };
317
+
318
+ // ── #2911: the errors a running preview reported (design §4.1 amendment) ──
319
+ // Recorded only: a count, and the first few distinct ones as one line of plain text each. Nothing here passes them on;
320
+ // a person's click in the panel (preview-panel-actions.js) puts them in the composer, quoted, for them to edit.
321
+ var ERRORS = { maxCount: 999, keep: 5, messageMax: 200, rawMax: 1000, notifyMs: 200 };
322
+ var ERROR_KINDS = { error: 1, rejection: 1, console: 1, blocked: 1 }; // blocked: #2911 (7), a CSP-blocked script/style/font URL
323
+ var errorLog = new Map(); // frame key → { count, items: [{ kind, message, line, col }] }
324
+ var errorListeners = new Set();
325
+ var errorNotify = new Map(); // frame key → a pending notification's timer
326
+ function oneLine(v, max) {
327
+ // Format characters (\p{Cf}: bidi overrides and isolates, zero-widths, U+FEFF…) go first: a quoted line must read
328
+ // as its bytes say (#2914 review — the Trojan Source reordering, text hidden between zero-widths).
329
+ var t = String(v).replace(/\p{Cf}+/gu, "").replace(/[\s\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ").trim();
330
+ var chars = Array.from(t);
331
+ return chars.length > max ? chars.slice(0, max - 1).join("") + "\u2026" : t;
332
+ }
333
+ function tellErrors(key) {
334
+ if (errorNotify.has(key)) return;
335
+ errorNotify.set(key, root.setTimeout(function () {
336
+ errorNotify.delete(key);
337
+ errorListeners.forEach(function (fn) { try { fn(key); } catch (e) { /* one listener's error stops no other */ } });
338
+ }, ERRORS.notifyMs));
339
+ }
340
+ // A new run: its chip goes at once (not after the notification delay), and a notification still pending is dropped.
341
+ function resetErrors(key) {
342
+ if (!errorLog.delete(key)) return;
343
+ if (errorNotify.has(key)) { root.clearTimeout(errorNotify.get(key)); errorNotify.delete(key); }
344
+ errorListeners.forEach(function (fn) { try { fn(key); } catch (e) { /* one listener's error stops no other */ } });
345
+ }
346
+ function recordError(card, d) {
347
+ if (!Object.prototype.hasOwnProperty.call(ERROR_KINDS, d.kind) || typeof d.message !== "string" || d.message.length > ERRORS.rawMax) return;
348
+ var pos = function (n) { return typeof n === "number" && isFinite(n) && Math.floor(n) === n && n >= 0 && n <= 1e7; };
349
+ if (!pos(d.line) || !pos(d.col)) return;
350
+ var log = errorLog.get(card.key);
351
+ if (!log) { log = { count: 0, items: [] }; errorLog.set(card.key, log); }
352
+ if (log.count >= ERRORS.maxCount) return;
353
+ log.count++;
354
+ var item = { kind: d.kind, message: oneLine(d.message, ERRORS.messageMax) || "(no message)", line: d.line, col: d.col };
355
+ if (log.items.length < ERRORS.keep && !log.items.some(function (x) { return x.kind === item.kind && x.message === item.message && x.line === item.line && x.col === item.col; })) log.items.push(item);
356
+ tellErrors(card.key);
357
+ }
358
+ /** What the preview under `key` reported since it started: { count, items } (copies), or null when nothing. */
359
+ function errors(key) {
360
+ var log = errorLog.get(key);
361
+ return log ? { count: log.count, items: log.items.map(function (x) { return { kind: x.kind, message: x.message, line: x.line, col: x.col }; }) } : null;
362
+ }
363
+ // ── #2911: pick mode — the person points at an element of a running preview (design §4.1 amendment) ──
364
+ // Only on a person's request (pick(key, true)), and only one answer per request: a `pick` from a frame that was not
365
+ // asked is dropped. The answer is the page's text — a selector it built and the element's words — kept one line each
366
+ // and handed to the listeners; the panel puts it in the composer for the person to edit. Nothing here sends it.
367
+ var PICK = { selectorMax: 300, textMax: 200 };
368
+ var pickListeners = new Set();
369
+ function tellPick(key, result, on) { pickListeners.forEach(function (fn) { try { fn(key, result, on); } catch (e) { /* one listener's error stops no other */ } }); }
370
+ /** Turn pick mode on or off for the running preview `key`; false when it is not running (rendered). */
371
+ function pick(key, on) {
372
+ var card = live.get(key);
373
+ if (!card || !card.rendered) return false;
374
+ card.picking = !!on;
375
+ try { card.iframe.contentWindow.postMessage({ v: 1, type: "pick", ch: card.ch, on: !!on }, "*"); } catch (e) { /* the frame went: its stop says so */ }
376
+ tellPick(key, null, card.picking);
377
+ return true;
378
+ }
379
+ function picking(key) { var c = live.get(key); return !!(c && c.picking); }
380
+ /** fn(key, result | null, on): a picked element ({ selector, text, x, y, w, h }), or the mode changing; returns the unsubscribe. */
381
+ function onPick(fn) { pickListeners.add(fn); return function () { pickListeners.delete(fn); }; }
382
+ function recordPick(card, d) {
383
+ if (!card.picking) return;
384
+ if (d.type === "pickend") { card.picking = false; tellPick(card.key, null, false); return; }
385
+ var box = function (n) { return typeof n === "number" && isFinite(n) && Math.floor(n) === n && n >= 0 && n < 1e6; };
386
+ if (typeof d.selector !== "string" || d.selector.length > 1000 || typeof d.text !== "string" || d.text.length > 1000) return;
387
+ if (!box(d.x) || !box(d.y) || !box(d.w) || !box(d.h)) return;
388
+ card.picking = false;
389
+ tellPick(card.key, { selector: oneLine(d.selector, PICK.selectorMax), text: oneLine(d.text, PICK.textMax), x: d.x, y: d.y, w: d.w, h: d.h }, false);
390
+ }
391
+
392
+ /** fn(key) after a preview's errors changed (at most every 200 ms per preview); returns the unsubscribe. */
393
+ function onErrors(fn) { errorListeners.add(fn); return function () { errorListeners.delete(fn); }; }
314
394
 
315
395
  /**
316
396
  * The only window message listener. A message counts only from one of OUR frames (event.source is that frame's
317
- * window, and its origin is the opaque "null"), with one of three exact shapes; each has one effect, on that frame
397
+ * window, and its origin is the opaque "null"), with one of six exact shapes; each has one effect, on that frame
318
398
  * only: ready → send the HTML once (boot checked, opt-in checked again), resize → its height, heartbeat → its
319
- * watchdog. It has no reference to the chat, the composer, the fleet's API, navigation or storage.
399
+ * watchdog, error (#2911) → its error log (recorded, never passed on), pick / pickend (#2911) → the answer to the
400
+ * person's own pick request, handed to the pick listeners (dropped when none was asked). It has no reference to the chat, the
401
+ * composer, the fleet's API, navigation or storage.
320
402
  */
321
403
  function onMessage(event) {
322
404
  var card = null;
@@ -344,6 +426,8 @@
344
426
  armWatchdog(card);
345
427
  return;
346
428
  }
429
+ if (d.type === "error") { recordError(card, d); return; }
430
+ if (d.type === "pick" || d.type === "pickend") { recordPick(card, d); return; }
347
431
  if (typeof d.height !== "number" || !isFinite(d.height) || Math.floor(d.height) !== d.height) return;
348
432
  requestHeight(card, d.height);
349
433
  }
@@ -377,6 +461,7 @@
377
461
 
378
462
  return {
379
463
  init: init, availability: availability, optedIn: optedIn, setOptIn: setOptIn, never: never, setNever: setNever, onChange: onChange, listeners: listeners, onStorage: onStorage,
464
+ errors: errors, onErrors: onErrors, ERRORS: ERRORS, pick: pick, picking: picking, onPick: onPick, PICK: PICK,
380
465
  start: start, startCount: startCount, stop: stop, stopAll: stopAll, stopIn: stopIn, running: running, liveCount: liveCount, liveFrame: liveFrame,
381
466
  mountPreview: mountPreview, onMessage: onMessage, BANNER: BANNER, LIMITS: LIMITS,
382
467
  /** The banner every running preview carries, in the page's language (#1554); BANNER is its English. */
@@ -65,6 +65,7 @@ const ROWS = [
65
65
  // Fleet · Web dashboard
66
66
  ["dash.log-tab", "web-dashboard", "#webLogTab", "settings.webLogTab", "web.log_tab", ["log tab", "steps", "log"], ["Log 分頁", "紀錄", "步驟"]],
67
67
  ["dash.usage-public", "web-dashboard", "#webUsagePublic", "settings.webUsagePublic", "web.usage_report_on_public_link", ["usage", "public link", "cost", "tokens"], ["用量", "公開連結", "費用"]],
68
+ ["dash.preview-cdn", "web-dashboard", "#webPreviewCdn", "settings.webPreviewCdn", "web.preview_cdn", ["cdn", "libraries", "html preview", "chart", "jsdelivr", "cdnjs"], ["CDN", "函式庫", "HTML 預覽", "圖表"]],
68
69
  ["dash.office-levels", "web-dashboard", "#webOfficeLevels", "settings.webOfficeLevels", "web.office_levels", ["office", "level", "badge", "profile card"], ["辦公室", "等級", "徽章", "名片"]],
69
70
  ["dash.app-icon", "web-dashboard", ".s-app-icon", "settings.appIconTitle", null, ["app icon", "favicon", "home screen", "icon"], ["App 圖示", "圖示", "主畫面"]],
70
71
  ["dash.card-limits", "web-dashboard", ".s-card-limits", "settings.cardLimitsTitle", "web.appearance", ["upload limits", "portrait", "banner", "card limits"], ["上傳限制", "身分卡上傳限制"]],
@@ -40,6 +40,8 @@ register("settings", {
40
40
  webEcho: "Web echo",
41
41
  webEchoHint: "Copies web messages to the bound Telegram or Discord topic. ClassicBot rooms follow it unless a room sets its own.",
42
42
  webOfficeLevels: "Office levels", webOfficeLevelsHint: "Each agent's level from its lifetime work, on its name tag in the Office and on its profile card. Off removes the badges and the card's level rows. The token counts behind a level follow the usage report's rules. Applies at once.",
43
+ webPreviewCdn: "HTML previews may load libraries from cdnjs / jsDelivr",
44
+ webPreviewCdnHint: "The page's scripts may load libraries from cdnjs (/ajax/libs/) and jsDelivr (/npm/); those requests leave this machine, so the CDN sees them, and a page could use them to send data out. Scripts, styles and fonts only — a preview still cannot fetch or post anything. Off by default; applies to the next preview started, on the public link too.",
43
45
  webUsagePublic: "Usage report over the public link", webUsagePublicHint: "Fleet → Usage (up to 400 days of each agent's tokens, requests, messages and API-equivalent amounts) is offered over the temporary public link too. Off by default; guests never see it. Applies at once.",
44
46
  webLogTab: "Log tab: each agent's steps", webLogTabHint: "Shows each instance's prompts, replies, tool calls and results, and fleet events in its Log tab, read from its own transcript only while the tab is open. Secret values are masked. Also over the public link. Turning it on waits for a fleet admin's confirmation.",
45
47
  general: "General Settings",
@@ -721,6 +723,8 @@ register("settings", {
721
723
  webEcho: "Web 同步",
722
724
  webEchoHint: "將網頁訊息複製到綁定的 Telegram 或 Discord 主題。ClassicBot 聊天室也照這個設定,除非該聊天室另外設定。",
723
725
  webOfficeLevels: "辦公室等級", webOfficeLevelsHint: "每個 agent 依終身工作量得到的等級,顯示在辦公室的名牌與名片上。關閉會移除徽章與名片上的等級列。等級背後的 token 數字依用量報告的規則顯示。變更立即生效。",
726
+ webPreviewCdn: "HTML 預覽可從 cdnjs / jsDelivr 載入函式庫",
727
+ webPreviewCdnHint: "頁面的指令碼可以從 cdnjs(/ajax/libs/)和 jsDelivr(/npm/)載入函式庫;這些請求會離開這台電腦,CDN 會看到它們,頁面也可能藉此把資料傳出去。只限指令碼、樣式與字型——預覽仍然無法擷取或送出任何資料。預設關閉;之後開始的預覽才會套用,公開連結也一樣。",
724
728
  webUsagePublic: "透過公開連結提供用量報告", webUsagePublicHint: "Fleet → 用量(最多 400 天、每個 agent 的 token、請求、訊息與依牌價換算的 API 等值)也會透過臨時公開連結提供。預設關閉;訪客一律看不到。變更立即生效。",
725
729
  webLogTab: "紀錄分頁:每個 agent 的步驟", webLogTabHint: "在每個 instance 的「紀錄」分頁顯示提示、回覆、工具呼叫與結果,以及 fleet 事件;只在分頁開著時從它自己的對話紀錄讀取。機密值會遮蔽。公開連結也看得到。開啟需 fleet admin 確認。",
726
730
  general: "一般設定",
@@ -105,7 +105,7 @@ register("app", {
105
105
  restartRefused: "Restart refused: {0}",
106
106
  pendingSent: "Sent for confirmation — a fleet admin confirms it in chat.",
107
107
  pendingSelfSent: "Confirmed by your account (this browser is web-verified) — applying.",
108
- needsNav: "Needs you", needsTab: "Needs you", needsCount: "{0} waiting",
108
+ needsNav: "Needs you", needsTab: "Needs you", needsCount: "{0} waiting", noticesNav: "Notices", noticesCount: "{0} unread",
109
109
  // #2293: global search — chats, cross-instance messages and tasks (not the sidebar's filter of agents).
110
110
  search: "Search", searchTitle: "Search chats, messages between agents and tasks ({0})",
111
111
  confirmTitle: "Please confirm", confirmOk: "Continue", cancel: "Cancel",
@@ -152,7 +152,7 @@ register("app", {
152
152
  restartRefused: "重新啟動被拒絕:{0}",
153
153
  pendingSent: "已送出確認——由 fleet 管理員在聊天中確認。",
154
154
  pendingSelfSent: "已由你的帳號確認(這個瀏覽器已通過網頁驗證)——套用中。",
155
- needsNav: "等你處理", needsTab: "待處理", needsCount: "{0} 件待處理",
155
+ needsNav: "等你處理", needsTab: "待處理", needsCount: "{0} 件待處理", noticesNav: "通知", noticesCount: "{0} 則未讀",
156
156
  search: "搜尋", searchTitle: "搜尋聊天、agent 之間的訊息與任務({0})",
157
157
  confirmTitle: "請確認", confirmOk: "繼續", cancel: "取消",
158
158
  sidePanelLeave: "不儲存就離開「{0}」?你在那裡的修改會遺失。", sidePanelDiscard: "捨棄修改",
@@ -30,6 +30,7 @@ export function parseRoute(pathname) {
30
30
  if (pathname === "/ui") return { panel: "chat", instance: null };
31
31
  if (pathname === "/ui/fleet") return { panel: "fleet", tab: "tasks" };
32
32
  if (pathname === "/ui/needs") return { panel: "needs" };
33
+ if (pathname === NOTICES_PATH) return { panel: "notices" }; // #2910 D2: a web-only fleet's Notices
33
34
  // #1523 N2: one instance's Details (`agent` is no Fleet tab; a tab is one segment).
34
35
  let m = /^\/ui\/fleet\/agent\/([^/]+)$/.exec(pathname);
35
36
  if (m) return named("details", m[1]);
@@ -82,6 +83,7 @@ export function rememberFleetTab(tab) {
82
83
  try { localStorage.setItem(LAST_FLEET_TAB_KEY, tab); } catch { /* this page only */ }
83
84
  }
84
85
  export const NEEDS_PATH = "/ui/needs";
86
+ export const NOTICES_PATH = "/ui/notices";
85
87
  /** A section's address; an old name gives its new one (and the place in it: `chat#classicbot`). */
86
88
  export function settingsPath(name) {
87
89
  const section = (name && legacySection(name)) || name;
@@ -109,6 +111,7 @@ export function routePath(route) {
109
111
  if (route.panel === "fleet") return fleetPath(route.tab);
110
112
  if (route.panel === "settings") return settingsPath(route.section);
111
113
  if (route.panel === "needs") return NEEDS_PATH;
114
+ if (route.panel === "notices") return NOTICES_PATH;
112
115
  if (route.panel === "view") return viewPath(route.instance);
113
116
  if (route.panel === "details") return detailsPath(route.instance);
114
117
  if (route.panel === "log") return logPath(route.instance);
@@ -140,6 +143,7 @@ export function textLinkSegments(text, links) {
140
143
  export function routeKey(route) {
141
144
  if (!route) return "none";
142
145
  if (route.panel === "needs") return "needs:";
146
+ if (route.panel === "notices") return "notices:";
143
147
  return `${route.panel}:${route.panel === "fleet" ? route.tab : route.panel === "settings" ? route.section : route.instance ?? ""}`;
144
148
  }
145
149
 
@@ -10,7 +10,7 @@ import { t, lang, setLang, onLang } from "./app-i18n.js";
10
10
  import { appStore, createStore, guestBadge, guestPage, guestTrusted, useStore } from "./app-store.js";
11
11
  import { navStore } from "./app-nav.js";
12
12
  import { toast } from "./ui-toast.js";
13
- import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, fleetEntryPath } from "./app-route.js";
13
+ import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, NOTICES_PATH, fleetEntryPath } from "./app-route.js";
14
14
  import { SessionMenu } from "./app-session.js";
15
15
  import { onPrefsChanged, savePref } from "./app-prefs.js";
16
16
  import { applyPageTheme, pageNow, resolvedMode, themesStore } from "./app-themes.js";
@@ -328,8 +328,8 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
328
328
  const canCreate = useCanCreate();
329
329
  const on = (panel) => !!route && route.panel === panel;
330
330
  // A count badge is hidden at 0; its number is also in the link's accessible name.
331
- const navLink = (panel, href, icon, label, count = 0) => html`<a class=${`side-row${on(panel) ? " active" : ""}`} href=${href}
332
- aria-current=${on(panel) ? "page" : undefined} aria-label=${count ? `${label} (${t("app.needsCount", count)})` : undefined} onClick=${closeDrawer}>
331
+ const navLink = (panel, href, icon, label, count = 0, countText = "app.needsCount") => html`<a class=${`side-row${on(panel) ? " active" : ""}`} href=${href}
332
+ aria-current=${on(panel) ? "page" : undefined} aria-label=${count ? `${label} (${t(countText, count)})` : undefined} onClick=${closeDrawer}>
333
333
  <${Icon} name=${icon} /><span class="grow">${label}</span>${count ? html`<span class="nav-count" aria-hidden="true">${count}</span>` : null}</a>`;
334
334
  const needsCount = Array.isArray(app.needs) ? app.needs.length : 0;
335
335
  // View-only (an anonymous reader, #1408 §3): View and a way to sign in — nothing that needs a session is rendered.
@@ -379,6 +379,8 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
379
379
  <nav data-trust="navigation" class="side-nav" aria-label=${t("app.menu")}>
380
380
  ${overview}
381
381
  ${navLink("needs", NEEDS_PATH, "inbox", t("app.needsNav"), needsCount)}
382
+ ${/* #2910 D2: a web-only fleet (no chat connection) has Notices — what General would have said. */
383
+ app.connections === 0 && app.notices && app.notices.enabled ? navLink("notices", NOTICES_PATH, "bell", t("app.noticesNav"), app.notices.unread | 0, "app.noticesCount") : null}
382
384
  ${searchOffered(canCreate) ? searchRow() : null}
383
385
  ${canCreate && onNewInstance ? html`<button type="button" class="side-row side-new" onClick=${onNewInstance}><${Icon} name="plus" /><span class="grow">${t("app.newAgent")}</span></button>` : null}
384
386
  </nav>
@@ -63,7 +63,77 @@ export function applyStatus(d) {
63
63
  // #1685: host memory pressure slowing or holding new agents ("elevated" | "critical"), else null.
64
64
  hostMemory: d.host_memory === "elevated" || d.host_memory === "critical" ? d.host_memory : null,
65
65
  // #2015: while it lasts, the largest processes in the fleet and whose they are (at most 3; anything else dropped).
66
- hostMemoryTop: hostMemoryTop(d.host_memory_top) });
66
+ hostMemoryTop: hostMemoryTop(d.host_memory_top),
67
+ // #2910 D2 (#2925 review): the newest Notice on a web-only fleet — a polling page reads the list again when it moves.
68
+ noticesNewest: Number.isSafeInteger(d.notices_newest) ? d.notices_newest : null });
69
+ }
70
+
71
+ /**
72
+ * #2910 D2 (#2925 review): a web-only fleet's Notices as the page keeps them. A list read (GET /ui/notices/list), a live
73
+ * one (SSE `notice`) and a read mark (POST /ui/notices/seen) can answer in any order, so each is merged, never
74
+ * replaced: the newest NOTICES_KEPT by id (a list read never drops one that arrived live after it was taken), and the
75
+ * read mark only moves forward. Unread is counted here from the two.
76
+ */
77
+ export const NOTICES_KEPT = 500;
78
+ function noticesState(enabled, byId, seen) {
79
+ const items = [...byId.values()].sort((a, b) => b.id - a.id).slice(0, NOTICES_KEPT);
80
+ return { enabled, items, seen, unread: items.filter((n) => n.id > seen).length };
81
+ }
82
+ const validNotice = (n) => !!n && Number.isSafeInteger(n.id) && n.id > 0 && typeof n.text === "string";
83
+ export function mergeNoticeList(cur, d) {
84
+ const byId = new Map();
85
+ for (const n of Array.isArray(d && d.items) ? d.items : []) if (validNotice(n)) byId.set(n.id, n);
86
+ const listNewest = Math.max(0, ...byId.keys());
87
+ for (const n of cur && Array.isArray(cur.items) ? cur.items : []) if (n.id > listNewest && !byId.has(n.id)) byId.set(n.id, n);
88
+ return noticesState(!!(d && d.enabled === true), byId, Math.max(cur ? cur.seen | 0 : 0, d ? d.seen | 0 : 0));
89
+ }
90
+ export function addNotice(cur, n) {
91
+ if (!cur || !cur.enabled || !validNotice(n) || cur.items.some((x) => x.id === n.id)) return cur;
92
+ return noticesState(true, new Map([...cur.items, n].map((x) => [x.id, x])), cur.seen | 0);
93
+ }
94
+ /** The newest Notice the status frame names that the page does not have yet (a polling page hears no `notice`). 0: none. */
95
+ export function noticesCatchUp(s) {
96
+ const have = s && s.notices && s.notices.items.length ? s.notices.items[0].id : 0;
97
+ return s && Number.isSafeInteger(s.noticesNewest) && s.noticesNewest > have ? s.noticesNewest : 0;
98
+ }
99
+ /** After a read that left the page still behind: try again this soon (a failed read waits longer). */
100
+ export const NOTICES_RETRY_MS = { stale: 1000, failed: 5000 };
101
+ /**
102
+ * #2910 D2 (#2925 review r2): the page's reads of GET /ui/notices/list. One at a time (a second ask joins the read in
103
+ * flight); once a read settles, the page asks again if it is still behind what the status frame names (an older read
104
+ * that was already in flight, or one that failed) — after NOTICES_RETRY_MS, and again until it catches up. Never a
105
+ * watermark set before a read succeeded.
106
+ */
107
+ export function createNoticesSync({ store, fetchList, setTimer = (fn, ms) => setTimeout(fn, ms), now = () => Date.now() }) {
108
+ let inFlight = null, retryAt = 0, timer = null, on = false;
109
+ const behind = () => on && noticesCatchUp(store.get()) > 0;
110
+ const recheck = () => { if (!inFlight && behind() && now() >= retryAt) read(); };
111
+ const later = (ms) => {
112
+ retryAt = now() + ms;
113
+ if (timer === null) timer = setTimer(() => { timer = null; recheck(); }, ms);
114
+ };
115
+ function read() {
116
+ return (inFlight ??= Promise.resolve().then(fetchList).then((d) => {
117
+ if (!d || !Array.isArray(d.items)) throw new Error("no list");
118
+ store.set({ notices: mergeNoticeList(store.get().notices, d) });
119
+ return true;
120
+ }).catch(() => false).then((ok) => {
121
+ inFlight = null;
122
+ if (behind()) later(ok ? NOTICES_RETRY_MS.stale : NOTICES_RETRY_MS.failed);
123
+ else retryAt = 0;
124
+ }));
125
+ }
126
+ return {
127
+ /** The fleet is web-only: start (the first read), and follow the status frame from now on. */
128
+ start() { if (!on) { on = true; read(); } },
129
+ read,
130
+ recheck,
131
+ started: () => on,
132
+ };
133
+ }
134
+ export function applyNoticesSeen(cur, seen) {
135
+ if (!cur || !Number.isSafeInteger(seen) || seen <= (cur.seen | 0)) return cur;
136
+ return noticesState(cur.enabled, new Map(cur.items.map((x) => [x.id, x])), seen);
67
137
  }
68
138
 
69
139
  /** #2015: the status frame's host_memory_top, as the dashboard keeps it: well-formed entries only, at most 3. */
@@ -37,7 +37,7 @@
37
37
  // offices — the stored offices changed ({ rev }; SSE only: the Office view reads /ui/offices again).
38
38
  // office_skins — a skin pack was uploaded or deleted ({ rev }; SSE only: the Office view reads /ui/office-skins again).
39
39
  // office_acts — the agents' live acts in the Office view changed ({ acts }, #2578; a poll page reads /ui/office-acts).
40
- const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "reactions", "steps_tick", "clock", "traffic", "delegation", "backend_login", "tasks", "offices", "office_skins", "office_acts", "office_pack_changes"];
40
+ const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "reactions", "steps_tick", "clock", "traffic", "delegation", "backend_login", "tasks", "offices", "office_skins", "office_acts", "office_pack_changes", "notice"];
41
41
  const MAX_HELD = 1000; // live events held during one catch-up read; more → read again afterwards
42
42
  const RETRY_DELAYS = [1000, 2000, 4000]; // after the first failed catch-up read; then it waits for Retry
43
43
  const POLL_LIMIT_MS = 10000; // a poll that has not answered by then counts as failed
@@ -623,9 +623,21 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
623
623
  Its class is `status-line`, never `status`: that is the header's inline-flex badge class, and on a message it turned
624
624
  the meta, body and tools into one flex row. */
625
625
  .msg.status-line { align-self: stretch; font-size: var(--fs-sm); color: var(--text-3); }
626
- .msg.status-line .st-text { white-space: pre-wrap; border-left: 2px solid var(--border); padding-left: var(--s-3); }
627
- .msg.status-line.folded .st-text { max-height: 9em; overflow: hidden; mask-image: linear-gradient(to bottom, #000 60%, transparent); -webkit-mask-image: linear-gradient(to bottom, #000 60%, transparent); }
626
+ /* Each line is a row (.st-ln); the line breaks between rows are text for copying, not extra rows. */
627
+ .msg.status-line .st-text { border-left: 2px solid var(--border); padding-left: var(--s-3); }
628
+ .msg.status-line .st-ln { display: block; white-space: pre-wrap; overflow-wrap: anywhere; }
629
+ .msg.status-line .st-ln:empty::after { content: " "; }
630
+ /* Folded (the user, hmsg-d6ac6dfb): two lines — the header and the first line — each one row with an ellipsis; the
631
+ button sits at the end of line 2. The text opens it too. */
632
+ .msg.status-line.folded .body { display: flex; align-items: flex-end; gap: var(--s-2); }
633
+ .msg.status-line.folded .st-text { flex: 1 1 auto; min-width: 0; }
634
+ .msg.status-line.folded.overflows .st-text { cursor: pointer; }
635
+ /* Its control only while something is hidden (two short rows that fit: plain, no control). */
636
+ .msg.status-line.folded:not(.overflows) .st-more { display: none; }
637
+ .msg.status-line.folded .st-ln { white-space: pre; overflow: hidden; text-overflow: ellipsis; }
638
+ .msg.status-line.folded .st-ln:nth-child(n+3) { display: none; }
628
639
  .msg.status-line .st-more { border: 0; background: none; color: var(--text-2); cursor: pointer; font-size: var(--fs-xs); padding: var(--s-1) 0 0 var(--s-3); }
640
+ .msg.status-line.folded .st-more { flex: none; padding: 0 var(--s-2) 0 0; white-space: nowrap; }
629
641
  .msg.status-line .st-more:hover { color: var(--text); }
630
642
  .msg-tools { display: flex; gap: var(--s-1); margin-top: var(--s-1); opacity: 0; transition: opacity var(--t-fast); }
631
643
  /* #2147: bounded stock toolbar paint, including fixed descendants. Padding preserves focus outlines. */
@@ -807,6 +819,39 @@ img.amb-p { object-fit: contain; }
807
819
  .preview-frame.fill { flex: 1; height: 100%; min-height: 0; }
808
820
  .pv-code { flex: 1; min-height: 0; margin: 0; overflow: auto; font-family: var(--font-mono); font-size: var(--fs-xs); line-height: 1.5;
809
821
  background: var(--surface); border-radius: var(--r-sm); padding: var(--s-3); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); }
822
+ /* preview panel chrome (M-2216) — #2911: the version step and the Changes view. */
823
+ .pv-ver { display: inline-flex; align-items: center; gap: var(--s-1); font-size: var(--fs-xs); color: var(--text-2); }
824
+ .pv-ver-at { min-width: 6ch; text-align: center; font-variant-numeric: tabular-nums; }
825
+ .pv-panel > .pv-ver-note { margin: 0 var(--s-3) var(--s-2); }
826
+ .pv-diff-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: var(--s-2); }
827
+ .pv-diff-head { font-size: var(--fs-xs); color: var(--text-2); }
828
+ .pv-diff { white-space: pre; overflow-wrap: normal; }
829
+ .pv-diff > code > span { display: block; }
830
+ .pv-diff .d-add { background: var(--ok-bg); color: var(--ok); }
831
+ .pv-diff .d-del { background: var(--danger-bg); color: var(--danger); }
832
+ .pv-diff .d-hunk { color: var(--text-3); }
833
+ .pv-diff-note { margin: 0; }
834
+ /* #2911 (2): the page at a phone's or a tablet's width, centred; a panel narrower than that scrolls sideways. */
835
+ .pv-panel-body.w-phone, .pv-panel-body.w-tablet { overflow-x: auto; }
836
+ /* The frame keeps its 2px warning border (.preview-frame): the holder is that much wider, so the page itself lays out
837
+ at exactly 390 / 768 CSS px (its innerWidth, its min-width media queries) — #2918 review. */
838
+ .pv-panel-body.w-phone .pv-holder { flex: 1 0 auto; width: calc(390px + 2 * 2px); max-width: none; margin-inline: auto; }
839
+ .pv-panel-body.w-tablet .pv-holder { flex: 1 0 auto; width: calc(768px + 2 * 2px); max-width: none; margin-inline: auto; }
840
+ .pv-width { font: inherit; font-size: var(--fs-xs); padding: 2px var(--s-1); border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg); color: var(--text); }
841
+ /* Full screen: the API's (:fullscreen), or the panel over the whole window where the page may not use it. */
842
+ /* Three classes, so it wins over the desktop split's ".chat-split > .pv-panel { position: relative; z-index: 1 }" — #2918 review. */
843
+ .pv-panel.pv-fs.pv-fs-native, .pv-panel.pv-fs.pv-fs-immersive { width: auto; min-width: 0; max-width: none; }
844
+ .chat-split > .pv-panel.pv-fs-immersive, .pv-panel.pv-fs.pv-fs-immersive { position: fixed; inset: 0; z-index: 60; }
845
+ /* #2911 (6): the thread's pages. */
846
+ .pv-pages { list-style: none; margin: 0; padding: 0; overflow: auto; display: flex; flex-direction: column; gap: var(--s-1); }
847
+ .pv-page { width: 100%; display: flex; align-items: center; gap: var(--s-2); padding: var(--s-2) var(--s-3); border: 1px solid var(--border);
848
+ border-radius: var(--r-sm); background: var(--surface); color: var(--text); font: inherit; font-size: var(--fs-sm); text-align: left; cursor: pointer; }
849
+ .pv-page.on { border-color: var(--accent); }
850
+ .pv-page-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
851
+ .pv-page-meta { flex: none; color: var(--text-3); font-size: var(--fs-xs); }
852
+ .pv-pages-toggle { flex: none; }
853
+ /* A narrow panel: two rows — versions and views; then the width, the run controls, the page's actions and Download. */
854
+ @container (max-width: 560px) { .pv-panel-bar .grow { flex-basis: 100%; height: 0; } }
810
855
  .msg.flash { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: var(--r-sm); transition: outline-color var(--t-med); }
811
856
  /* #2272: a message's reply action (top right, on hover or focus), the quote a reply shows, the message menu, a swipe. */
812
857
  .msg[data-key] { position: relative; }
@@ -837,6 +882,10 @@ img.amb-p { object-fit: contain; }
837
882
  @container (max-width: 560px) {
838
883
  .pv-panel-bar :not(.pv-panel-run) > .lbl { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
839
884
  }
885
+ /* preview feedback (M-2217, #2911): the errors chip — its count stays when a narrow bar hides the word. */
886
+ .pv-fb-errors { color: var(--warn); border-color: var(--warn-line); background: var(--warn-bg); }
887
+ .pv-fb-pick.on { color: var(--warn); border-color: var(--warn); }
888
+ .pv-fb-errors .pv-fb-n { font-variant-numeric: tabular-nums; font-weight: var(--fw-semibold); }
840
889
  /* #1266: an agent reply's buttons — choices under the text; one answers for everyone. */
841
890
  .rb-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); margin-top: var(--s-2); }
842
891
  .rb-btn { max-width: 100%; white-space: normal; text-align: left; }
@@ -1595,6 +1644,11 @@ textarea.s-yaml { white-space: pre; overflow-x: auto; }
1595
1644
  .n-detail { color: var(--text-2); font-size: var(--fs-sm); overflow-wrap: anywhere; white-space: pre-wrap; }
1596
1645
  .n-actions { display: flex; gap: var(--s-2); flex-wrap: wrap; margin-top: var(--s-1); }
1597
1646
  .n-notify { display: flex; flex-direction: column; gap: var(--s-1); }
1647
+ /* #2910 D2: Notices (a web-only fleet's General) — the Needs you rows; a new one has an accent edge and a "New" tag. */
1648
+ .n-item.notice.unread { box-shadow: inset 3px 0 0 var(--accent); }
1649
+ .n-item.notice:not(.bad) .n-icon { color: var(--text-2); }
1650
+ .notice-new { font-size: var(--fs-xs); color: var(--accent); font-weight: var(--fw-semibold); }
1651
+ .notice-sub { margin: 0 var(--s-1) var(--s-3); }
1598
1652
  .confirm-text { white-space: pre-wrap; font-size: var(--fs-sm); color: var(--text-2); }
1599
1653
 
1600
1654
  /* ── Org chart (#1389): General → teams → instances. Connectors are borders and pseudo-elements; no inline style.
@@ -2242,7 +2296,7 @@ html.oo-immersive .p-fleet > .panel-body { z-index: 50; }
2242
2296
  @media (min-width: 900px) { .chat-split > .pv-divider, .chat-split > .pv-panel { position: relative; z-index: 1; } }
2243
2297
  /* #1629: full-app bounded stock surfaces. Panel roots stay neutral; effects never raise the whole app. */
2244
2298
  .p-fleet > .seg, .p-settings > .seg,
2245
- .p-fleet > .panel-body, .p-needs > .panel-body, .p-settings > .panel-body,
2299
+ .p-fleet > .panel-body, .p-needs > .panel-body, .p-notices > .panel-body, .p-settings > .panel-body,
2246
2300
  .p-log > .panel-body, .p-details > .panel-body, .p-view > .panel-body,
2247
2301
  .p-log > .log-bar, .p-view > .v-term, .p-view > .v-id, .p-view > .v-watching { position: relative; z-index: 1; }
2248
2302
  /* Keep the existing Apply bar's sticky behavior, in its own bounded stock band. */
@@ -13,7 +13,7 @@ import { installSwipe } from "./app-swipe.js";
13
13
  import { installTextUndo } from "./text-undo.js";
14
14
  import { startRouter, navStore, navigate } from "./app-nav.js";
15
15
  import { createStream } from "./app-stream.js";
16
- import { appStore, applyStatus, applyActivity, guestPage, guestTrusted } from "./app-store.js";
16
+ import { appStore, applyStatus, applyActivity, guestPage, guestTrusted, addNotice, createNoticesSync } from "./app-store.js";
17
17
  import { chatPath } from "./app-route.js";
18
18
  import { startNeedsNotifier } from "./app-needs.js";
19
19
  import { toast } from "./ui-toast.js";
@@ -91,6 +91,27 @@ if (mode === "full") {
91
91
  const loadApply = retryable((a) => import(retryUrl("/ui/js/settings-apply.js", a)));
92
92
  appStore.set({ restartRunner: (jobId) => loadApply().then((m) => m.restartPendingChanges(jobId)) });
93
93
  panels.set("needs", { load: () => loadNeeds().then((m) => m.NeedsPanel) });
94
+ // #2910 D2: a web-only fleet's Notices (/ui/notices): read once the status says there is no chat connection (and
95
+ // again after a reconnect: a poll page hears no `notice`), then each new one from the stream.
96
+ const loadNotices = retryable((a) => import(retryUrl("/ui/js/panel-notices.js", a)));
97
+ panels.set("notices", { load: () => loadNotices().then((m) => m.NoticesPanel) });
98
+ // A list read and a live notice can cross: each is merged into what the page has (app-store.js mergeNoticeList); a
99
+ // read that leaves the page behind what the status frame names is asked again (createNoticesSync).
100
+ const notices = createNoticesSync({ store: appStore,
101
+ fetchList: () => fetch("/ui/notices/list", { credentials: "same-origin", cache: "no-store" }).then((r) => (r.ok ? r.json() : null)) });
102
+ const readNotices = () => notices.read();
103
+ appStore.set({ readNotices });
104
+ appStore.subscribe((s) => {
105
+ if (s.connections === 0) notices.start();
106
+ notices.recheck(); // a polling page hears no `notice`: the status frame's newest id is its catch-up
107
+ });
108
+ stream.on("connection", (c) => { if ((c === "live" || c === "polling") && notices.started()) readNotices(); });
109
+ stream.on("notice", (d) => {
110
+ const cur = appStore.get().notices;
111
+ if (!cur || !cur.enabled) { readNotices(); return; }
112
+ const next = addNotice(cur, d && d.notice);
113
+ if (next !== cur) appStore.set({ notices: next });
114
+ });
94
115
  // #1523 N2: one instance's Details (/ui/fleet/agent/<name>).
95
116
  panels.set("details", { load: () => loadDetails().then((m) => m.DetailsPanel) });
96
117
  // #1218: one instance's Log (/ui/log/<name>).