@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.
- package/dist/backend/codex-metadata.d.ts +8 -0
- package/dist/backend/codex-metadata.js +74 -17
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/fleet-manager.js +7 -13
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +3 -2
- package/dist/topic-commands.d.ts +10 -0
- package/dist/topic-commands.js +27 -15
- package/dist/topic-commands.js.map +1 -1
- package/dist/ui/panel-chat.js +18 -11
- package/dist/ui/shared/app.css +2 -11
- package/dist/ui/shared/card-strings.js +3 -3
- package/dist/ui/shared/identity-card.js +21 -21
- package/dist/ui/shared/ui-icons.js +0 -1
- package/dist/ui/shared/ui-menu.js +12 -5
- package/dist/web-api.d.ts +0 -3
- package/dist/web-api.js +62 -13
- package/dist/web-api.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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,
|
|
184
|
+
export function IdentityCard({ name, card, compact = false, sizeControl = !compact, rowHidden = false }) {
|
|
180
185
|
useStore(cardSizeStore);
|
|
181
|
-
const
|
|
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[
|
|
188
|
-
? html`<img class=${cls} src=${card[slot].src} alt=${alt} title=${whose(card[slot])} decoding="async" onError=${() =>
|
|
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
|
|
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}
|
|
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
|
-
/**
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1209
|
-
|
|
1210
|
-
res.write(
|
|
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
|
-
}
|
|
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
|
-
|
|
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"
|