@songsid/agend 2.3.3-beta.5 → 2.3.3-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/backend/antigravity.js +6 -1
  2. package/dist/backend/antigravity.js.map +1 -1
  3. package/dist/backend/claude-code.d.ts +7 -0
  4. package/dist/backend/claude-code.js +14 -3
  5. package/dist/backend/claude-code.js.map +1 -1
  6. package/dist/backend/codex.js +11 -2
  7. package/dist/backend/codex.js.map +1 -1
  8. package/dist/backend/grok.js +58 -12
  9. package/dist/backend/grok.js.map +1 -1
  10. package/dist/backend/kiro.js +2 -0
  11. package/dist/backend/kiro.js.map +1 -1
  12. package/dist/backend/muse.js +2 -1
  13. package/dist/backend/muse.js.map +1 -1
  14. package/dist/backend/types.d.ts +22 -0
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/daemon.d.ts +8 -0
  17. package/dist/daemon.js +37 -4
  18. package/dist/daemon.js.map +1 -1
  19. package/dist/doctor.d.ts +4 -0
  20. package/dist/doctor.js +16 -1
  21. package/dist/doctor.js.map +1 -1
  22. package/dist/fleet-manager.d.ts +5 -0
  23. package/dist/fleet-manager.js +24 -1
  24. package/dist/fleet-manager.js.map +1 -1
  25. package/dist/instance-lifecycle.d.ts +6 -0
  26. package/dist/instance-lifecycle.js +19 -3
  27. package/dist/instance-lifecycle.js.map +1 -1
  28. package/dist/locale.js +56 -4
  29. package/dist/locale.js.map +1 -1
  30. package/dist/notify-socket.d.ts +10 -0
  31. package/dist/notify-socket.js +45 -0
  32. package/dist/notify-socket.js.map +1 -0
  33. package/dist/outbound-schemas.js +1 -1
  34. package/dist/outbound-schemas.js.map +1 -1
  35. package/dist/quota-notice.d.ts +23 -0
  36. package/dist/quota-notice.js +22 -0
  37. package/dist/quota-notice.js.map +1 -0
  38. package/dist/sd-notify.d.ts +10 -1
  39. package/dist/sd-notify.js +27 -2
  40. package/dist/sd-notify.js.map +1 -1
  41. package/dist/settings-confirmation.d.ts +17 -3
  42. package/dist/settings-confirmation.js +8 -5
  43. package/dist/settings-confirmation.js.map +1 -1
  44. package/dist/settings-http-confirmation.js +31 -1
  45. package/dist/settings-http-confirmation.js.map +1 -1
  46. package/dist/topic-commands.d.ts +21 -5
  47. package/dist/topic-commands.js +93 -36
  48. package/dist/topic-commands.js.map +1 -1
  49. package/dist/ui/chat-strings.js +6 -4
  50. package/dist/ui/chat-thread.js +45 -15
  51. package/dist/ui/fleet-office-skin.js +37 -14
  52. package/dist/ui/fleet-office.js +31 -7
  53. package/dist/ui/html-attachment.js +22 -8
  54. package/dist/ui/preview-panel.js +9 -6
  55. package/dist/ui/preview.js +10 -3
  56. package/dist/ui/settings-confirm.js +18 -0
  57. package/dist/ui/shared/app-i18n.js +13 -2
  58. package/dist/ui/shared/app-shell.js +35 -1
  59. package/dist/ui/shared/app.css +15 -0
  60. package/dist/web-preview.d.ts +5 -2
  61. package/dist/web-preview.js +6 -3
  62. package/dist/web-preview.js.map +1 -1
  63. package/package.json +1 -1
