@songsid/agend 2.3.3 → 2.3.4-beta.1

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 (134) 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/config-validator.js +18 -1
  17. package/dist/config-validator.js.map +1 -1
  18. package/dist/daemon.js +8 -1
  19. package/dist/daemon.js.map +1 -1
  20. package/dist/env-file.d.ts +2 -0
  21. package/dist/env-file.js +13 -8
  22. package/dist/env-file.js.map +1 -1
  23. package/dist/fleet-manager.d.ts +25 -1
  24. package/dist/fleet-manager.js +150 -21
  25. package/dist/fleet-manager.js.map +1 -1
  26. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -1
  27. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +4 -0
  28. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  29. package/dist/instance-config-impact.js +1 -0
  30. package/dist/instance-config-impact.js.map +1 -1
  31. package/dist/instance-lifecycle.d.ts +2 -0
  32. package/dist/instance-lifecycle.js +23 -7
  33. package/dist/instance-lifecycle.js.map +1 -1
  34. package/dist/instructions.d.ts +1 -1
  35. package/dist/instructions.js +1 -1
  36. package/dist/instructions.js.map +1 -1
  37. package/dist/interaction-observation.d.ts +1 -0
  38. package/dist/interaction-observation.js +1 -1
  39. package/dist/interaction-observation.js.map +1 -1
  40. package/dist/locale.js +48 -0
  41. package/dist/locale.js.map +1 -1
  42. package/dist/next-steps.d.ts +12 -2
  43. package/dist/next-steps.js +19 -3
  44. package/dist/next-steps.js.map +1 -1
  45. package/dist/office-share-service.d.ts +57 -0
  46. package/dist/office-share-service.js +76 -11
  47. package/dist/office-share-service.js.map +1 -1
  48. package/dist/office-share.d.ts +8 -0
  49. package/dist/office-share.js +34 -0
  50. package/dist/office-share.js.map +1 -1
  51. package/dist/outbound-schemas.js +1 -1
  52. package/dist/outbound-schemas.js.map +1 -1
  53. package/dist/page-share.d.ts +122 -0
  54. package/dist/page-share.js +199 -0
  55. package/dist/page-share.js.map +1 -0
  56. package/dist/public-preview.d.ts +4 -0
  57. package/dist/public-preview.js +5 -4
  58. package/dist/public-preview.js.map +1 -1
  59. package/dist/public-web-gateway.js +5 -0
  60. package/dist/public-web-gateway.js.map +1 -1
  61. package/dist/quickstart-api.d.ts +5 -0
  62. package/dist/quickstart-api.js +1 -1
  63. package/dist/quickstart-api.js.map +1 -1
  64. package/dist/quickstart.d.ts +40 -0
  65. package/dist/quickstart.js +174 -28
  66. package/dist/quickstart.js.map +1 -1
  67. package/dist/remote-card-answer.js +2 -0
  68. package/dist/remote-card-answer.js.map +1 -1
  69. package/dist/remote-card-prompt.js +4 -2
  70. package/dist/remote-card-prompt.js.map +1 -1
  71. package/dist/selection-card.d.ts +21 -0
  72. package/dist/selection-card.js +129 -0
  73. package/dist/selection-card.js.map +1 -0
  74. package/dist/settings-api.js +4 -0
  75. package/dist/settings-api.js.map +1 -1
  76. package/dist/settings-change.js +5 -0
  77. package/dist/settings-change.js.map +1 -1
  78. package/dist/settings-effect.js +2 -0
  79. package/dist/settings-effect.js.map +1 -1
  80. package/dist/setup-host.js +2 -2
  81. package/dist/setup-host.js.map +1 -1
  82. package/dist/setup-wizard.js +2 -2
  83. package/dist/setup-wizard.js.map +1 -1
  84. package/dist/types.d.ts +10 -0
  85. package/dist/ui/chat-store.js +8 -1
  86. package/dist/ui/chat-strings.js +36 -0
  87. package/dist/ui/page-share-dialog.js +191 -0
  88. package/dist/ui/panel-chat.js +14 -0
  89. package/dist/ui/panel-notices.js +98 -0
  90. package/dist/ui/panel-settings.js +9 -2
  91. package/dist/ui/preview-pages.js +41 -0
  92. package/dist/ui/preview-panel-actions.js +161 -0
  93. package/dist/ui/preview-panel.js +209 -28
  94. package/dist/ui/preview-versions.js +155 -0
  95. package/dist/ui/preview.js +88 -3
  96. package/dist/ui/settings-registry.js +1 -0
  97. package/dist/ui/settings-strings.js +4 -0
  98. package/dist/ui/shared/app-i18n.js +2 -2
  99. package/dist/ui/shared/app-route.js +4 -0
  100. package/dist/ui/shared/app-shell.js +5 -3
  101. package/dist/ui/shared/app-store.js +71 -1
  102. package/dist/ui/shared/app-stream.js +1 -1
  103. package/dist/ui/shared/app.css +43 -1
  104. package/dist/ui/shared/app.js +22 -1
  105. package/dist/ui/shared/signin.js +12 -1
  106. package/dist/ui/shared/ui-icons.js +3 -0
  107. package/dist/ui/shared/web-authorize.js +3 -1
  108. package/dist/web-api.d.ts +26 -0
  109. package/dist/web-api.js +180 -1
  110. package/dist/web-api.js.map +1 -1
  111. package/dist/web-auth.js +7 -1
  112. package/dist/web-auth.js.map +1 -1
  113. package/dist/web-login.d.ts +2 -0
  114. package/dist/web-login.js +3 -1
  115. package/dist/web-login.js.map +1 -1
  116. package/dist/web-notices.d.ts +51 -0
  117. package/dist/web-notices.js +118 -0
  118. package/dist/web-notices.js.map +1 -0
  119. package/dist/web-only-fleet.d.ts +22 -0
  120. package/dist/web-only-fleet.js +13 -0
  121. package/dist/web-only-fleet.js.map +1 -0
  122. package/dist/web-preview.d.ts +32 -3
  123. package/dist/web-preview.js +52 -19
  124. package/dist/web-preview.js.map +1 -1
  125. package/dist/web-session.d.ts +7 -0
  126. package/dist/web-session.js +2 -0
  127. package/dist/web-session.js.map +1 -1
  128. package/dist/web-settings-patch.d.ts +3 -1
  129. package/dist/web-settings-patch.js +4 -1
  130. package/dist/web-settings-patch.js.map +1 -1
  131. package/dist/web-shell-routes.d.ts +2 -0
  132. package/dist/web-shell-routes.js +3 -0
  133. package/dist/web-shell-routes.js.map +1 -1
  134. package/package.json +1 -1
