@songsid/agend 2.3.0-beta.13 → 2.3.0-beta.14

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.
@@ -11,7 +11,6 @@ import { html, useEffect, useState } from "./app-html.js";
11
11
  import { t } from "./app-i18n.js";
12
12
  import { createStore, useStore } from "./app-store.js";
13
13
  import { onPrefsChanged, prefInForce, savePref } from "./app-prefs.js";
14
- import { Icon } from "./ui-icons.js";
15
14
  import "./card-strings.js";
16
15
 
17
16
  export const CARD_SIZES = ["s", "m", "l"];
@@ -161,43 +160,45 @@ function SizeControl({ name, size }) {
161
160
  </div>`;
162
161
  }
163
162
 
164
- /** A small round control on the card (the chat's Hide card and Hide picture and bio). */
165
- function CardControl({ cls, icon, label, onClick, expanded }) {
166
- return html`<button type="button" class=${`id-ctl ${cls}`} aria-label=${label} title=${label}
167
- aria-expanded=${expanded === undefined ? undefined : expanded ? "true" : "false"} onClick=${onClick}><${Icon} name=${icon} size=${16} /></button>`;
168
- }
163
+ /**
164
+ * #2217 hotfix (Prism): pictures that failed to load on this page, by src — shared, so the chat's card menu judges a card
165
+ * as drawn (a broken banner is not drawn, and then the card cannot fold), not by its metadata alone.
166
+ */
167
+ export const brokenPictures = createStore({});
168
+ const markBroken = (src) => { if (src && !brokenPictures.get()[src]) brokenPictures.set((s) => ({ ...s, [src]: true })); };
169
+ const drawn = (card, slot) => !!(card && card[slot] && !brokenPictures.get()[card[slot].src]);
170
+ /**
171
+ * #2217: whether a card can fold its row away and keep the banner — a banner drawn, and something on its row (a picture
172
+ * drawn, a tagline or a bio). The chat's card menu enables Show picture and bio only then.
173
+ */
174
+ export const cardFoldable = (card) => !!(card && drawn(card, "banner") && (drawn(card, "portrait") || card.tagline || card.bio));
169
175
 
170
176
  /**
171
177
  * The card. `compact`: the chat header's own small size (no size control; the banner still follows the size choice,
172
178
  * #2038). Nothing when the card is empty. The
173
179
  * tagline and bio say whose they are (title), since a person's field wins over the agent's.
174
- * #2217: the chat's controls, on the card itself (the user, 2026-10-19): `onHide` — Hide card, on the banner's top-right corner
175
- * (on the row when there is no banner); `onToggleRow` — hide the picture, tagline and bio and keep the banner, from the
176
- * row's bottom-right corner, and back from the banner's bottom-right while they are hidden (`rowHidden`). Only a card
177
- * with a banner and something on its row can fold its row (else it would be the card hidden).
180
+ * #2217: `rowHidden` — the chat's picture and bio folded away, the banner kept. The user (2026-10-20): no control on the
181
+ * card at all — the chat header's two buttons show / hide the card and fold / unfold its row. Only a card with a banner
182
+ * and something on its row folds (cardFoldable; else it would be the card hidden).
178
183
  */
179
- export function IdentityCard({ name, card, compact = false, sizeControl = !compact, onHide = null, rowHidden = false, onToggleRow = null }) {
184
+ export function IdentityCard({ name, card, compact = false, sizeControl = !compact, rowHidden = false }) {
180
185
  useStore(cardSizeStore);
181
- const [broken, setBroken] = useState({});
186
+ const broken = useStore(brokenPictures);
182
187
  if (!card) return null;
183
188
  const size = compact ? "c" : cardSizeFor(name);
184
189
  // #2038: the chat header's card is its own small size, but its banner follows the person's size choice too.
185
190
  const bannerSize = compact ? ` bs-${cardSizeFor(name)}` : "";
186
191
  const whose = (f) => t(f.by === "human" ? "card.byHuman" : "card.byAgent");
187
- const pic = (slot, cls, alt) => card[slot] && broken[slot] !== card[slot].src
188
- ? html`<img class=${cls} src=${card[slot].src} alt=${alt} title=${whose(card[slot])} decoding="async" onError=${() => setBroken({ ...broken, [slot]: card[slot].src })} />` : null;
192
+ const pic = (slot, cls, alt) => card[slot] && !broken[card[slot].src]
193
+ ? html`<img class=${cls} src=${card[slot].src} alt=${alt} title=${whose(card[slot])} decoding="async" onError=${() => markBroken(card[slot].src)} />` : null;
189
194
  // #2086: the banner at its focus; on a screen wider than BANNER_MAX_ASPECT it is centred over a blurred copy of itself.
190
195
  const focus = card.banner_focus ? card.banner_focus.value : 50;
191
196
  const atFocus = (el) => { if (el && el.style) el.style.setProperty("--id-focus", `${focus}%`); };
192
197
  const banner = pic("banner", "id-banner", "");
193
198
  const portrait = pic("portrait", "id-portrait", t("card.portraitAlt", name));
194
- const foldable = !!(onToggleRow && banner && (portrait || card.tagline || card.bio));
195
- const rowShown = !(foldable && rowHidden);
196
- const hide = onHide ? html`<${CardControl} cls="id-hide" icon="eyeOff" label=${t("card.hide")} onClick=${onHide} />` : null;
197
- const fold = foldable ? html`<${CardControl} cls=${rowShown ? "id-fold" : "id-fold id-unfold"} icon=${rowShown ? "up" : "user"}
198
- label=${t(rowShown ? "card.hideRow" : "card.showRow")} expanded=${rowShown} onClick=${onToggleRow} />` : null;
199
+ const rowShown = !(rowHidden && banner && cardFoldable(card));
199
200
  return html`<section class=${`id-card card-${size}${bannerSize}${rowShown ? "" : " row-hidden"}`} aria-label=${t("card.label", name)}>
200
- ${banner ? html`<div class="id-banner-box" ref=${atFocus}><img class="id-banner-fill" src=${card.banner.src} alt="" aria-hidden="true" decoding="async" />${banner}${hide}${rowShown ? null : fold}</div>` : null}
201
+ ${banner ? html`<div class="id-banner-box" ref=${atFocus}><img class="id-banner-fill" src=${card.banner.src} alt="" aria-hidden="true" decoding="async" />${banner}</div>` : null}
201
202
  ${rowShown ? html`<div class="id-row">
202
203
  ${portrait}
203
204
  <div class="id-text">
@@ -205,7 +206,6 @@ export function IdentityCard({ name, card, compact = false, sizeControl = !compa
205
206
  ${card.bio ? html`<p class="id-bio" title=${whose(card.bio)}>${card.bio.text}</p>` : null}
206
207
  </div>
207
208
  ${sizeControl ? html`<${SizeControl} name=${name} size=${size} />` : null}
208
- ${banner ? null : hide}${fold}
209
209
  </div>` : null}
210
210
  </section>`;
211
211
  }
@@ -40,7 +40,6 @@ const P = {
40
40
  sort: () => html`<path d="M7 4v16M3 16l4 4 4-4M17 20V4M13 8l4-4 4 4"/>`, // #2179: the sidebar's sort
41
41
  star: () => html`<path d="M12 2.5l2.94 5.96 6.56.95-4.75 4.63 1.12 6.54L12 17.5l-5.87 3.08 1.12-6.54L2.5 9.41l6.56-.95L12 2.5z"/>`,
42
42
  eye: () => html`<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>`,
43
- eyeOff: () => html`<path d="M3 3l18 18M10.6 5.1A10.9 10.9 0 0 1 12 5c7 0 11 7 11 7a18.6 18.6 0 0 1-3.2 4.2M6.6 6.6C3.5 8.6 1 12 1 12s4 7 11 7a10.6 10.6 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2"/>`,
44
43
  idcard: () => html`<rect x="2" y="5" width="20" height="14" rx="2"/><circle cx="8" cy="11" r="2"/><path d="M5 16a3 3 0 0 1 6 0M14 10h5M14 14h3"/>`,
45
44
  check: () => html`<path d="M20 6L9 17l-5-5"/>`,
46
45
  inbox: () => html`<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>`,
@@ -4,21 +4,25 @@ import { html, useEffect, useRef, useState } from "./app-html.js";
4
4
  import { Icon } from "./ui-icons.js";
5
5
  import { t } from "./app-i18n.js";
6
6
 
7
- /** items: [{ key, label, icon?, onSelect, danger?, disabled? }] (falsy entries are skipped). */
8
- export function Menu({ items, label, icon = "more", cls = "" }) {
7
+ /**
8
+ * items: [{ key, label, icon?, onSelect, danger?, disabled? }] (falsy entries are skipped). `checked` makes a radio item;
9
+ * `checkbox: true` with `checked` a checkbox item (#2217 hotfix: the chat's card menu), which toggles and keeps the menu
10
+ * open. `active`: the button shows a pressed look (what it controls is on), without changing its role.
11
+ */
12
+ export function Menu({ items, label, icon = "more", cls = "", active = false }) {
9
13
  const [open, setOpen] = useState(false);
10
14
  const btn = useRef(null), list = useRef(null);
11
15
  const shown = (items || []).filter(Boolean);
12
16
  useEffect(() => {
13
17
  if (!open) return undefined;
14
- const first = list.current && list.current.querySelector("[role=menuitem]:not([disabled])");
18
+ const first = list.current && list.current.querySelector("[role=menuitem]:not([disabled]), [role=menuitemradio]:not([disabled]), [role=menuitemcheckbox]:not([disabled])");
15
19
  if (first) first.focus();
16
20
  const outside = (e) => { if (list.current && !list.current.contains(e.target) && btn.current && !btn.current.contains(e.target)) setOpen(false); };
17
21
  document.addEventListener("pointerdown", outside, true);
18
22
  return () => document.removeEventListener("pointerdown", outside, true);
19
23
  }, [open]);
20
24
  function onKey(e) {
21
- const all = [...list.current.querySelectorAll("[role=menuitem]:not([disabled]), [role=menuitemradio]:not([disabled])")];
25
+ const all = [...list.current.querySelectorAll("[role=menuitem]:not([disabled]), [role=menuitemradio]:not([disabled]), [role=menuitemcheckbox]:not([disabled])")];
22
26
  const i = all.indexOf(document.activeElement);
23
27
  if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setOpen(false); btn.current && btn.current.focus(); }
24
28
  else if (e.key === "ArrowDown") { e.preventDefault(); (all[(i + 1) % all.length] || all[0])?.focus(); }
@@ -29,11 +33,14 @@ export function Menu({ items, label, icon = "more", cls = "" }) {
29
33
  }
30
34
  function choose(item) { setOpen(false); btn.current && btn.current.focus(); item.onSelect(); }
31
35
  return html`<div class=${`menu ${cls}`}>
32
- <button ref=${btn} type="button" class="icon-btn" aria-haspopup="menu" aria-expanded=${open ? "true" : "false"}
36
+ <button ref=${btn} type="button" class=${`icon-btn${active ? " active" : ""}`} aria-haspopup="menu" aria-expanded=${open ? "true" : "false"}
33
37
  aria-label=${label || t("app.more")} title=${label || t("app.more")} onClick=${() => setOpen(!open)}><${Icon} name=${icon} /></button>
34
38
  ${open ? html`<div data-trust="menu" ref=${list} class="menu-list" role="menu" aria-label=${label || t("app.more")} onKeyDown=${onKey}>
35
39
  ${shown.map(item => item.heading
36
40
  ? html`<div key=${item.key} class="menu-heading" role="presentation">${item.label}</div>`
41
+ : item.checkbox
42
+ ? html`<button key=${item.key} type="button" role="menuitemcheckbox" aria-checked=${item.checked ? "true" : "false"} class="menu-item menu-radio"
43
+ disabled=${!!item.disabled} onClick=${() => item.onSelect()}><span class="menu-tick" aria-hidden="true">${item.checked ? html`<${Icon} name="check" size=${16} />` : null}</span><span>${item.label}</span></button>`
37
44
  : item.checked !== undefined
38
45
  // #2196: one of a group of choices (Page colours): a radio item, the chosen one ticked.
39
46
  ? html`<button key=${item.key} type="button" role="menuitemradio" aria-checked=${item.checked ? "true" : "false"} class="menu-item menu-radio"
package/dist/web-api.d.ts CHANGED
@@ -371,9 +371,6 @@ export interface WebPerson {
371
371
  /** Their account's name at sign-in, made safe (bound only). */
372
372
  readonly platformName?: string;
373
373
  }
374
- /**
375
- * Handle a Web UI request. Returns true if handled, false to pass through.
376
- */
377
374
  export declare function handleWebRequest(req: IncomingMessage, res: ServerResponse, url: URL, ctx: WebApiContext): boolean;
378
375
  /**
379
376
  * #1723: a public-link page's preview: where it may frame from (`frameSrc` — the quick-tunnel wildcard while its own
package/dist/web-api.js CHANGED
@@ -969,9 +969,54 @@ function profileBody(p) {
969
969
  }
970
970
  /** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
971
971
  function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
972
- /**
973
- * Handle a Web UI request. Returns true if handled, false to pass through.
974
- */
972
+ const statusTicks = new WeakMap();
973
+ function subscribeStatusTick(ctx, stream) {
974
+ let tick = statusTicks.get(ctx);
975
+ if (!tick)
976
+ statusTicks.set(ctx, tick = { streams: new Set(), timer: null });
977
+ const t = tick;
978
+ t.streams.add(stream);
979
+ t.timer ??= setInterval(() => statusTick(ctx, t), ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
980
+ return () => {
981
+ t.streams.delete(stream);
982
+ if (!t.streams.size && t.timer) {
983
+ clearInterval(t.timer);
984
+ t.timer = null;
985
+ }
986
+ };
987
+ }
988
+ function statusTick(ctx, t) {
989
+ const live = [...t.streams].filter((s) => s.alive());
990
+ if (!live.length)
991
+ return;
992
+ let status, admin = null;
993
+ const guests = new Map();
994
+ try {
995
+ status = measureSyncWork("web.sseStatus", () => ctx.getUiStatus());
996
+ }
997
+ catch {
998
+ return;
999
+ } // nothing to send this tick; the next one tries again
1000
+ const frameFor = (s) => {
1001
+ if (!s.scope)
1002
+ return admin ??= `event: status\ndata: ${JSON.stringify(status)}\n\n`;
1003
+ const scope = s.scope(), key = [...scope].sort().join("\u0000");
1004
+ let frame = guests.get(key);
1005
+ if (frame === undefined)
1006
+ guests.set(key, frame = `event: status\ndata: ${JSON.stringify(guestStatus(status, scope))}\n\n`);
1007
+ return frame;
1008
+ };
1009
+ for (const s of live) {
1010
+ let frame;
1011
+ try {
1012
+ frame = frameFor(s);
1013
+ }
1014
+ catch {
1015
+ continue;
1016
+ }
1017
+ s.write(frame);
1018
+ }
1019
+ }
975
1020
  export function handleWebRequest(req, res, url, ctx) {
976
1021
  const path = url.pathname;
977
1022
  const method = req.method ?? "GET";
@@ -1188,13 +1233,17 @@ export function handleWebRequest(req, res, url, ctx) {
1188
1233
  if (!guest && ctx.needsYouItems)
1189
1234
  res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
1190
1235
  ctx.sseClients.add(res);
1191
- const interval = setInterval(() => {
1236
+ // #1956: the heartbeat's status comes from the one build every open stream shares each tick (statusTick), with
1237
+ // this stream's own projection; this stream's checks stay its own.
1238
+ const unsubscribe = subscribeStatusTick(ctx, {
1192
1239
  // A stream authorized once must not outlive the authorization. Re-checked
1193
1240
  // without counting as activity, or an open tab would keep an idle session
1194
1241
  // alive forever; a revoked, expired or rotated-away session ends here.
1195
1242
  // #2049 review: a guest's stream also needs the exact session that opened it — another cookie on the same request
1196
1243
  // still authenticating does not keep a revoked device's stream (with its scope) alive.
1197
- if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) || (guest && !guest.current())) {
1244
+ alive: () => {
1245
+ if (isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) && (!guest || guest.current()))
1246
+ return true;
1198
1247
  // Close the connection too, not just the response: an ended response leaves the socket idle in
1199
1248
  // keep-alive, still held open by whoever's session just ended.
1200
1249
  const socket = req.socket;
@@ -1203,23 +1252,23 @@ export function handleWebRequest(req, res, url, ctx) {
1203
1252
  }
1204
1253
  catch { /* already closed */ }
1205
1254
  cleanup();
1206
- return;
1207
- }
1208
- try {
1209
- // Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
1210
- res.write(measureSyncWork("web.sseStatus", statusFrame) + sseClock());
1255
+ return false;
1256
+ },
1257
+ scope: guest ? () => guest.scope() : null,
1258
+ write: (frame) => { try {
1259
+ res.write(frame + sseClock());
1211
1260
  }
1212
1261
  catch {
1213
1262
  cleanup();
1214
- }
1215
- }, ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
1263
+ } },
1264
+ });
1216
1265
  let cleanedUp = false;
1217
1266
  const cleanup = () => {
1218
1267
  if (cleanedUp)
1219
1268
  return;
1220
1269
  cleanedUp = true;
1221
1270
  ctx.sseClients.delete(res);
1222
- clearInterval(interval);
1271
+ unsubscribe();
1223
1272
  };
1224
1273
  // The *response's* `close` is the one that means the stream is over: it fires when the connection
1225
1274
  // goes away or the response is finished. The request's `close` means "the request has been read"