@@ -44,7 +44,8 @@ register("chat", {
44
44
  pvConfirm: "Allow HTML previews?\n\nThis applies on every device where you are signed in. A preview runs the agent's HTML in an isolated frame. It cannot use your login, but it may be able to send data out, and it can slow or freeze this tab. You still click Preview each time.",
45
45
  pvNever: "Never preview HTML on this device", pvNeverUndo: "Let this device preview HTML again", pvStarting: "Starting the preview…",
46
46
  pvChanged: "Preview stopped — the message changed. Preview again.", pvTruncated: "Truncated — ask the agent to send it as a .html file.",
47
- pvLoading: "Reading the file…", pvAttOver: "This file is over 1 MiB; it is not previewed.",
47
+ pvLoading: "Reading the file…", pvAttOver: "This file is over 50 MB; it is not previewed. Download it to open it.",
48
+ pvLarge: "This file is large ({0}). Preview anyway?", pvAnyway: "Preview anyway",
48
49
  pvAttGone: "This file is no longer available here (it changed, or the fleet restarted). Ask the agent to send it again.",
49
50
  pvAttFailed: "Could not read this file. Try again.",
50
51
  pvBannerAck: "I understand", pvBannerAckTitle: "Hide this notice until you sign in again",
@@ -64,7 +65,7 @@ register("chat", {
64
65
  pvWhyNever: "Previews are switched off on this device for this session.",
65
66
  pvWhyOrigin: "Previews are off: this page is at {0}, but the fleet believes it is at {1} (a proxy that rewrites Host). Pass the external Host through, or set web.preview_origin.",
66
67
  pvUnknownAddress: "an unknown address", pvWhyServer: "Previews are not available here.",
67
- pvHtmlOver: "This HTML is over 1 MiB; it is not previewed.",
68
+ pvHtmlOver: "This HTML is over 50 MB; it is not previewed. Download it to open it.",
68
69
  pvNoAnswer: "Preview unavailable: the preview did not answer within 3 s. If an earlier preview froze, it may still be running — open the dashboard in a new tab. Over SSH, forward the preview port too.",
69
70
  pvStoppedAnswering: "The preview stopped answering, so it was closed.", pvSwitchedOff: "Previews were switched off on this device.",
70
71
  pvAnother: "Another preview started.", pvWrongBoot: "Preview unavailable: something else answered on the preview port.",
@@ -167,7 +168,8 @@ register("chat", {
167
168
  pvConfirm: "要允許預覽 HTML 嗎?\n\n這會套用到你登入的每一台裝置。預覽會在隔離的框架裡執行 agent 的 HTML。它無法使用你的登入,但可能把資料傳出去,也可能讓這個分頁變慢或卡住。每次仍需要你按「預覽」。",
168
169
  pvNever: "這台裝置永不預覽 HTML", pvNeverUndo: "讓這台裝置再次可以預覽 HTML", pvStarting: "正在啟動預覽…",
169
170
  pvChanged: "預覽已停止——訊息內容變了。請再預覽一次。", pvTruncated: "內容被截斷——請 agent 改用 .html 檔案傳送。",
170
- pvLoading: "正在讀取檔案…", pvAttOver: "這個檔案超過 1 MiB,不提供預覽。",
171
+ pvLoading: "正在讀取檔案…", pvAttOver: "這個檔案超過 50 MB,不提供預覽,請下載後開啟。",
172
+ pvLarge: "這個檔案比較大({0}),仍要預覽嗎?", pvAnyway: "仍要預覽",
171
173
  pvAttGone: "這裡已無法取得這個檔案(檔案已變更,或艦隊已重新啟動)。請 agent 再傳一次。",
172
174
  pvAttFailed: "無法讀取這個檔案,請再試一次。",
173
175
  pvBannerAck: "我知道了", pvBannerAckTitle: "隱藏這則提醒,直到下次登入",
@@ -185,7 +187,7 @@ register("chat", {
185
187
  pvWhyNever: "這台裝置在這個工作階段已關閉預覽。",
186
188
  pvWhyOrigin: "預覽已關閉:這個頁面位於 {0},但艦隊認為它位於 {1}(有會改寫 Host 的代理)。請讓外部 Host 原樣傳入,或設定 web.preview_origin。",
187
189
  pvUnknownAddress: "未知的網址", pvWhyServer: "這裡無法使用預覽。",
188
- pvHtmlOver: "這段 HTML 超過 1 MiB,不提供預覽。",
190
+ pvHtmlOver: "這段 HTML 超過 50 MB,不提供預覽,請下載後開啟。",
189
191
  pvNoAnswer: "無法預覽:預覽在 3 秒內沒有回應。如果先前的預覽卡住了,它可能還在執行——請在新分頁開啟儀表板。透過 SSH 使用時,也要轉送預覽的連接埠。",
190
192
  pvStoppedAnswering: "預覽沒有回應,已將它關閉。", pvSwitchedOff: "這台裝置已關閉預覽。",
191
193
  pvAnother: "另一個預覽已開始。", pvWrongBoot: "無法預覽:預覽連接埠上回應的不是這個艦隊。",
@@ -8,7 +8,7 @@
8
8
  // Unrelated updates (a new message, another message's tick) stop nothing (#1306 §6.2, #1408 §4).
9
9
  import "./chat-render.js";
10
10
  import "./preview.js";
11
- import { loadHtmlAttachment } from "./html-attachment.js";
11
+ import { HTML_PREVIEW_MAX, loadHtmlAttachment, sizeLabel } from "./html-attachment.js";
12
12
  import { chatTime, chatTimeFull, onDayChange } from "./chat-time.js";
13
13
  import { textLinkSegments } from "/assets/app-route.js";
14
14
 
@@ -23,6 +23,12 @@ export const TEXT_PREVIEW_LINES = 200;
23
23
  export const TEXT_PREVIEW_NODES = 4000;
24
24
  /** A csv/tsv wider than this is shown as text, as Markdown tables are capped at 20 columns. */
25
25
  export const TEXT_PREVIEW_COLUMNS = 50;
26
+ /**
27
+ * #2863: the most a text card reads (bytes). It stays 1 MiB while an HTML preview goes to 8 MiB (and 50 MiB on
28
+ * "Preview anyway"): a text card reads, parses and formats the file on this page's own thread (Markdown, highlighting,
29
+ * a CSV table; Show all draws every line), where an HTML preview runs in its own frame. A bigger file is a download.
30
+ */
31
+ export const TEXT_CARD_MAX = 1024 * 1024;
26
32
  const P = () => globalThis.AgendPreview;
27
33
  /** #1672: a status line longer than this (lines, or characters) is folded until it is opened. */
28
34
  export const STATUS_FOLD_LINES = 6;
@@ -406,7 +412,7 @@ export function createThread(list, scroller, opts) {
406
412
  if (reading) return;
407
413
  if (text !== null) { body.hidden = !body.hidden; show.textContent = tr(body.hidden ? "chat.tcShow" : "chat.tcHide"); return; }
408
414
  const mine = reading = ++reads; note.textContent = tr("chat.pvLoading");
409
- const r = await loadHtmlAttachment({ id: att.id, name: att.name, size: att.size });
415
+ const r = await loadHtmlAttachment({ id: att.id, name: att.name, size: att.size }, undefined, { ask: TEXT_CARD_MAX, max: TEXT_CARD_MAX });
410
416
  if (reading !== mine) return; // dropped (its chat left meanwhile): a new click reads again
411
417
  reading = 0;
412
418
  if (!ph.isConnected) return; // the card left the page while it was read: nothing to draw
@@ -462,6 +468,8 @@ export function createThread(list, scroller, opts) {
462
468
  const head = el("div", "pv-head"); head.setAttribute("data-trust", "thread-control");
463
469
  const label = el("span", "pv-label", att ? `${tr("chat.pvHtml")} · ${att.name}` : tr("chat.pvHtml"));
464
470
  const run = el("button", "btn btn-sm pv-run", tr("chat.pvPreview")); run.type = "button";
471
+ // #2863: a large file (over 8 MB) is previewed only on this second click, in the card or the panel as first asked.
472
+ const anywayB = el("button", "btn btn-sm pv-anyway", tr("chat.pvAnyway")); anywayB.type = "button"; anywayB.hidden = true;
465
473
  const stopB = el("button", "btn btn-sm pv-stop", tr("chat.pvStop")); stopB.type = "button"; stopB.hidden = true;
466
474
  // An attachment already has its download link (the file itself) right above the card.
467
475
  const dl = att ? null : el("button", "btn btn-sm btn-ghost pv-dl", tr("chat.pvDownload"));
@@ -474,7 +482,7 @@ export function createThread(list, scroller, opts) {
474
482
  const optB = el("button", "chip-btn pv-opt"); optB.type = "button";
475
483
  const neverB = el("button", "chip-btn pv-never"); neverB.type = "button";
476
484
  menu.append(sum, optB, neverB);
477
- head.append(label, run, stopB, ...(toPanel ? [toPanel] : []), ...(dl ? [dl] : []), menu);
485
+ head.append(label, run, anywayB, stopB, ...(toPanel ? [toPanel] : []), ...(dl ? [dl] : []), menu);
478
486
  const note = el("div", "pv-note"); note.setAttribute("data-trust", "thread-control");
479
487
  // #1568: the banner, with its "I understand" (hidden until the next sign-in; the frame's border stays).
480
488
  const banner = el("div", "pv-banner"); banner.setAttribute("data-trust", "thread-control"); banner.hidden = true;
@@ -486,45 +494,64 @@ export function createThread(list, scroller, opts) {
486
494
  // Reading an attachment: one read per card at a time (a second click while it reads does nothing), and a read
487
495
  // whose card was stopped, replaced or left meanwhile is dropped (`reading` moved on, or the card left the page).
488
496
  let reading = 0, reads = 0;
497
+ // #2863: where "Preview anyway" runs it ("card" | "panel") and what the card says meanwhile; null: not asked.
498
+ // `tooBig`: over the 50 MB ceiling (listed, or read) — the card says so and offers only the file's download.
499
+ let large = null, largeNote = "", tooBig = !!att && typeof att.size === "number" && att.size > HTML_PREVIEW_MAX;
489
500
  const refresh = (reason) => {
490
501
  const a = P().availability();
491
502
  const going = state === "starting" || state === "running";
492
503
  const inPanel = !!panel && panel.shown() === key; // #1481: the panel shows this block; the card points there
493
- run.hidden = !a.ok || going || inPanel || !!reading;
494
- if (toPanel) toPanel.hidden = inPanel || !!reading;
504
+ if (going || reading) large = null;
505
+ if (reason == null && large) reason = largeNote;
506
+ if (reason == null && tooBig) reason = tr("chat.pvAttOver");
507
+ run.hidden = !a.ok || going || inPanel || !!reading || !!large || tooBig;
508
+ anywayB.hidden = !a.ok || !large;
509
+ if (toPanel) toPanel.hidden = inPanel || !!reading || tooBig;
495
510
  stopB.hidden = !going && !reading;
496
511
  banner.hidden = !going || P().bannerAcked();
497
512
  note.textContent = reason != null ? reason : reading ? tr("chat.pvLoading") : going ? (state === "starting" ? tr("chat.pvStarting") : "") : inPanel ? tr("chat.pvInPanel") : a.ok ? "" : a.reason;
498
513
  optB.textContent = P().optedIn() ? tr("chat.pvDisallow") : tr("chat.pvAllow");
499
514
  neverB.textContent = P().never() ? tr("chat.pvNeverUndo") : tr("chat.pvNever");
500
515
  };
501
- /** The HTML, then `then(code)`: at once for a fence or an attachment already read; otherwise read it first. */
502
- const withCode = async (then) => {
516
+ /** Over 8 MB: the card asks, and "Preview anyway" runs it where `target` ("card" | "panel") says. */
517
+ const askLarge = (target, reason) => { large = target; largeNote = reason; refresh(reason); };
518
+ /**
519
+ * The HTML, then `then(code)`: at once for a fence or an attachment already read; otherwise read it first — a large
520
+ * one only once `anyway` (its listed size asks before anything is fetched).
521
+ */
522
+ const withCode = async (then, target = "card", anyway = false) => {
503
523
  if (code != null) { then(code); return; }
504
524
  if (reading) return;
505
525
  const mine = reading = ++reads;
506
526
  refresh();
507
- const r = await loadHtmlAttachment(att);
527
+ const r = await loadHtmlAttachment(att, undefined, { anyway });
508
528
  if (reading !== mine || !ph.isConnected) return;
509
529
  reading = 0;
530
+ if (!r.ok && r.reason === "large") { askLarge(target, trf("chat.pvLarge", sizeLabel(r.size))); return; }
531
+ if (!r.ok && r.reason === "over") { tooBig = true; refresh(); return; }
510
532
  if (!r.ok) { refresh(attReason(r.reason)); return; }
511
533
  code = r.code;
512
534
  refresh();
513
535
  then(code);
514
536
  };
515
- const ui = { state: (name, reason) => { state = name === "starting" || name === "running" ? name : "idle"; refresh(reason || null); } };
537
+ const ui = { state: (name, reason) => {
538
+ state = name === "starting" || name === "running" ? name : "idle";
539
+ if (name === "large") { askLarge("card", reason || ""); return; }
540
+ large = null; refresh(reason || null);
541
+ } };
516
542
  // Nothing is read for Preview on a device that does not allow previews: the button is not even shown there.
517
543
  // A Preview click is newer than any Open in panel still reading (#1704): it claims the panel's turn too — and, its
518
544
  // own HTML read, starts only if no newer click claimed meanwhile and no preview was started since (the panel's own
519
545
  // Preview): a late read never undoes a newer choice. A close is neither, so it does not drop this click.
520
- run.onclick = () => {
546
+ const preview = (anyway) => {
521
547
  if (!P().availability().ok || reading) return; // this card already reading: that click stays the one
522
548
  const mine = panel ? (panel.claim(), panel.claims()) : 0, started = P().startCount();
523
549
  withCode((c) => {
524
550
  if ((panel && panel.claims() !== mine) || P().startCount() !== started) { refresh(); return; }
525
- P().start(key, holder, c, ui);
526
- });
551
+ if (anyway) P().start(key, holder, c, ui, { anyway: true }); else P().start(key, holder, c, ui);
552
+ }, "card", anyway);
527
553
  };
554
+ run.onclick = () => preview(false);
528
555
  stopB.onclick = () => { if (reading) { reading = 0; refresh(); } else P().stop(key, "stopped", ""); };
529
556
  // #1704: this click runs it in the panel, as Preview there would — a running preview moves (it stops here and
530
557
  // starts there), an idle one starts there. Whether it can run is the panel's to say: a device that has not allowed
@@ -532,15 +559,18 @@ export function createThread(list, scroller, opts) {
532
559
  // An attachment's HTML is read first. The click claims the panel at once, so a newer click (even one still reading)
533
560
  // wins: if the panel changed (another click, block, close) or any preview started meanwhile, this one is dropped —
534
561
  // a late read never undoes a newer click.
535
- if (toPanel) toPanel.onclick = () => {
562
+ const openInPanel = (anyway) => {
536
563
  if (reading) return; // this card already reading: that click stays the one
537
564
  if (P().running(key)) P().stop(key, "stopped", "");
538
565
  const at = panel.claim(), started = P().startCount();
539
566
  withCode((c) => {
540
567
  if (panel.version() !== at || P().startCount() !== started) { refresh(); return; }
541
- panel.open({ key, instance: x.instance, msgKey: msgKey(x), ...(att ? { att } : { n: source.n }), code: c, sender: x.sender, ts: x.ts }, { run: true });
542
- });
568
+ large = null; refresh();
569
+ panel.open({ key, instance: x.instance, msgKey: msgKey(x), ...(att ? { att } : { n: source.n }), code: c, sender: x.sender, ts: x.ts, ...(anyway ? { anyway: true } : {}) }, { run: true });
570
+ }, "panel", anyway);
543
571
  };
572
+ if (toPanel) toPanel.onclick = () => openInPanel(false);
573
+ anywayB.onclick = () => { const where = large; large = null; if (where === "panel" && toPanel) openInPanel(true); else preview(true); };
544
574
  if (dl) dl.onclick = () => opts.download(code);
545
575
  ack.onclick = () => P().ackBanner(); // every card and the panel hear it (onChange)
546
576
  optB.onclick = () => { menu.open = false; opts.setPreviewOptIn(!P().optedIn()); };
@@ -27,7 +27,7 @@
27
27
  // @property {(ctx, item, active, ms, env) => void} [drawFurniture] zones: one furniture item (active: activeWhen holds)
28
28
  // @property {(ctx, L, env) => void} [drawForeground] zones: a pack's foreground over the whole 1280×720 scene, above
29
29
  // the people, furniture and items in the air, below the crowd counts, name tags and bubbles
30
- // @property {(ctx, x, y, look, env) => void} [drawTag] zones: a name tag under feet at (x, y)
30
+ // @property {(ctx, x, y, look, env, compact?) => void} [drawTag] zones: a name tag under feet at (x, y) (compact: #2860)
31
31
  // @property {(ctx, area, n, env) => void} [drawCrowd] zones: an area's "+n" (who found no spot in it)
32
32
  // @property {(ctx, desk, look, ms, env) => void} drawCharacter the occupant in its pose at time `ms` (0: still); in
33
33
  // zones `desk` is the character's box at its current position (its feet at desk.seat); walking, `look.heading` is
@@ -293,21 +293,44 @@ function drawBubble(ctx, x, y, text, env, lift = 0) {
293
293
  /** A zones name tag's widest text (logical units): longer names are cut with an ellipsis. */
294
294
  export const TAG_TEXT_W = 84;
295
295
  const TAG_FONT = "600 10px system-ui, sans-serif";
296
+ /**
297
+ * #2860: the compact tag a crowded area keeps on (it used to show tags on hover only, #2513): a smaller font, a smaller
298
+ * plate, a shorter cut, the same level badge.
299
+ */
300
+ const COMPACT = { font: "500 9px system-ui, sans-serif", textW: 48, pad: 6, top: 2, h: 11 };
301
+ const NORMAL = { font: TAG_FONT, textW: TAG_TEXT_W, pad: 10, top: 3, h: 14 };
296
302
  /** The box a name tag under feet at (x, y) takes (what drawTag draws; bubbles keep clear of it, #2513). */
297
- export function tagBox(ctx, x, y, label) {
303
+ export function tagBox(ctx, x, y, label, compact = false) {
304
+ const k = compact ? COMPACT : NORMAL;
305
+ ctx.save();
306
+ ctx.font = k.font;
307
+ const t = fitText(ctx, label, k.textW), w = Math.ceil((ctx.measureText ? ctx.measureText(t).width : t.length * 6) + k.pad);
308
+ ctx.restore();
309
+ return { x: Math.round(x - w / 2), y: y + k.top, w, h: k.h, text: t };
310
+ }
311
+ /** #2860: what a tag covers with its level badge: on a full tag's top-left corner (half outside); left of a compact one. */
312
+ export function tagFootprint(ctx, box, badge, compact = false) {
313
+ if (!badge) return box;
314
+ if (compact) { const w = badgeWidth(ctx, badge) + 1; return { x: box.x - w, y: box.y, w: box.w + w, h: box.h }; }
315
+ return { x: box.x - 10, y: box.y - 7, w: box.w + 10, h: box.h + 7 };
316
+ }
317
+ function badgeWidth(ctx, badge) {
318
+ if (badge.mark) return 8;
298
319
  ctx.save();
299
- ctx.font = TAG_FONT;
300
- const t = fitText(ctx, label, TAG_TEXT_W), w = Math.ceil((ctx.measureText ? ctx.measureText(t).width : t.length * 6) + 10);
320
+ ctx.font = BADGE_FONT;
321
+ const w = Math.max(12, Math.ceil((ctx.measureText ? ctx.measureText(badge.text).width : badge.text.length * 5) + 6));
301
322
  ctx.restore();
302
- return { x: Math.round(x - w / 2), y: y + 3, w, h: 14, text: t };
323
+ return w;
303
324
  }
304
325
  /**
305
326
  * #2661: a level badge on a name tag's top-left corner (x, y: the tag's corner), overlapping its edge like an app's
306
327
  * badge — it takes none of the tag's width. `badge`: { text, tier } (fleet-office-levels.js badgeOf), or { mark } for a
307
328
  * pinned title that hides the number: a small diamond.
308
329
  */
309
- export function drawBadge(ctx, x, y, badge, pal) {
330
+ export function drawBadge(ctx, x, y, badge, pal, inline = false) {
310
331
  if (!badge) return;
332
+ // #2860 inline (a compact tag): (x, y) is the tag's left edge and middle; the badge sits just left of it, whole.
333
+ if (inline) { const w = badgeWidth(ctx, badge); x = x - 1 - w + (badge.mark ? w / 2 : w / 2 - 3); y = y + (badge.mark ? 0 : 1); }
311
334
  const c = tierColours(badge.tier, pal);
312
335
  ctx.save();
313
336
  ctx.globalAlpha = 1;
@@ -331,18 +354,18 @@ export function drawBadge(ctx, x, y, badge, pal) {
331
354
  }
332
355
  const BADGE_FONT = "700 8px system-ui, sans-serif";
333
356
 
334
- /** Zones: a name tag under someone's feet at (x, y). */
335
- function drawTag(ctx, x, y, look, env) {
357
+ /** Zones: a name tag under someone's feet at (x, y); `compact` (#2860): the smaller one a crowded area keeps on. */
358
+ function drawTag(ctx, x, y, look, env, compact = false) {
336
359
  const { pal } = env;
360
+ const b = tagBox(ctx, x, y, look.label, compact);
337
361
  ctx.save();
338
362
  if (look.dim) ctx.globalAlpha = 0.35;
339
- ctx.font = TAG_FONT;
340
- const t = fitText(ctx, look.label, TAG_TEXT_W), w = Math.ceil((ctx.measureText ? ctx.measureText(t).width : t.length * 6) + 10);
341
- ctx.fillStyle = pal.surface; ctx.fillRect(Math.round(x - w / 2), y + 3, w, 14);
342
- ctx.strokeStyle = look.match ? pal.focus : pal.border; ctx.lineWidth = look.match ? 2 : 1; ctx.strokeRect(Math.round(x - w / 2) + 0.5, y + 3.5, w - 1, 13);
343
- ctx.fillStyle = pal.text; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(t, x, y + 10.5);
363
+ ctx.font = compact ? COMPACT.font : TAG_FONT;
364
+ ctx.fillStyle = pal.surface; ctx.fillRect(b.x, b.y, b.w, b.h);
365
+ ctx.strokeStyle = look.match ? pal.focus : pal.border; ctx.lineWidth = look.match ? 2 : 1; ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1);
366
+ ctx.fillStyle = pal.text; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(b.text, x, b.y + b.h / 2 + 0.5);
344
367
  ctx.restore();
345
- if (look.badge) drawBadge(ctx, Math.round(x - w / 2), y + 3, look.badge, pal);
368
+ if (look.badge) compact ? drawBadge(ctx, b.x, b.y + b.h / 2, look.badge, pal, true) : drawBadge(ctx, b.x, b.y, look.badge, pal);
346
369
  }
347
370
 
348
371
  /**
@@ -33,8 +33,8 @@
33
33
  // any part — an office names it as its style; one this page cannot load is drawn as Desks, and says so.
34
34
  // - Crowds (#2513): an area with more people than seats packs the rest into a tighter grid, then counts who is left
35
35
  // ("+N", in its corner, never over its name); there idle and asleep people say nothing, at most a few speak (calls
36
- // first, then work), no bubble covers a name tag, and the tags show on hover or focus only. "Hide paused and stopped"
37
- // (per browser) leaves the sleepers out.
36
+ // first, then work), no bubble covers a name tag, and the tags are compact (#2860; one that would touch another shows
37
+ // on hover or focus only). "Hide paused and stopped" (per browser) leaves the sleepers out.
38
38
  // - Share… (#2636; an admin, on the dashboard reached directly or over the public link): a link anyone can open to watch an office live, read-only,
39
39
  // until it ends (office-share-dialog.js; the page it opens is office-share-page.js).
40
40
  // - Edit layout (an admin, a stored office on a zones skin): fleet-office-layout.js, saved as the office's overrides.
@@ -64,7 +64,7 @@ import { nodeText, stateWords } from "./fleet-org.js";
64
64
  import { hits, needle } from "./fleet-canvas-layout.js";
65
65
  import { AUTO_ID, LOBBY, autoLayout, backingScale, coatOf, createFlights, customLayout, deskAt, desksOf, filterOk, lampOf, layoutOk, lightOf, membersOf,
66
66
  officePose, officeRooms, signOf, workOn } from "./fleet-office-model.js";
67
- import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, bubbleBox, loadCozySkin, loadPackSkin, modeOf, skinFor, tagBox } from "./fleet-office-skin.js";
67
+ import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, bubbleBox, loadCozySkin, loadPackSkin, modeOf, skinFor, tagBox, tagFootprint } from "./fleet-office-skin.js";
68
68
  import { CALLING, activeFurniture, bubbleAt, hiddenBy, bubbleRank, NIGHT, WANDER, createMail, createWalkers, isNight, linesFor, npcKey, personAt, placeBubbles, thinBubbles, walkTargets, zonesLayout } from "./fleet-office-zones.js";
69
69
  import { actsOk, createOfficeStore } from "./fleet-office-store.js";
70
70
  import { badgeOf, fixedOf } from "./fleet-office-levels.js";
@@ -131,6 +131,8 @@ const deviceRatio = () => Math.max(0.5, Math.min(4, Number(globalThis.devicePixe
131
131
  const SIZE0 = { w: 960, h: 600 };
132
132
  /** #2554: All offices' Fit keeps this many CSS px clear round the tiles (the zoom controls' column and a margin). */
133
133
  const FIT_PAD = 60;
134
+ /** #2860: how far below its place a crowded area's compact tag drops when a neighbour's is in the way (a tag's height + 1). */
135
+ const COMPACT_ROW = 12;
134
136
  /** #2554: in All offices a tile's name tags show (always) from this many CSS px a unit up; below it on hover only. */
135
137
  export const TAG_MIN_K = 0.75;
136
138
  const PAN_KEYS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
@@ -418,13 +420,19 @@ export function drawPart(ctx, p, a, ox, oy, ms, still, hot, env, tagsOff = false
418
420
  if (sk.drawForeground) sk.drawForeground(ctx, p.L, env);
419
421
  // Who found no spot in an area: a count in its corner (never over its name).
420
422
  for (const [ar, n] of p.L.crowd || []) if (p.L.areas[ar] && sk.drawCrowd) sk.drawCrowd(ctx, p.L.areas[ar], n, env);
421
- // In an area over capacity the name tags show on hover or focus only (#2513: a crowd's tags covered each other).
423
+ // In an area over capacity (#2513: a crowd's full tags covered each other) everyone keeps a compact tag (#2860:
424
+ // in Cozy, the default, most of a fleet is in a crowd, and hover-only hid names and levels); one that would touch
425
+ // a tag already drawn, its badge included, is left to hover or focus. The full tags go first.
422
426
  const over = p.L.over || new Set();
427
+ const crowded = (q) => over.has(q.d.area) && q.d.key !== hot && !q.look.match;
423
428
  const tagged = (q) => !((tagsOff || over.has(q.d.area)) && q.d.key !== hot && !q.look.match);
429
+ const taken = [];
430
+ const touches = (b) => taken.some((o) => b.x < o.x + o.w && o.x < b.x + b.w && b.y < o.y + o.h && o.y < b.y + b.h);
424
431
  for (const q of people) {
425
432
  if (live.hidden.has(q.look.name) || !tagged(q)) continue;
426
433
  sk.drawTag(ctx, q.at.x, q.at.y, q.look, env);
427
- tags.push(tagBox(ctx, q.at.x, q.at.y, q.look.label));
434
+ const box = tagBox(ctx, q.at.x, q.at.y, q.look.label);
435
+ tags.push(box); taken.push(tagFootprint(ctx, box, q.look.badge));
428
436
  }
429
437
  // A furniture item's own name (#2574: an NPC drawn as furniture), tagged like a person; none on a tile too small to read.
430
438
  // #2698: an item hiding its people carries their tags too, in one row at its tag point (its own first; each keeps
@@ -434,7 +442,10 @@ export function drawPart(ctx, p, a, ox, oy, ms, still, hot, env, tagsOff = false
434
442
  for (const f of furniture) {
435
443
  const on = people.filter((q) => live.hidden.get(q.look.name) === f.id);
436
444
  if (!on.length) {
437
- if (!tagsOff && f.label) { const nl = npcLook(p, f); sk.drawTag(ctx, f.x, f.y, nl, env); tags.push(tagBox(ctx, f.x, f.y, nl.label)); }
445
+ if (!tagsOff && f.label) {
446
+ const nl = npcLook(p, f), box = tagBox(ctx, f.x, f.y, nl.label);
447
+ sk.drawTag(ctx, f.x, f.y, nl, env); tags.push(box); taken.push(tagFootprint(ctx, box, nl.badge));
448
+ }
438
449
  continue;
439
450
  }
440
451
  const order = new Map((f.activeFor || []).map((n, i) => [n, i]));
@@ -447,11 +458,24 @@ export function drawPart(ctx, p, a, ox, oy, ms, still, hot, env, tagsOff = false
447
458
  if (q ? !tagged(q) : tagsOff) continue;
448
459
  sk.drawTag(ctx, r.x, f.y, r.look, env);
449
460
  const box = tagBox(ctx, r.x, f.y, r.look.label);
450
- tags.push(box);
461
+ tags.push(box); taken.push(tagFootprint(ctx, box, r.look.badge));
451
462
  if (q) held.push({ name: q.look.name, id: f.id, box });
452
463
  }
453
464
  }
454
465
  p.heldTags = held;
466
+ // The crowd's compact tags go last, clear of every full tag above: people's, an item's own name and the row it
467
+ // carries (#2862 review). A crowd's grid is tighter than a compact tag is wide (OVERFLOW.STEPS down to 32): a
468
+ // neighbour's tag goes a row lower (COMPACT_ROW) rather than out, so a row of people reads as two staggered rows.
469
+ if (!tagsOff) for (const q of people) {
470
+ if (live.hidden.has(q.look.name) || !crowded(q)) continue;
471
+ for (const dy of [0, COMPACT_ROW]) {
472
+ const box = tagBox(ctx, q.at.x, q.at.y + dy, q.look.label, true), foot = tagFootprint(ctx, box, q.look.badge, true);
473
+ if (touches(foot)) continue;
474
+ sk.drawTag(ctx, q.at.x, q.at.y + dy, q.look, env, true);
475
+ tags.push(box); taken.push(foot);
476
+ break;
477
+ }
478
+ }
455
479
  } else {
456
480
  for (const d of p.desks) { const look = p.looks.get(d.key); sk.drawCharacter(ctx, d, actPosed(p, look), ms, env); speakers.push({ look, x: d.x + 58, y: d.y + 12, area: p.roomOf.get(d.key) ?? null }); }
457
481
  blit(p.layers.front);
@@ -3,24 +3,38 @@
3
3
  // card. What runs is then handed to the preview frame exactly like a block's (AgendPreview.start): the file URL itself
4
4
  // is never opened or framed on the dashboard's origin.
5
5
 
6
- /** The same cap as a block's (preview.js LIMITS.maxBytes): UTF-8 bytes of the HTML. */
7
- export const HTML_ATTACHMENT_MAX = 1024 * 1024;
6
+ /**
7
+ * #2863: the sizes a preview takes (UTF-8 bytes of the HTML), the same as a block's (preview.js LIMITS): up to ASK it
8
+ * runs at once; above it the person is asked first ("Preview anyway?"); above MAX there is no preview, only the
9
+ * download. MAX is the frame's own ceiling (web-preview.ts PREVIEW_HTML_MAX_BYTES).
10
+ */
11
+ export const HTML_PREVIEW_ASK = 8 * 1024 * 1024;
12
+ export const HTML_PREVIEW_MAX = 50 * 1024 * 1024;
8
13
 
9
14
  /**
10
- * Read an attachment's HTML: { ok: true, code } or { ok: false, reason } with reason "over" (the file is over the
11
- * cap: its listed size is checked before anything is fetched, the bytes again after), "gone" (the fleet no longer
12
- * serves that id — the file changed, or the fleet restarted), or "failed".
15
+ * Read an attachment's HTML: { ok: true, code } or { ok: false, reason } with reason "large" (above `ask` and not
16
+ * `anyway`: nothing is fetched when its listed size says so; `size` is what it is), "over" (above `max`: its listed size
17
+ * is checked before anything is fetched, the bytes again after), "gone" (the fleet no longer serves that id — the file
18
+ * changed, or the fleet restarted), or "failed". The text card reads with its own, single cap (ask = max).
13
19
  */
14
- export async function loadHtmlAttachment(att, fetchImpl = globalThis.fetch) {
20
+ export async function loadHtmlAttachment(att, fetchImpl = globalThis.fetch, { ask = HTML_PREVIEW_ASK, max = HTML_PREVIEW_MAX, anyway = false } = {}) {
15
21
  if (!att || typeof att.id !== "string" || !/^[0-9a-f]{32}$/.test(att.id)) return { ok: false, reason: "failed" };
16
- if (typeof att.size === "number" && att.size > HTML_ATTACHMENT_MAX) return { ok: false, reason: "over" };
22
+ if (typeof att.size === "number" && att.size > max) return { ok: false, reason: "over" };
23
+ if (typeof att.size === "number" && att.size > ask && !anyway) return { ok: false, reason: "large", size: att.size };
17
24
  let res;
18
25
  try { res = await fetchImpl(`/ui/file/${att.id}`, { credentials: "same-origin", cache: "no-store" }); }
19
26
  catch { return { ok: false, reason: "failed" }; }
20
27
  if (!res || !res.ok) return { ok: false, reason: res && res.status === 404 ? "gone" : "failed" };
21
28
  let bytes;
22
29
  try { bytes = await res.arrayBuffer(); } catch { return { ok: false, reason: "failed" }; }
23
- if (bytes.byteLength > HTML_ATTACHMENT_MAX) return { ok: false, reason: "over" };
30
+ if (bytes.byteLength > max) return { ok: false, reason: "over" };
31
+ if (bytes.byteLength > ask && !anyway) return { ok: false, reason: "large", size: bytes.byteLength };
24
32
  // UTF-8, as a block's text is; a BOM is dropped.
25
33
  return { ok: true, code: new TextDecoder("utf-8").decode(bytes) };
26
34
  }
35
+
36
+ /** #2863: a size as the person reads it: "12.3 MB" (1 MB = 1024 × 1024 bytes, as the caps are). */
37
+ export function sizeLabel(bytes) {
38
+ const mb = bytes / (1024 * 1024);
39
+ return `${mb >= 10 ? Math.round(mb) : Math.round(mb * 10) / 10} MB`;
40
+ }
@@ -152,11 +152,12 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
152
152
  const [, dayTick] = useState(0);
153
153
  useEffect(() => (open ? onDayChange(() => dayTick(n => n + 1)) : undefined), [!!open]);
154
154
 
155
- const ui = { state: (s, reason) => setRun({ state: s === "starting" || s === "running" ? s : "idle", reason: reason || "" }) };
156
- const start = () => {
155
+ // #2863: "large" — over 8 MB, nothing runs until "Preview anyway" (or the card's, which opened it with `anyway`).
156
+ const ui = { state: (s, reason) => setRun({ state: s === "starting" || s === "running" || s === "large" ? s : "idle", reason: reason || "" }) };
157
+ const start = (anyway) => {
157
158
  if (!open || !holder.current) return;
158
159
  setView("preview");
159
- P().start(frameKey(open.key), holder.current, open.code, ui, { fill: true });
160
+ P().start(frameKey(open.key), holder.current, open.code, ui, anyway === true || open.anyway ? { fill: true, anyway: true } : { fill: true });
160
161
  };
161
162
  const stop = () => { if (open) P().stop(frameKey(open.key), "stopped", ""); };
162
163
  // A new block in the panel: what ran before is gone (openPanel stopped it); the card's click starts it here — or,
@@ -224,7 +225,8 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
224
225
  if (newer.code != null) { openPanel(newer); return; }
225
226
  const was = open && open.key, mine = reading.current = newer;
226
227
  setNewerNote(t("chat.pvLoading"));
227
- const r = await loadHtmlAttachment(newer.att);
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 });
228
230
  if (reading.current !== mine) return;
229
231
  reading.current = null;
230
232
  const now = panelStore.get().open;
@@ -270,9 +272,10 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
270
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>
271
273
  </div>
272
274
  <span class="grow"></span>
273
- ${going ? html`<button type="button" class="btn btn-sm pv-panel-reload" title=${t("chat.pvReload")} onClick=${() => { stop(); start(); }}><${Icon} name="restart" size=${14} /><span class="lbl">${t("chat.pvReload")}</span></button>
275
+ ${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>
274
276
  <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>`
275
- : 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>`}
277
+ : 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
+ : 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>`}
276
279
  <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>
277
280
  </div>
278
281
  ${newer && newer.key !== open.key ? html`<div class="pv-newer" role="status"><span>${t("chat.pvNewer", timeOf(newer.ts))}</span>
@@ -20,7 +20,9 @@
20
20
  })(typeof globalThis !== "undefined" ? globalThis : this, function (root) {
21
21
  "use strict";
22
22
 
23
- var LIMITS = { maxBytes: 1024 * 1024, minHeight: 40, maxHeight: 4000, readyMs: 3000, watchdogMs: 10000, resizePerSec: 10, minDelta: 2, growthFreeze: 5, growthWindowMs: 2000 };
23
+ // #2863: askBytes — above it the person is asked first ("Preview anyway?"); maxBytes — above it only a download (the
24
+ // frame's own ceiling, web-preview.ts PREVIEW_HTML_MAX_BYTES; html-attachment.js reads with the same two).
25
+ var LIMITS = { askBytes: 8 * 1024 * 1024, maxBytes: 50 * 1024 * 1024, minHeight: 40, maxHeight: 4000, readyMs: 3000, watchdogMs: 10000, resizePerSec: 10, minDelta: 2, growthFreeze: 5, growthWindowMs: 2000 };
24
26
  var OPT_IN_KEY = "agend_html_preview"; // localStorage: "on" — this device allows previews
25
27
  var NEVER_KEY = "agend_html_preview_never"; // sessionStorage: "1" — never preview on this device (this session)
26
28
  // #1568: the banner was acknowledged ("I understand"). localStorage: the sign-in's session handle — hidden while the
@@ -54,6 +56,8 @@
54
56
  function now() { return root.performance && typeof root.performance.now === "function" ? root.performance.now() : Date.now(); }
55
57
  function store(kind) { try { return root[kind] || null; } catch (e) { return null; } }
56
58
  function utf8Bytes(s) { try { return new root.TextEncoder().encode(s).length; } catch (e) { return String(s).length * 3; } }
59
+ /** #2863: "12 MB" / "8.4 MB" (1 MB = 1024 × 1024 bytes, as the caps are; html-attachment.js sizeLabel). */
60
+ function sizeLabel(bytes) { var mb = bytes / (1024 * 1024); return (mb >= 10 ? Math.round(mb) : Math.round(mb * 10) / 10) + " MB"; }
57
61
  function randomCh() {
58
62
  var b = new Uint8Array(16);
59
63
  root.crypto.getRandomValues(b);
@@ -229,7 +233,8 @@
229
233
 
230
234
  /**
231
235
  * Run `html` in `holder` (a node of the card) for `key`. `ui` is told what happened: ui.state(name, reason) with
232
- * "starting" | "running" | "stopped" | "unavailable". One preview at a time: any other running one stops first.
236
+ * "starting" | "running" | "stopped" | "unavailable" | "large" (#2863: over LIMITS.askBytes — nothing runs until it is
237
+ * asked again with opts.anyway, the person's "Preview anyway"). One preview at a time: any other running one stops first.
233
238
  * opts.fill (#1481, the side panel): the frame fills its holder — the same frame from mountPreview, only sized by the
234
239
  * panel instead of by the frame's height messages, which it then ignores.
235
240
  */
@@ -239,7 +244,9 @@
239
244
  function start(key, holder, html, ui, opts) {
240
245
  var a = availability();
241
246
  if (!a.ok) { ui.state("unavailable", a.reason); return false; }
242
- if (typeof html !== "string" || utf8Bytes(html) > LIMITS.maxBytes) { ui.state("unavailable", say("pvHtmlOver", "This HTML is over 1 MiB; it is not previewed.")); return false; }
247
+ var bytes = typeof html === "string" ? utf8Bytes(html) : Infinity;
248
+ if (bytes > LIMITS.maxBytes) { ui.state("unavailable", say("pvHtmlOver", "This HTML is over 50 MB; it is not previewed. Download it to open it.")); return false; }
249
+ if (bytes > LIMITS.askBytes && !(opts && opts.anyway)) { ui.state("large", say("pvLarge", "This file is large ({0}). Preview anyway?", sizeLabel(bytes))); return false; }
243
250
  stopAll("another");
244
251
  starts++;
245
252
  var doc = holder.ownerDocument;
@@ -65,6 +65,8 @@ function publish() {
65
65
  // Monotonic: when the server's countdown reaches zero on this page's clock.
66
66
  deadline: at + Math.max(0, Number(view.remaining_ms) || 0),
67
67
  withdraw: view.can_withdraw && view.state === "pending" ? () => withdraw(view.id) : null,
68
+ // #2868: this browser may confirm it itself now (the server's read says so; it checks again on the click).
69
+ applyNow: view.can_self_confirm && view.state === "pending" ? () => applyNow(view.id) : null,
68
70
  dismiss: TERMINAL.has(view.state) ? () => forget(view.id) : null,
69
71
  })),
70
72
  });
@@ -150,6 +152,22 @@ export async function withdraw(id) {
150
152
  return false;
151
153
  }
152
154
 
155
+ /**
156
+ * #2868: "Apply now" — confirm this session's own pending request from this browser (#2170's rule, asked again by the
157
+ * server). Its answer is followed like any other; a refusal (no longer allowed here) leaves it waiting and is { ok: false }.
158
+ */
159
+ export async function applyNow(id) {
160
+ const e = tracked.get(id);
161
+ if (!e) return { ok: false };
162
+ const token = ticket(e);
163
+ let res;
164
+ try { res = await api(`/api/settings/pending/${encodeURIComponent(id)}/confirm`, { method: "POST", body: "{}" }); } catch { return { ok: false }; }
165
+ if (isPendingResponse(res) && res.body.pending_change.id === id) { update(res.body.pending_change, token); schedule(); return { ok: true }; }
166
+ // Refused (403: not allowed here any more): read it again, so "Apply now" goes until the server offers it again.
167
+ if (e.view.can_self_confirm) update({ ...e.view, can_self_confirm: false }, token);
168
+ return { ok: false, status: res.status };
169
+ }
170
+
153
171
  /** This session's requests still open on the server (after a reload): followed like new ones. */
154
172
  export async function attach() {
155
173
  let res;
@@ -89,7 +89,13 @@ register("app", {
89
89
  pendingWeb: "Confirmed by your account (this browser is web-verified). General is told.",
90
90
  pending_applied: "Confirmed and applied", pending_rejected: "Not applied", pending_expired: "Expired — nothing was changed",
91
91
  pending_stale: "Not applied — settings changed meanwhile", pending_failed: "Confirmed, but applying it failed",
92
- pendingChat: "A fleet admin confirms this in chat (General topic).", pendingHost: "Confirm it on the host: {0}", pendingLeft: "{0} left", withdraw: "Withdraw",
92
+ pendingLeft: "{0} left", withdraw: "Withdraw",
93
+ // #2868: where to confirm it, said plainly; the host's command folded away unless it is the only way.
94
+ pendingPosting: "Sending the confirm card to General…", pendingChatWhere: "To apply it, press Confirm on the card in General on {0}.",
95
+ pendingHostOnly: "No General can take the confirm card. Confirm it on the host: {0}", pendingAdvanced: "Advanced: confirm on the host",
96
+ pendingSelf: "This browser is verified, so you can also apply it here.", pendingApplyNow: "Apply now",
97
+ pendingApplyRefused: "This browser can't apply it here now — confirm it in General.",
98
+ pendingAuthorize: "Authorize this browser to apply changes like this here next time, without General.",
93
99
  opWriting: "Applying settings… {0}/{1}", opWaiting: "Settings: waiting for confirmation in chat", opApplying: "Applying settings…",
94
100
  opDone: "Settings applied", opRestart: "Settings saved — AgEnD needs a restart to use them", opPartial: "Some settings were not applied",
95
101
  opFailed: "Settings were not applied", openSettings: "Open Settings",
@@ -131,7 +137,12 @@ register("app", {
131
137
  pendingWeb: "由你的帳號確認(這個瀏覽器已通過網頁驗證),也會通知 General。",
132
138
  pending_applied: "已確認並套用", pending_rejected: "未套用", pending_expired: "已逾時——沒有任何變更",
133
139
  pending_stale: "未套用——設定在這期間已變動", pending_failed: "已確認,但套用失敗",
134
- pendingChat: "由 fleet 管理員在聊天(General topic)中確認。", pendingHost: "請在主機上確認:{0}", pendingLeft: "剩 {0}", withdraw: "撤回",
140
+ pendingLeft: "剩 {0}", withdraw: "撤回",
141
+ pendingPosting: "正在把確認卡送到 General…", pendingChatWhere: "要套用,請到 {0} 的 General,在確認卡上按「確認」。",
142
+ pendingHostOnly: "沒有可以接收確認卡的 General。請在主機上確認:{0}", pendingAdvanced: "進階:在主機上確認",
143
+ pendingSelf: "這個瀏覽器已通過驗證,也可以直接在這裡套用。", pendingApplyNow: "直接套用",
144
+ pendingApplyRefused: "這個瀏覽器現在無法直接套用,請到 General 確認。",
145
+ pendingAuthorize: "授權這個瀏覽器,之後這類變更可以直接套用,不必到 General。",
135
146
  opWriting: "正在套用設定… {0}/{1}", opWaiting: "設定:等待在聊天中確認", opApplying: "正在套用設定…",
136
147
  opDone: "設定已套用", opRestart: "設定已儲存——需要重新啟動 AgEnD 才會生效", opPartial: "部分設定沒有套用",
137
148
  opFailed: "設定沒有套用", openSettings: "開啟設定",