@@ -17,6 +17,11 @@ import "./chat-render.js";
17
17
  import "./preview.js";
18
18
  import { loadHtmlAttachment } from "./html-attachment.js";
19
19
  import { chatTime, onDayChange } from "./chat-time.js";
20
+ import { PanelActions } from "./preview-panel-actions.js";
21
+ import { htmlTitle, versionsOf, lineDiff, diffHunks, DIFF_LIMITS } from "./preview-versions.js";
22
+ import { pagesOf, pageIdOf } from "./preview-pages.js";
23
+
24
+ export { htmlTitle };
20
25
 
21
26
  const R = () => globalThis.AgendChatRender;
22
27
  const P = () => globalThis.AgendPreview;
@@ -60,12 +65,6 @@ export function closePanel() {
60
65
  /** The card key the panel shows, or null. */
61
66
  export const shownKey = () => (state.open ? state.open.key : null);
62
67
 
63
- /** The text of a block's <title>, or "" — what says two blocks are versions of one thing. */
64
- export function htmlTitle(code) {
65
- const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(String(code || ""));
66
- return m ? m[1].replace(/\s+/g, " ").trim() : "";
67
- }
68
-
69
68
  /**
70
69
  * A newer version of the shown block: the last complete ```html block in an agent reply after the one it came from.
71
70
  * When the shown block has a <title>, only a block with the same title counts (another page is not a new version);
@@ -118,6 +117,30 @@ export function clampWidth(w, total) {
118
117
  return Math.round(Math.min(max, Math.max(MIN_PANEL, w)));
119
118
  }
120
119
 
120
+ // ── #2911 (7): a CDN library the preview blocked (web.preview_cdn off) — the panel says where to allow it ──
121
+
122
+ /** web-preview.ts PREVIEW_CDN_SOURCES (a test keeps the two equal): what web.preview_cdn allows. */
123
+ export const CDN_PREFIXES = ["https://cdnjs.cloudflare.com/ajax/libs/", "https://cdn.jsdelivr.net/npm/"];
124
+ /** The CDN host of the first blocked library in a frame's error log that the setting would allow, or null. */
125
+ export function blockedCdnHost(log) {
126
+ for (const x of (log && log.items) || []) {
127
+ if (x.kind !== "blocked") continue;
128
+ const p = CDN_PREFIXES.find(pre => String(x.message).startsWith(pre));
129
+ if (p) return new URL(p).host;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ // ── #2911 (2): the page's width in the panel (a phone, a tablet, or the panel's own), this device's choice ──
135
+
136
+ const DEVICE_KEY = "agend_preview_panel_device";
137
+ /** The widths a preview can be shown at: CSS px, or null for the panel's full width. */
138
+ export const DEVICE_WIDTHS = { full: null, tablet: 768, phone: 390 };
139
+ export function storedDevice() { try { const v = localStorage.getItem(DEVICE_KEY); return Object.hasOwn(DEVICE_WIDTHS, v) ? v : "full"; } catch { return "full"; } }
140
+ function storeDevice(v) { try { localStorage.setItem(DEVICE_KEY, v); } catch { /* this page only */ } }
141
+ /** The element the page has fullscreen (standard, else webkit's), or null. */
142
+ const fullscreenEl = () => { const d = globalThis.document; return (d && (d.fullscreenElement || d.webkitFullscreenElement)) || null; };
143
+
121
144
  /** #1770: as the thread shows it — the fleet's zone, with the day when it is not today. */
122
145
  const timeOf = (ts) => (ts ? chatTime(ts, { yesterday: (hm) => t("chat.yesterdayAt", hm) }) : "");
123
146
 
@@ -133,10 +156,53 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
133
156
  const [run, setRun] = useState({ state: "idle", reason: "" });
134
157
  const [newer, setNewer] = useState(null);
135
158
  const [newerNote, setNewerNote] = useState("");
159
+ const [vers, setVers] = useState({ list: [], index: -1 }); // #2911 (1): the shown block's versions in the thread
160
+ const [pages, setPages] = useState([]); // #2911 (6): every page in the thread, newest first
161
+ const [cdnHost, setCdnHost] = useState(null); // #2911 (7): a CDN library this page's run was refused
162
+ const [listing, setListing] = useState(false); // the page list is on screen (instead of the page)
163
+ const [changes, setChanges] = useState(null); // the Changes view: { state, from, hunks, shown, total }
164
+ const keepView = useRef(false); // a version step keeps Code / Changes on screen
165
+ const loaded = useRef(new Map()); // an attached version's HTML, read once (key → code)
136
166
  const reading = useRef(null); // the newer version whose file is being read (one at a time)
137
167
  const pending = useRef(0); // #1704: the card's click, waiting for this device's Allow (the st.run it came with)
138
168
  const ownAllow = useRef(0); // while this panel's own Allow commits its choice: the click it answers (not cancelled by it)
139
169
  const holder = useRef(null), divider = useRef(null), panel = useRef(null);
170
+ const [device, setDeviceState] = useState(storedDevice); // #2911 (2): the page's width
171
+ const setDevice = (v) => { setDeviceState(v); storeDevice(v); };
172
+ // Full screen: the Fullscreen API on the panel (the dashboard's document — the frame's own policy still denies it),
173
+ // or, where the page may not, the panel over the whole window ("immersive"). Esc leaves it before it closes the panel.
174
+ const [fs, setFs] = useState("");
175
+ const fsTarget = useRef(null); // the element asked to go full screen (the panel may be gone when it is left)
176
+ // #2918 review: each request has a generation; leaving (Esc, the button, a close, the chat unmounting) moves it, so a
177
+ // request still pending answers nobody — a late refusal does not make a later open immersive, and a late success
178
+ // only takes back the full screen it made.
179
+ const fsGen = useRef(0);
180
+ const exitOwn = (el) => {
181
+ const d = globalThis.document;
182
+ if (!d || !el || fullscreenEl() !== el) return;
183
+ const ex = d.exitFullscreen || d.webkitExitFullscreen;
184
+ try { const r = typeof ex === "function" ? ex.call(d) : null; if (r && typeof r.catch === "function") r.catch(() => {}); } catch { /* already out */ }
185
+ };
186
+ const enterFs = () => {
187
+ const el = panel.current, d = globalThis.document; if (!el || fs) return;
188
+ const gen = ++fsGen.current;
189
+ const req = el.requestFullscreen || el.webkitRequestFullscreen;
190
+ const enabled = d ? (d.fullscreenEnabled ?? d.webkitFullscreenEnabled) : false;
191
+ if (typeof req !== "function" || enabled === false) { setFs("immersive"); return; }
192
+ let r;
193
+ fsTarget.current = el;
194
+ try { r = req.call(el); } catch { fsTarget.current = null; setFs("immersive"); return; }
195
+ if (r && typeof r.then === "function") {
196
+ r.then(() => { if (fsGen.current === gen) { if (fullscreenEl() === el) setFs("native"); } else exitOwn(el); },
197
+ () => { if (fsGen.current === gen) { fsTarget.current = null; setFs("immersive"); } });
198
+ } else setFs("native");
199
+ };
200
+ function leaveFs() {
201
+ fsGen.current++;
202
+ const el = fsTarget.current; fsTarget.current = null;
203
+ exitOwn(el);
204
+ setFs("");
205
+ }
140
206
  const open = st.open?.instance === name ? st.open : null;
141
207
 
142
208
  useEffect(() => panelStore.subscribe(setSt), []);
@@ -147,6 +213,15 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
147
213
  useEffect(() => (P() ? P().onChange(() => { if (pending.current && ownAllow.current !== pending.current) pending.current = 0; choice(n => n + 1); }) : undefined), []);
148
214
  // Leaving the chat (another instance, another panel) closes the panel: its frame stops with the thread's.
149
215
  useEffect(() => () => closePanel(), []);
216
+ // Leaving the chat: a pending request answers nobody, and our own full screen is taken back.
217
+ useEffect(() => () => { fsGen.current++; exitOwn(fsTarget.current); fsTarget.current = null; }, []);
218
+ // Full screen ends with the browser's own Esc or its exit control too: follow it.
219
+ useEffect(() => {
220
+ const d = globalThis.document; if (!d || !d.addEventListener) return undefined;
221
+ const sync = () => setFs(f => (f === "native" && fullscreenEl() !== fsTarget.current ? "" : f));
222
+ d.addEventListener("fullscreenchange", sync); d.addEventListener("webkitfullscreenchange", sync);
223
+ return () => { d.removeEventListener("fullscreenchange", sync); d.removeEventListener("webkitfullscreenchange", sync); };
224
+ }, []);
150
225
  // #1770: the source and newer-version labels say the day as the thread does — drawn again when the fleet's day
151
226
  // changes, while a block is open. A render: the frame and the run are left as they are.
152
227
  const [, dayTick] = useState(0);
@@ -166,7 +241,9 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
166
241
  // change of this device's choice cancels it.
167
242
  useEffect(() => {
168
243
  setRun({ state: "idle", reason: "" });
169
- setView("preview");
244
+ if (!keepView.current) setView("preview");
245
+ setListing(false); // #2920 review: a new block on screen leaves the page list
246
+ keepView.current = false;
170
247
  if (open && st.run) {
171
248
  if (P().availability().ok) start();
172
249
  else pending.current = st.run;
@@ -174,11 +251,21 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
174
251
  // Focus goes to the panel's close control that is on screen (Back on a phone, × beside the thread).
175
252
  if (panel.current) { const f = [...panel.current.querySelectorAll(".pv-panel-close")].find(b => b.offsetParent !== null); if (f) f.focus(); }
176
253
  }, [open && open.key, st.run]);
254
+ // The panel closing, or showing another instance's block: out of full screen.
255
+ useEffect(() => { if (!open) leaveFs(); }, [!!open]);
256
+ // #2911 (7): the running page's blocked CDN library, read from its error log (preview.js), kept as it changes.
257
+ useEffect(() => {
258
+ const key = open ? frameKey(open.key) : null;
259
+ const read = () => setCdnHost(key && P() && typeof P().errors === "function" ? blockedCdnHost(P().errors(key)) : null);
260
+ read();
261
+ if (!key || !P() || typeof P().onErrors !== "function") return undefined;
262
+ return P().onErrors((k) => { if (k === key) read(); });
263
+ }, [open && open.key]);
177
264
  // A later reply with a newer version of this block: offered, not swapped in.
178
265
  useEffect(() => {
179
266
  setNewerNote(""); reading.current = null;
180
267
  if (!open) { setNewer(null); return undefined; }
181
- const check = () => setNewer(newerVersion(open, msgs() || [], keyOf));
268
+ const check = () => { const all = msgs() || []; setNewer(newerVersion(open, all, keyOf)); setVers(versionsOf(open, all, keyOf)); setPages(pagesOf(all, keyOf, name)); };
182
269
  check();
183
270
  return subscribe((instance, kind) => { if (instance === name && kind === "msgs") check(); });
184
271
  }, [open && open.key, name]);
@@ -219,24 +306,70 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
219
306
  el.addEventListener("pointerup", up);
220
307
  el.addEventListener("pointercancel", up);
221
308
  };
222
- // Show the newer version: an attached file's HTML is read first. A read the panel moved on from is dropped.
223
- const showNewer = async () => {
224
- if (!newer || reading.current) return;
225
- if (newer.code != null) { openPanel(newer); return; }
226
- const was = open && open.key, mine = reading.current = newer;
309
+ // Show another version (the newer one offered, or a step of ‹ ›): an attached file's HTML is read first. A read the
310
+ // panel moved on from is dropped. It is shown as Show it shows one — waiting for Preview, never started by the step.
311
+ const attReason = (r) => t(r.reason === "over" ? "chat.pvAttOver" : r.reason === "gone" ? "chat.pvAttGone" : "chat.pvAttFailed");
312
+ // #2920 review: a newer choice supersedes a read still waiting (it opens, or claims, and so moves the generation the
313
+ // waiting read checks) — it is never ignored because another read is pending.
314
+ const show = async (spec, { keep = false } = {}) => {
315
+ if (!spec) return;
316
+ const cached = spec.code == null ? loaded.current.get(spec.key) : undefined;
317
+ if (spec.code != null || cached != null) { keepView.current = keep; openPanel(cached != null ? { ...spec, code: cached } : spec); return; }
318
+ // #2915 review: the panel's generation, not just the key — a close and reopen of the same block while this read
319
+ // waits moves it, and the read is then dropped (claimPanel also drops any earlier click still reading).
320
+ const gen = claimPanel(), mine = reading.current = spec;
227
321
  setNewerNote(t("chat.pvLoading"));
228
- // A click on the newer version reads it whatever its size (up to the 50 MB ceiling); over 8 MB the panel then asks.
229
- const r = await loadHtmlAttachment(newer.att, undefined, { anyway: true });
230
- if (reading.current !== mine) return;
231
- reading.current = null;
232
- const now = panelStore.get().open;
233
- if (!now || now.key !== was) return;
234
- if (!r.ok) { setNewerNote(t(r.reason === "over" ? "chat.pvAttOver" : r.reason === "gone" ? "chat.pvAttGone" : "chat.pvAttFailed")); return; }
235
- openPanel({ ...newer, code: r.code });
322
+ // A click on a version reads it whatever its size (up to the 50 MB ceiling); over 8 MB the panel then asks.
323
+ const r = await loadHtmlAttachment(spec.att, undefined, { anyway: true });
324
+ if (reading.current === mine) reading.current = null;
325
+ if (panelVersion() !== gen) return;
326
+ if (!r.ok) { setNewerNote(attReason(r)); return; }
327
+ setNewerNote("");
328
+ loaded.current.set(spec.key, r.code);
329
+ keepView.current = keep;
330
+ openPanel({ ...spec, code: r.code });
236
331
  };
332
+ const showNewer = () => show(newer);
333
+ /** A view button: that view on screen — the page list, if open, gives way to it (#2920 review). */
334
+ const pickView = (v) => { setListing(false); setView(v); };
335
+ const step = (by) => { const v = vers.list[vers.index + by]; if (vers.index >= 0 && v) show(v, { keep: true }); };
336
+ // Changes: the previous version against this one, a line diff (bounded: DIFF_LIMITS). An attached previous version
337
+ // is read for it, once.
338
+ const prev = vers.index > 0 ? vers.list[vers.index - 1] : null;
339
+ // A step to the first version from Changes: nothing to compare it with, so the page is shown (Changes is kept as the
340
+ // choice, and comes back with the next step that has a previous version).
341
+ const shown = view === "changes" && !prev ? "preview" : view;
342
+ useEffect(() => {
343
+ if (shown !== "changes" || !open) { setChanges(null); return undefined; }
344
+ let live = true;
345
+ const from = vers.index; // "Changes from v{from}" (1-based: the previous one)
346
+ const diff = (before) => {
347
+ const ops = lineDiff(before, open.code);
348
+ if (!ops) return { state: "big", from };
349
+ const hunks = diffHunks(ops);
350
+ let total = 0; for (const h of hunks) total += h.lines.length + 1;
351
+ return { state: "ready", from, hunks, total };
352
+ };
353
+ const known = prev.code != null ? prev.code : loaded.current.get(prev.key);
354
+ if (known != null) setChanges(diff(known));
355
+ else {
356
+ setChanges({ state: "loading", from });
357
+ loadHtmlAttachment(prev.att, undefined, { anyway: true }).then((r) => {
358
+ if (!live) return;
359
+ if (!r.ok) { setChanges({ state: "failed", from, reason: attReason(r) }); return; }
360
+ loaded.current.set(prev.key, r.code);
361
+ setChanges(diff(r.code));
362
+ });
363
+ }
364
+ return () => { live = false; };
365
+ }, [shown, open && open.key, prev && prev.key]);
237
366
  const fileName = open && open.att ? open.att.name : undefined;
238
367
  // Esc in the panel closes it (and is not the chat's "stop the reply").
239
- const onPanelKey = (e) => { if (e.key === "Escape" && !e.isComposing) { e.preventDefault(); closePanel(); } };
368
+ const onPanelKey = (e) => {
369
+ if (e.key !== "Escape" || e.isComposing) return;
370
+ e.preventDefault();
371
+ if (fs) leaveFs(); else closePanel();
372
+ };
240
373
 
241
374
  if (!open) return null;
242
375
  const going = run.state === "starting" || run.state === "running";
@@ -259,33 +392,81 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
259
392
  const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
260
393
  return html`<div data-trust="preview" class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
261
394
  aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>
262
- <aside data-trust="preview" class="pv-panel" ref=${panel} aria-label=${t("chat.pvPanel")} onKeyDown=${onPanelKey}>
395
+ <aside data-trust="preview" class=${`pv-panel${fs ? ` pv-fs pv-fs-${fs}` : ""}`} ref=${panel} aria-label=${t("chat.pvPanel")} onKeyDown=${onPanelKey}>
263
396
  <div class="pv-panel-head">
264
397
  <button type="button" class="icon-btn only-narrow pv-panel-close" title=${t("chat.pvBack")} aria-label=${t("chat.pvBack")} onClick=${closePanel}><${Icon} name="back" size=${18} /></button>
265
398
  <div class="pv-panel-title"><strong>${t("chat.pvPanel")}</strong>
266
399
  <button type="button" class="pv-source" title=${source} onClick=${() => { if (window.matchMedia && window.matchMedia("(max-width: 899px)").matches) closePanel(); reveal(open.msgKey); }}>${source}</button></div>
400
+ ${pages.length > 1 ? html`<button type="button" class=${`btn btn-sm pv-pages-toggle${listing ? " on" : ""}`} aria-pressed=${listing ? "true" : "false"} title=${t("chat.pvPagesTitle")}
401
+ onClick=${() => setListing(v => !v)}><${Icon} name="file" size=${14} />${t("chat.pvPagesCount", pages.length)}</button>` : null}
267
402
  <button type="button" class="icon-btn only-wide pv-panel-close" title=${t("chat.pvClose")} aria-label=${t("chat.pvClose")} onClick=${closePanel}><${Icon} name="close" size=${18} /></button>
268
403
  </div>
269
404
  <div class="pv-panel-bar">
405
+ ${vers.list.length > 1 && vers.index >= 0 ? html`<div class="pv-ver" role="group" aria-label=${t("chat.pvVersions")}>
406
+ <button type="button" class="icon-btn pv-ver-prev" title=${t("chat.pvVerPrev")} aria-label=${t("chat.pvVerPrev")} disabled=${vers.index === 0} onClick=${() => step(-1)}><${Icon} name="prev" size=${16} /></button>
407
+ <span class="pv-ver-at" aria-live="polite" title=${t("chat.pvVerLong", vers.index + 1, vers.list.length)} aria-label=${t("chat.pvVerLong", vers.index + 1, vers.list.length)}>${t("chat.pvVer", vers.index + 1, vers.list.length)}</span>
408
+ <button type="button" class="icon-btn pv-ver-next" title=${t("chat.pvVerNext")} aria-label=${t("chat.pvVerNext")} disabled=${vers.index === vers.list.length - 1} onClick=${() => step(1)}><${Icon} name="next" size=${16} /></button>
409
+ </div>` : null}
270
410
  <div class="seg-inline" role="group" aria-label=${t("chat.pvPanel")}>
271
- <button type="button" class=${`btn btn-sm${view === "preview" ? " on" : ""}`} aria-pressed=${view === "preview" ? "true" : "false"} onClick=${() => setView("preview")}><${Icon} name="eye" size=${14} />${t("chat.pvPage")}</button>
272
- <button type="button" class=${`btn btn-sm${view === "code" ? " on" : ""}`} aria-pressed=${view === "code" ? "true" : "false"} onClick=${() => setView("code")}><${Icon} name="code" size=${14} />${t("chat.pvCode")}</button>
411
+ <button type="button" class=${`btn btn-sm${shown === "preview" ? " on" : ""}`} aria-pressed=${shown === "preview" ? "true" : "false"} title=${t("chat.pvPage")} onClick=${() => pickView("preview")}><${Icon} name="eye" size=${14} /><span class="lbl">${t("chat.pvPage")}</span></button>
412
+ <button type="button" class=${`btn btn-sm${shown === "code" ? " on" : ""}`} aria-pressed=${shown === "code" ? "true" : "false"} title=${t("chat.pvCode")} onClick=${() => pickView("code")}><${Icon} name="code" size=${14} /><span class="lbl">${t("chat.pvCode")}</span></button>
413
+ ${prev ? html`<button type="button" class=${`btn btn-sm pv-changes${shown === "changes" ? " on" : ""}`} aria-pressed=${shown === "changes" ? "true" : "false"} title=${t("chat.pvChanges")} onClick=${() => pickView("changes")}><${Icon} name="diff" size=${14} /><span class="lbl">${t("chat.pvChanges")}</span></button>` : null}
273
414
  </div>
274
415
  <span class="grow"></span>
416
+ ${shown === "preview" ? html`<select class="pv-width" aria-label=${t("chat.pvWidth")} title=${t("chat.pvWidth")} value=${device} onChange=${(e) => setDevice(e.currentTarget.value)}>
417
+ <option value="full">${t("chat.pvWidthFull")}</option><option value="tablet">${t("chat.pvWidthTablet")}</option><option value="phone">${t("chat.pvWidthPhone")}</option></select>` : null}
275
418
  ${going ? html`<button type="button" class="btn btn-sm pv-panel-reload" title=${t("chat.pvReload")} onClick=${() => { stop(); start(true); }}><${Icon} name="restart" size=${14} /><span class="lbl">${t("chat.pvReload")}</span></button>
276
419
  <button type="button" class="btn btn-sm pv-panel-stop" title=${t("chat.pvStop")} onClick=${stop}><${Icon} name="stop" size=${14} /><span class="lbl">${t("chat.pvStop")}</span></button>`
277
420
  : run.state === "large" ? html`<button type="button" class="btn btn-sm btn-primary pv-panel-anyway" title=${t("chat.pvAnyway")} disabled=${!a.ok} onClick=${() => start(true)}><${Icon} name="play" size=${14} /><span class="lbl">${t("chat.pvAnyway")}</span></button>`
278
421
  : html`<button type="button" class="btn btn-sm btn-primary pv-panel-run" title=${t("chat.pvPreview")} disabled=${!a.ok} onClick=${() => start()}><${Icon} name="play" size=${14} /><span class="lbl">${t("chat.pvPreview")}</span></button>`}
422
+ <button type="button" class=${`btn btn-sm only-wide pv-panel-fs${fs ? " on" : ""}`} aria-pressed=${fs ? "true" : "false"} title=${t(fs ? "chat.pvFsExit" : "chat.pvFs")} aria-label=${t(fs ? "chat.pvFsExit" : "chat.pvFs")}
423
+ onClick=${() => (fs ? leaveFs() : enterFs())}><${Icon} name=${fs ? "fullscreenexit" : "fullscreen"} size=${14} /></button>
424
+ <${PanelActions} open=${open} name=${name} run=${run} view=${view} />
279
425
  <button type="button" class="btn btn-sm pv-panel-dl" title=${t("chat.pvDownload")} onClick=${() => download(open.code, fileName)}><${Icon} name="download" size=${14} /><span class="lbl">${t("chat.pvDownload")}</span></button>
280
426
  </div>
281
427
  ${newer && newer.key !== open.key ? html`<div class="pv-newer" role="status"><span>${t("chat.pvNewer", timeOf(newer.ts))}</span>
282
428
  <button type="button" class="btn btn-sm pv-newer-show" onClick=${showNewer}>${t("chat.pvNewerShow")}</button>${newerNote ? html`<span class="pv-newer-note">${newerNote}</span>` : null}</div>` : null}
429
+ ${newerNote && !(newer && newer.key !== open.key) ? html`<div class="pv-note pv-ver-note" role="status">${newerNote}</div>` : null}
283
430
  ${going && !P().bannerAcked() ? html`<div class="pv-banner"><span class="pv-banner-text">${P().banner()}</span>
284
431
  <button type="button" class="btn btn-sm btn-ghost pv-ack" title=${P().bannerAckTitle()} onClick=${() => P().ackBanner()}>${P().bannerAckLabel()}</button></div>` : null}
285
- ${note && view === "preview" ? html`<div class="pv-note">${note}${askOptIn ? html` <button type="button" class="btn btn-sm pv-panel-allow" onClick=${allowHere}>${t("chat.pvAllowButton")}</button>` : null}</div>` : null}
286
- <div class=${`pv-panel-body${view === "code" ? " show-code" : ""}`}>
432
+ ${cdnHost && shown === "preview" ? html`<div class="pv-note pv-cdn-note" role="status">${t("chat.pvCdnNote", cdnHost)}</div>` : null}
433
+ ${note && shown === "preview" ? html`<div class="pv-note">${note}${askOptIn ? html` <button type="button" class="btn btn-sm pv-panel-allow" onClick=${allowHere}>${t("chat.pvAllowButton")}</button>` : null}</div>` : null}
434
+ <div class=${`pv-panel-body${listing || shown !== "preview" ? " show-code" : ` w-${device}`}`}>
287
435
  <div class="pv-holder" ref=${holder}></div>
288
- ${view === "code" ? html`<pre class="pv-code"><code>${open.code}</code></pre>` : null}
436
+ ${listing ? html`<${PageList} pages=${pages} current=${pageIdOf(open)} pick=${(p) => { setListing(false); if (p.id !== pageIdOf(open)) show(p.latest); }} />` : null}
437
+ ${!listing && shown === "code" ? html`<pre class="pv-code"><code>${open.code}</code></pre>` : null}
438
+ ${!listing && shown === "changes" ? html`<${Changes} changes=${changes} />` : null}
289
439
  </div>
290
440
  </aside>`;
291
441
  }
442
+
443
+ /** The Changes view: hunks of the line diff as a unified diff shows them, drawn as text (never as HTML). */
444
+ function Changes({ changes }) {
445
+ if (!changes || changes.state === "loading") return html`<div class="pv-note pv-diff-note">${t("chat.pvLoading")}</div>`;
446
+ if (changes.state === "failed") return html`<div class="pv-note pv-diff-note">${changes.reason}</div>`;
447
+ if (changes.state === "big") return html`<div class="pv-note pv-diff-note">${t("chat.pvChangesBig")}</div>`;
448
+ if (!changes.hunks.length) return html`<div class="pv-note pv-diff-note">${t("chat.pvChangesNone", changes.from)}</div>`;
449
+ const rows = [];
450
+ let left = DIFF_LIMITS.maxLines;
451
+ for (const h of changes.hunks) {
452
+ if (left <= 0) break;
453
+ rows.push(html`<span class="d-hunk">${`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@\n`}</span>`);
454
+ left--;
455
+ for (const l of h.lines) {
456
+ if (left-- <= 0) break;
457
+ rows.push(html`<span class=${l.t === "+" ? "d-add" : l.t === "-" ? "d-del" : "d-ctx"}>${`${l.t} ${l.text}\n`}</span>`);
458
+ }
459
+ }
460
+ return html`<div class="pv-diff-wrap"><div class="pv-diff-head">${t("chat.pvChangesFrom", changes.from)}${changes.total > DIFF_LIMITS.maxLines ? html` · <span class="pv-diff-cut">${t("chat.pvChangesCut", DIFF_LIMITS.maxLines)}</span>` : null}</div>
461
+ <pre class="pv-code pv-diff"><code>${rows}</code></pre></div>`;
462
+ }
463
+
464
+ /** #2911 (6): the thread's pages, newest first — one row each, its newest version opened by a click (waiting for Preview). */
465
+ function PageList({ pages, current, pick }) {
466
+ return html`<ul class="pv-pages" aria-label=${t("chat.pvPagesTitle")}>${pages.map(p => html`<li key=${p.id}>
467
+ <button type="button" class=${`pv-page${p.id === current ? " on" : ""}`} aria-current=${p.id === current ? "true" : undefined} onClick=${() => pick(p)}>
468
+ <${Icon} name=${p.kind === "file" ? "attach" : "code"} size=${14} />
469
+ <span class="pv-page-name">${p.label || t("chat.pvPagesUntitled")}</span>
470
+ <span class="pv-page-meta">${p.versions > 1 ? t("chat.pvPagesVersions", p.versions) : ""}${p.versions > 1 ? " · " : ""}${timeOf(p.latest.ts)}</span>
471
+ </button></li>`)}</ul>`;
472
+ }
@@ -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. */