@songsid/agend 2.2.0-alpha.7 → 2.2.0-alpha.9
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/ansi-faint.d.ts +25 -0
- package/dist/ansi-faint.js +90 -0
- package/dist/ansi-faint.js.map +1 -0
- package/dist/auth-api.js +1 -1
- package/dist/auth-api.js.map +1 -1
- package/dist/fleet-manager.d.ts +13 -0
- package/dist/fleet-manager.js +118 -28
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-health/SKILL.md +11 -2
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +2 -1
- package/dist/locale.js +4 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.js +8 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/tz-utils.d.ts +6 -0
- package/dist/tz-utils.js +23 -0
- package/dist/tz-utils.js.map +1 -1
- package/dist/ui/chat-render.js +29 -1
- package/dist/ui/chat-strings.js +2 -0
- package/dist/ui/chat-thread.js +128 -3
- package/dist/ui/panel-chat.js +4 -13
- package/dist/ui/panel-settings.js +54 -5
- package/dist/ui/settings-strings.js +2 -0
- package/dist/ui/shared/app-i18n.js +4 -4
- package/dist/ui/shared/app-route.js +1 -1
- package/dist/ui/shared/app-shell.js +30 -15
- package/dist/ui/shared/app-stream.js +186 -43
- package/dist/ui/shared/app-swipe.js +89 -0
- package/dist/ui/shared/app.css +32 -1
- package/dist/ui/shared/app.js +15 -1
- package/dist/ui/shared/panel-view.js +21 -8
- package/dist/ui/shared/usage-dialog.js +16 -1
- package/dist/ui/shared/view-strings.js +10 -0
- package/dist/usage/usage-api.d.ts +8 -1
- package/dist/usage/usage-api.js +100 -31
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/web-api.d.ts +1 -1
- package/dist/web-api.js +64 -3
- package/dist/web-api.js.map +1 -1
- package/dist/web-host-guard.d.ts +2 -0
- package/dist/web-host-guard.js +7 -1
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +2 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-upload.d.ts +17 -0
- package/dist/web-upload.js +38 -7
- package/dist/web-upload.js.map +1 -1
- package/package.json +1 -1
package/dist/ui/chat-thread.js
CHANGED
|
@@ -11,6 +11,16 @@ import "./preview.js";
|
|
|
11
11
|
import { loadHtmlAttachment } from "./html-attachment.js";
|
|
12
12
|
|
|
13
13
|
const R = () => globalThis.AgendChatRender;
|
|
14
|
+
/** #1589: the public link shows attachments as downloads only (its page says so: <body data-public-link="1">). */
|
|
15
|
+
const PUBLIC_LINK = () => globalThis.document?.body?.dataset?.publicLink === "1";
|
|
16
|
+
/** #1589: how much of a text attachment is shown before "Show all". */
|
|
17
|
+
export const TEXT_PREVIEW_LINES = 200;
|
|
18
|
+
/** #1589: the most elements one formatted text card may build (table cells, highlight spans, Markdown elements), Show all
|
|
19
|
+
* included. Lines and the 1 MiB read do not bound it (one 200 KB line can be 100,000 cells): past it the card is plain
|
|
20
|
+
* text, one text node. */
|
|
21
|
+
export const TEXT_PREVIEW_NODES = 4000;
|
|
22
|
+
/** A csv/tsv wider than this is shown as text, as Markdown tables are capped at 20 columns. */
|
|
23
|
+
export const TEXT_PREVIEW_COLUMNS = 50;
|
|
14
24
|
const P = () => globalThis.AgendPreview;
|
|
15
25
|
const COPY = '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
16
26
|
|
|
@@ -46,7 +56,7 @@ export function createThread(list, scroller, opts) {
|
|
|
46
56
|
const ticks = u && x.delivery ? R().deliveryHtml(x.delivery, TICKS()) : "";
|
|
47
57
|
const tools = u ? "" : `<div class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
|
|
48
58
|
const buttons = x.role === "agent" ? replyButtonsHtml(x.buttons) : "";
|
|
49
|
-
return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span><span class="time">${time}</span>${ticks}</div><div class="body"><div class="md">${R().renderMarkdown(x.text, x.role === "agent" ? { htmlCards: true } : undefined)}</div>${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable") })}${buttons}</div>${tools}</div>`;
|
|
59
|
+
return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span><span class="time">${time}</span>${ticks}</div><div class="body"><div class="md">${R().renderMarkdown(x.text, x.role === "agent" ? { htmlCards: true } : undefined)}</div>${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
|
|
50
60
|
}
|
|
51
61
|
/**
|
|
52
62
|
* #1266: an agent reply's buttons. Labels are text (escaped, never Markdown); the click names the set and the index,
|
|
@@ -69,6 +79,7 @@ export function createThread(list, scroller, opts) {
|
|
|
69
79
|
const node = tpl.content.firstElementChild;
|
|
70
80
|
decorateCode(node);
|
|
71
81
|
if (x) decorateHtmlCards(node, x, stoppedKeys || []);
|
|
82
|
+
if (x) decorateTextCards(node, x);
|
|
72
83
|
return node;
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -117,6 +128,114 @@ export function createThread(list, scroller, opts) {
|
|
|
117
128
|
buildCard(ph, x, `${msgKey(x)}:a${att.id}`, { att: { id: att.id, name: att.name, size: att.size } }, stoppedKeys);
|
|
118
129
|
}
|
|
119
130
|
}
|
|
131
|
+
// #1589: a text attachment (md, csv, json, code, plain) gets a card: Show reads the file once (the same capped,
|
|
132
|
+
// same-origin read as an HTML attachment's) and shows it — Markdown through renderMarkdown (it escapes first), code
|
|
133
|
+
// through the highlighter (escaped), csv as a table and anything else as text, all built with textContent. Nothing in
|
|
134
|
+
// the file is ever run: no frame, no script, no HTML of its own.
|
|
135
|
+
function decorateTextCards(node, x) {
|
|
136
|
+
const atts = Array.isArray(x.attachments) ? x.attachments : [];
|
|
137
|
+
for (const ph of node.querySelectorAll(".text-card[data-att]")) {
|
|
138
|
+
const att = atts.find(a => a && a.id === ph.dataset.att);
|
|
139
|
+
const type = att ? R().attachmentPreviewType(att) : null;
|
|
140
|
+
if (!att || !type || type !== ph.dataset.type) continue;
|
|
141
|
+
buildTextCard(ph, att, type);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function buildTextCard(ph, att, type) {
|
|
145
|
+
const doc = list.ownerDocument;
|
|
146
|
+
const el = (tag, cls, text) => { const e = doc.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
|
|
147
|
+
ph.textContent = "";
|
|
148
|
+
const head = el("div", "tc-head");
|
|
149
|
+
const label = el("span", "tc-label", att.name);
|
|
150
|
+
const show = el("button", "btn btn-sm tc-show", tr("chat.tcShow")); show.type = "button";
|
|
151
|
+
const dl = el("a", "btn btn-sm tc-dl", tr("chat.tcDownload")); dl.href = `/ui/file/${att.id}`; dl.setAttribute("download", att.name);
|
|
152
|
+
head.append(label, show, dl);
|
|
153
|
+
const note = el("div", "pv-note");
|
|
154
|
+
const body = el("div", "tc-body"); body.hidden = true;
|
|
155
|
+
ph.append(head, note, body);
|
|
156
|
+
let text = null, reading = false, all = false;
|
|
157
|
+
const draw = () => {
|
|
158
|
+
body.textContent = "";
|
|
159
|
+
const lines = text.split(/\r?\n/);
|
|
160
|
+
const cut = !all && lines.length > TEXT_PREVIEW_LINES;
|
|
161
|
+
const shown = cut ? lines.slice(0, TEXT_PREVIEW_LINES) : lines;
|
|
162
|
+
const src = shown.join("\n");
|
|
163
|
+
// Formatting is built only within TEXT_PREVIEW_NODES: the markup is measured as a string first, never as DOM.
|
|
164
|
+
const plain = () => {
|
|
165
|
+
const pre = el("pre", "tc-pre"), code = el("code", null, src);
|
|
166
|
+
pre.append(code); body.append(el("div", "pv-note", tr("chat.tcPlain")), pre);
|
|
167
|
+
};
|
|
168
|
+
if (type === "md") {
|
|
169
|
+
const html = R().renderMarkdown(src);
|
|
170
|
+
if (tagCount(html) > TEXT_PREVIEW_NODES) plain();
|
|
171
|
+
else { const md = el("div", "md"); md.innerHTML = html; body.append(md); }
|
|
172
|
+
} else if (type === "csv" || type === "tsv") {
|
|
173
|
+
const table = csvTable(src, type === "tsv" ? "\t" : ",", el);
|
|
174
|
+
if (table) body.append(table); else plain();
|
|
175
|
+
} else {
|
|
176
|
+
const lang = type === "json" ? "json" : type.startsWith("code:") ? type.slice(5) : "";
|
|
177
|
+
const lit = lang ? R().highlight(src, lang) : null;
|
|
178
|
+
if (lit != null && tagCount(lit) > TEXT_PREVIEW_NODES) plain();
|
|
179
|
+
else {
|
|
180
|
+
const pre = el("pre", "tc-pre"), code = el("code");
|
|
181
|
+
if (lit != null) code.innerHTML = lit; else code.textContent = src;
|
|
182
|
+
pre.append(code); body.append(pre);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (cut) {
|
|
186
|
+
const more = el("button", "btn btn-sm tc-more", trf("chat.tcShowAll", lines.length)); more.type = "button";
|
|
187
|
+
more.onclick = () => { all = true; draw(); };
|
|
188
|
+
body.append(more);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
show.onclick = async () => {
|
|
192
|
+
if (reading) return;
|
|
193
|
+
if (text !== null) { body.hidden = !body.hidden; show.textContent = tr(body.hidden ? "chat.tcShow" : "chat.tcHide"); return; }
|
|
194
|
+
reading = true; note.textContent = tr("chat.pvLoading");
|
|
195
|
+
const r = await loadHtmlAttachment({ id: att.id, name: att.name, size: att.size });
|
|
196
|
+
reading = false;
|
|
197
|
+
if (!ph.isConnected) return; // the card left the page while it was read: nothing to draw
|
|
198
|
+
if (!r.ok) { note.textContent = r.reason === "over" ? tr("chat.tcOver") : attReason(r.reason); return; }
|
|
199
|
+
note.textContent = "";
|
|
200
|
+
text = r.code;
|
|
201
|
+
if (type === "json") { try { text = JSON.stringify(JSON.parse(text), null, 2); } catch { /* shown as written */ } }
|
|
202
|
+
draw();
|
|
203
|
+
body.hidden = false; show.textContent = tr("chat.tcHide");
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** How many elements an HTML string would build: its opening tags (escaped text has none). */
|
|
207
|
+
function tagCount(html) {
|
|
208
|
+
let n = 0;
|
|
209
|
+
for (let i = html.indexOf("<"); i !== -1; i = html.indexOf("<", i + 1)) { const c = html.charCodeAt(i + 1); if ((c | 32) >= 97 && (c | 32) <= 122) n++; }
|
|
210
|
+
return n;
|
|
211
|
+
}
|
|
212
|
+
/** A csv/tsv as a table: quoted fields ("a, b", "say ""hi""") kept whole; every cell is textContent. */
|
|
213
|
+
function csvTable(src, sep, el) {
|
|
214
|
+
const rows = [];
|
|
215
|
+
let row = [], cell = "", quoted = false;
|
|
216
|
+
for (let i = 0; i < src.length; i++) {
|
|
217
|
+
const c = src[i];
|
|
218
|
+
if (quoted) {
|
|
219
|
+
if (c === '"') { if (src[i + 1] === '"') { cell += '"'; i++; } else quoted = false; }
|
|
220
|
+
else cell += c;
|
|
221
|
+
} else if (c === '"' && cell === "") quoted = true;
|
|
222
|
+
else if (c === sep) { row.push(cell); cell = ""; }
|
|
223
|
+
else if (c === "\n") { row.push(cell.replace(/\r$/, "")); rows.push(row); row = []; cell = ""; }
|
|
224
|
+
else cell += c;
|
|
225
|
+
}
|
|
226
|
+
if (cell !== "" || row.length) { row.push(cell.replace(/\r$/, "")); rows.push(row); }
|
|
227
|
+
// Within the card's budget, or not a table at all (the caller shows it as text).
|
|
228
|
+
let cells = 0;
|
|
229
|
+
for (const r of rows) { cells += r.length; if (r.length > TEXT_PREVIEW_COLUMNS || cells + rows.length > TEXT_PREVIEW_NODES) return null; }
|
|
230
|
+
const table = el("table", "tc-table");
|
|
231
|
+
rows.forEach((r, i) => {
|
|
232
|
+
const tr_ = el("tr");
|
|
233
|
+
for (const v of r) tr_.append(el(i === 0 ? "th" : "td", null, v));
|
|
234
|
+
table.append(tr_);
|
|
235
|
+
});
|
|
236
|
+
const wrap = el("div", "tc-table-wrap"); wrap.append(table);
|
|
237
|
+
return wrap;
|
|
238
|
+
}
|
|
120
239
|
/** Why an attachment's HTML could not be read, for the card's note. */
|
|
121
240
|
const attReason = (reason) => tr(reason === "over" ? "chat.pvAttOver" : reason === "gone" ? "chat.pvAttGone" : "chat.pvAttFailed");
|
|
122
241
|
/** One card: `source` is { n, code } for a fence or { att } for an attachment (its HTML read on the first click). */
|
|
@@ -201,11 +320,17 @@ export function createThread(list, scroller, opts) {
|
|
|
201
320
|
for (const node of list.querySelectorAll(".html-card[data-card], .html-card[data-att]")) { const f = cardRefresh.get(node); if (f) f(); }
|
|
202
321
|
}
|
|
203
322
|
|
|
323
|
+
// Near the bottom (48 px slack): where the READER counts as at the newest message — for the pill and for pinning on scroll.
|
|
204
324
|
const nearBottom = () => R().isNearBottom(scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight);
|
|
325
|
+
// #1584: at the exact bottom — what a pinned view is kept at. The slack is the reader's, not the layout's: a dock that
|
|
326
|
+
// grew by less than 48 px (the working line, a command card, the composer shrinking after a send) used to leave the
|
|
327
|
+
// newest bubble clipped behind it, and the next growth past 48 px put the pill over it.
|
|
328
|
+
const atBottom = () => R().isNearBottom(scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight, 1);
|
|
205
329
|
function reportJump() { opts.onJump({ show: !nearBottom(), unseen }); }
|
|
206
330
|
function toBottom() { scroller.scrollTop = scroller.scrollHeight; pinned = true; lastTop = scroller.scrollTop; }
|
|
207
|
-
// Sizes change without a scroll event (layout after the render):
|
|
208
|
-
|
|
331
|
+
// Sizes change without a scroll event (layout after the render, the dock under the thread): while pinned, the view
|
|
332
|
+
// stays flush with the bottom; a reader who scrolled up is never moved.
|
|
333
|
+
const resized = () => { if (pinned && !atBottom()) toBottom(); reportJump(); };
|
|
209
334
|
const ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
|
|
210
335
|
if (ro) { ro.observe(list); ro.observe(scroller); }
|
|
211
336
|
|
package/dist/ui/panel-chat.js
CHANGED
|
@@ -10,7 +10,7 @@ import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-
|
|
|
10
10
|
import { t } from "/assets/app-i18n.js";
|
|
11
11
|
import { appStore, useStore } from "/assets/app-store.js";
|
|
12
12
|
import { useLease } from "/assets/app-ctx.js";
|
|
13
|
-
import { PanelHeader, onPanelKey, setTitle,
|
|
13
|
+
import { PanelHeader, onPanelKey, setTitle, statusClass, statusLabel, openDrawer, requestNewInstance } from "/assets/app-shell.js";
|
|
14
14
|
import { detailsPath } from "/assets/app-route.js";
|
|
15
15
|
import { InstanceSwitch } from "/assets/instance-switch.js";
|
|
16
16
|
import { HeaderTools } from "/assets/header-tools.js";
|
|
@@ -27,7 +27,7 @@ import { createChatStore } from "./chat-store.js";
|
|
|
27
27
|
import { createThread, msgKey } from "./chat-thread.js";
|
|
28
28
|
import { PreviewPanel, openPanel, panelStore, shownKey } from "./preview-panel.js";
|
|
29
29
|
import { needsArgument, paletteFor, parseCommandLine } from "./chat-commands.js";
|
|
30
|
-
import { installTour, refreshTourSpot
|
|
30
|
+
import { installTour, refreshTourSpot } from "./chat-tour.js";
|
|
31
31
|
import { FirstRunCard } from "./first-run.js";
|
|
32
32
|
import { LightboxHost, openLightbox } from "./image-lightbox.js";
|
|
33
33
|
|
|
@@ -77,21 +77,11 @@ export function boot({ stream, boot: bootData, deps = {} }) {
|
|
|
77
77
|
if (auth && typeof auth.sessionHandle === "function") auth.sessionHandle().then((h) => P().setBannerScope(h), () => P().setBannerScope(null));
|
|
78
78
|
else P().setBannerScope(null);
|
|
79
79
|
}
|
|
80
|
-
|
|
81
|
-
addFooterItem("tour", TourButton);
|
|
80
|
+
// #1604: the preview opt-in and Tour live in Settings → This device now (and on each preview card's menu).
|
|
82
81
|
installTour();
|
|
83
82
|
return store;
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
function PreviewOptIn() {
|
|
87
|
-
const [on, setOn] = useState(P() ? P().optedIn() : false);
|
|
88
|
-
useEffect(() => (P() ? P().onChange(() => setOn(P().optedIn())) : undefined), []);
|
|
89
|
-
return html`<label class="side-row toggle"><input type="checkbox" checked=${on} onChange=${(e) => { const want = e.target.checked; setPreviewOptIn(want).then(setOn); }} />
|
|
90
|
-
<span>${t("chat.previews")}</span></label>`;
|
|
91
|
-
}
|
|
92
|
-
function TourButton() {
|
|
93
|
-
return html`<button type="button" id="tourBtn" class="side-row" title=${t("chat.tourBtnTitle")} onClick=${startTour}><${Icon} name="info" /><span>${t("chat.tour")}</span></button>`;
|
|
94
|
-
}
|
|
95
85
|
|
|
96
86
|
// ── Code wrap: this browser's choice (agend_code_wrap). On a phone, code wraps whatever it is (app.css).
|
|
97
87
|
const stored = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
|
|
@@ -421,6 +411,7 @@ function Composer({ name, lease, busy }) {
|
|
|
421
411
|
<button id="stopBtn" type="button" class="btn btn-stop" hidden=${!busy} title=${t("chat.stopReplyTitle")}
|
|
422
412
|
disabled=${!!s.cancelling[name]} onClick=${() => store.cancelReply(name)}><${Icon} name="stop" size=${14} /><span class="stop-label">${t("chat.stopReply")}</span></button>
|
|
423
413
|
<button id="sendBtn" type="button" class="btn btn-primary btn-send" hidden=${busy && !content} disabled=${!content || !!s.sending[name]}
|
|
414
|
+
onMouseDown=${(e) => e.preventDefault()}
|
|
424
415
|
aria-label=${t("chat.send")} title=${t("chat.send")} onClick=${submit}><${Icon} name="send" size=${16} /><span class="send-label">${t("chat.send")}</span></button>
|
|
425
416
|
</div></div>`;
|
|
426
417
|
}
|
|
@@ -12,9 +12,9 @@ import { html, useEffect, useMemo, useRef, useState } from "/assets/app-html.js"
|
|
|
12
12
|
import { t } from "/assets/app-i18n.js";
|
|
13
13
|
import { appStore, useStore } from "/assets/app-store.js";
|
|
14
14
|
import { useLease } from "/assets/app-ctx.js";
|
|
15
|
-
import { PanelHeader, PendingChanges, setTitle } from "/assets/app-shell.js";
|
|
16
|
-
import { navigate, setLeaveGuard } from "/assets/app-nav.js";
|
|
17
|
-
import { settingsPath, SETTINGS_SECTIONS } from "/assets/app-route.js";
|
|
15
|
+
import { DevicePrefs, PanelHeader, PendingChanges, setTitle } from "/assets/app-shell.js";
|
|
16
|
+
import { navigate, navStore, setLeaveGuard } from "/assets/app-nav.js";
|
|
17
|
+
import { chatPath, settingsPath, SETTINGS_SECTIONS } from "/assets/app-route.js";
|
|
18
18
|
import { Dialog } from "/assets/ui-dialog.js";
|
|
19
19
|
import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
|
|
20
20
|
import { Icon } from "/assets/ui-icons.js";
|
|
@@ -37,9 +37,13 @@ import { SetupWizard } from "./settings-wizard.js";
|
|
|
37
37
|
import { CreateInstanceDialog } from "./panel-fleet.js";
|
|
38
38
|
import { FirstRunCard, takeWizardRequest } from "./first-run.js";
|
|
39
39
|
import { takeAgentRequest } from "./settings-request.js";
|
|
40
|
+
// #1604: This device — the preview opt-in (one path: the app's confirm, then preview.js) and the tour, from the chat.
|
|
41
|
+
import { setPreviewOptIn } from "./panel-chat.js";
|
|
42
|
+
import { startTour } from "./chat-tour.js";
|
|
43
|
+
import "./chat-strings.js";
|
|
40
44
|
|
|
41
45
|
const tn = (k, ...v) => t(`settings.${k}`, ...v);
|
|
42
|
-
const ICONS = { agents: "bot", bots: "plug", classic: "room", general: "sliders", advanced: "code" };
|
|
46
|
+
const ICONS = { device: "user", agents: "bot", bots: "plug", classic: "room", general: "sliders", advanced: "code" };
|
|
43
47
|
const RELEASES = "https://github.com/songsid/AgEnD/releases";
|
|
44
48
|
|
|
45
49
|
/** GET under the panel's lease → { ok, status, body }. */
|
|
@@ -218,7 +222,9 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
218
222
|
const insts = (ctx.fleet && ctx.fleet.instances) || {};
|
|
219
223
|
if (own(insts, want.name)) setDialog({ kind: "agent", name: want.name, inst: insts[want.name], key: navKey });
|
|
220
224
|
}, [!!ctx, navKey]);
|
|
221
|
-
|
|
225
|
+
// #1604: This device needs no configuration: shown at once, whatever the configuration read does.
|
|
226
|
+
const sectionBody = section === "device" ? html`<${DeviceSection} />`
|
|
227
|
+
: !data ? html`<${Skeleton} lines=${6} />`
|
|
222
228
|
: data.error ? html`<${ErrorState} message=${tn("configLoadFailed")} onRetry=${reload} />`
|
|
223
229
|
: section === "agents" ? html`<${Agents} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
|
|
224
230
|
: section === "bots" ? html`<${Bots} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
|
|
@@ -250,6 +256,49 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
250
256
|
</div>`;
|
|
251
257
|
}
|
|
252
258
|
|
|
259
|
+
/**
|
|
260
|
+
* #1604: what this browser chooses for itself — moved here from the sidebar footer, each with the storage it always had:
|
|
261
|
+
* theme (theme.js), language (app-i18n.js), HTML previews on this device (preview.js, through the app's confirm), and
|
|
262
|
+
* the chat tour (replayed on the chat it points at).
|
|
263
|
+
*/
|
|
264
|
+
export function DeviceSection() {
|
|
265
|
+
const P = () => globalThis.AgendPreview;
|
|
266
|
+
const [on, setOn] = useState(P() ? P().optedIn() : false);
|
|
267
|
+
useEffect(() => (P() ? P().onChange(() => setOn(P().optedIn())) : undefined), []);
|
|
268
|
+
const replay = () => {
|
|
269
|
+
let last = null;
|
|
270
|
+
try { last = localStorage.getItem("agend_last_instance"); } catch { /* none */ }
|
|
271
|
+
navigate(last ? chatPath(last) : "/ui");
|
|
272
|
+
// This replay belongs to the navigation it just made — that one, by its sequence number, not by URL (A → B → A, or
|
|
273
|
+
// the same path again, is a new navigation). It did not happen (a leave guard said no) or it is not the chat:
|
|
274
|
+
// nothing. Left meanwhile (another page, another navigation): nothing (#1609 review).
|
|
275
|
+
const nav = navStore.get();
|
|
276
|
+
if (!nav.route || nav.route.panel !== "chat") return;
|
|
277
|
+
const mine = nav.seq;
|
|
278
|
+
const current = () => navStore.get().seq === mine;
|
|
279
|
+
// The tour points at the chat's own controls: start it once the CHAT has drawn — its Send button, not #main (the
|
|
280
|
+
// shell's landmark is on every page, Settings included) — waiting at most ~3 s (#1608 review); a chat that has not
|
|
281
|
+
// drawn by then (its module failed, an unknown instance) gets no tour. The focus goes to the composer first: the
|
|
282
|
+
// tour returns it there when it ends, not to this button, which is gone.
|
|
283
|
+
let tries = 0;
|
|
284
|
+
const go = () => {
|
|
285
|
+
if (!current()) return;
|
|
286
|
+
if (!document.getElementById("sendBtn")) { if (++tries <= 30) setTimeout(go, 100); return; }
|
|
287
|
+
const here = document.getElementById("msgIn") || document.getElementById("main");
|
|
288
|
+
if (here && typeof here.focus === "function") here.focus();
|
|
289
|
+
if (current()) startTour();
|
|
290
|
+
};
|
|
291
|
+
setTimeout(go, 0);
|
|
292
|
+
};
|
|
293
|
+
return html`<section class="card s-device"><h3>${tn("sec_device")}</h3>
|
|
294
|
+
<p class="sub">${tn("deviceLead")}</p>
|
|
295
|
+
<${DevicePrefs} labels=${true} />
|
|
296
|
+
${P() ? html`<label class="check"><input type="checkbox" checked=${on} onChange=${(e) => { const want = e.target.checked; setPreviewOptIn(want).then(setOn); }} />
|
|
297
|
+
<span>${t("chat.previews")}</span></label>` : null}
|
|
298
|
+
<div class="s-device-row"><button type="button" id="tourBtn" class="btn" title=${t("chat.tourBtnTitle")} onClick=${replay}><${Icon} name="info" size=${16} />${t("chat.tour")}</button></div>
|
|
299
|
+
</section>`;
|
|
300
|
+
}
|
|
301
|
+
|
|
253
302
|
/** The operation in hand still owns Apply: its writes and job, or a restart of AgEnD from the press to its end. */
|
|
254
303
|
const opBusy = (op) => !!op && (["writing", "posting", "watching"].includes(op.phase) || op.restart === "busy");
|
|
255
304
|
function dropSecrets(staged) { for (const c of staged.values()) if (c.connectionSecret) c.connectionSecret.secret = ""; }
|
|
@@ -217,6 +217,7 @@ register("settings", {
|
|
|
217
217
|
concurrencyWarning: "May exceed CPU capacity on smaller hosts",
|
|
218
218
|
staggerRange: "Must be between 0 and 30000",
|
|
219
219
|
staggerWarning: "Very low values can cause startup spikes",
|
|
220
|
+
sec_device: "This device", deviceLead: "Choices for this browser only — other devices keep their own.",
|
|
220
221
|
sec_agents: "Agents",
|
|
221
222
|
sec_bots: "Connections",
|
|
222
223
|
sec_classic: "ClassicBot",
|
|
@@ -561,6 +562,7 @@ register("settings", {
|
|
|
561
562
|
concurrencyWarning: "在較小的主機上可能超出 CPU 負荷",
|
|
562
563
|
staggerRange: "必須介於 0 到 30000",
|
|
563
564
|
staggerWarning: "數值太低可能造成啟動尖峰",
|
|
565
|
+
sec_device: "這台裝置", deviceLead: "只套用在這個瀏覽器,其他裝置保留各自的設定。",
|
|
564
566
|
sec_agents: "Agent",
|
|
565
567
|
sec_bots: "連線",
|
|
566
568
|
sec_classic: "ClassicBot",
|
|
@@ -45,11 +45,11 @@ register("app", {
|
|
|
45
45
|
needsYou: "needs you", approxNote: "Read from the terminal, so approximate", chat: "Chat", details: "Details", instanceViews: "Views of {0}",
|
|
46
46
|
textSize: "Text size: {0}", textSize_s: "S", textSize_m: "M", textSize_l: "L", textSize_fit: "Fit", usage: "Usage",
|
|
47
47
|
statusRunning: "running", statusStopped: "stopped", statusCrashed: "crashed", statusWorking: "working", statusStuck: "looks stuck", statusIdle: "idle",
|
|
48
|
-
|
|
48
|
+
connReconnecting: "Can't reach AgEnD — it may be restarting. Reconnecting…", connReconnectingIn: "Can't reach AgEnD — it may be restarting. Trying again in {0} s.",
|
|
49
49
|
hydrating: "Loading what is open now…", hydrateFailed: "Could not load what is open now (prompts, ticks).",
|
|
50
50
|
skip: "Skip to the conversation", loadFailed: "This could not be loaded.", retry: "Try again", loading: "Loading…",
|
|
51
51
|
noInstances: "No instances yet", noInstancesHint: "Create one to start chatting.",
|
|
52
|
-
session: "Session", signIn: "Sign in", signedIn: "Signed in", signedOut: "Not signed in", thisDevice: "This device",
|
|
52
|
+
session: "Session", signIn: "Sign in", signedIn: "Signed in", signedOut: "Not signed in", thisDevice: "This device", devicePrefs: "Theme and language",
|
|
53
53
|
lastActive: "active {0}", justNow: "just now", minAgo: "{0} min ago", hrAgo: "{0} h ago", signOut: "Sign out",
|
|
54
54
|
signOutAll: "Sign out everywhere", devices: "Signed-in devices", expires: "Ends by {0} at the latest, or after {1} unused",
|
|
55
55
|
failed: "That did not work — try again.", close: "Close", more: "More actions", menu: "Menu",
|
|
@@ -74,11 +74,11 @@ register("app", {
|
|
|
74
74
|
needsYou: "等你回應", approxNote: "從終端畫面判讀,僅供參考", chat: "聊天", details: "詳細資訊", instanceViews: "{0} 的檢視",
|
|
75
75
|
textSize: "字級:{0}", textSize_s: "小", textSize_m: "中", textSize_l: "大", textSize_fit: "剛好填滿", usage: "用量",
|
|
76
76
|
statusRunning: "執行中", statusStopped: "已停止", statusCrashed: "已當掉", statusWorking: "工作中", statusStuck: "似乎卡住", statusIdle: "閒置",
|
|
77
|
-
|
|
77
|
+
connReconnecting: "連不上 AgEnD——可能正在重新啟動。正在重新連線…", connReconnectingIn: "連不上 AgEnD——可能正在重新啟動。{0} 秒後再試。",
|
|
78
78
|
hydrating: "正在載入目前的狀態…", hydrateFailed: "無法載入目前的狀態(提示、送達標記)。",
|
|
79
79
|
skip: "跳到對話", loadFailed: "無法載入。", retry: "再試一次", loading: "載入中…",
|
|
80
80
|
noInstances: "還沒有 instance", noInstancesHint: "建立一個就能開始聊天。",
|
|
81
|
-
session: "登入狀態", signIn: "登入", signedIn: "已登入", signedOut: "尚未登入", thisDevice: "這台裝置",
|
|
81
|
+
session: "登入狀態", signIn: "登入", signedIn: "已登入", signedOut: "尚未登入", thisDevice: "這台裝置", devicePrefs: "主題與語言",
|
|
82
82
|
lastActive: "{0}活動", justNow: "剛剛", minAgo: "{0} 分鐘前", hrAgo: "{0} 小時前", signOut: "登出",
|
|
83
83
|
signOutAll: "全部登出", devices: "已登入的裝置", expires: "最晚 {0} 結束;閒置 {1} 也會結束",
|
|
84
84
|
failed: "沒有成功,請再試一次。", close: "關閉", more: "更多動作", menu: "選單",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// a test runs both over one list of cases). Pure: no DOM, no history.
|
|
3
3
|
|
|
4
4
|
export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "cache", "config"];
|
|
5
|
-
export const SETTINGS_SECTIONS = ["agents", "bots", "classic", "general", "advanced"];
|
|
5
|
+
export const SETTINGS_SECTIONS = ["device", "agents", "bots", "classic", "general", "advanced"]; // #1604: device first
|
|
6
6
|
const MAX_INSTANCE_NAME = 128;
|
|
7
7
|
const PATH_SEPARATOR_OR_CONTROL = /[/\\\u0000-\u001f\u007f]/;
|
|
8
8
|
|
|
@@ -14,7 +14,7 @@ import { ErrorState, Skeleton } from "./ui-states.js";
|
|
|
14
14
|
import { InstanceNav, RosterNav } from "./instance-nav.js";
|
|
15
15
|
|
|
16
16
|
/** Shell state shared with panels: the phone drawer, the collapsed sidebar, extensions a panel module adds. */
|
|
17
|
-
export const shellStore = createStore({ drawer: false, collapsed: readCollapsed(),
|
|
17
|
+
export const shellStore = createStore({ drawer: false, collapsed: readCollapsed(), keyboard: false, dialog: null, side: null });
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* The mounted panel's own sidebar section, in place of the instance list (View's roster: its groups, filter and order,
|
|
@@ -49,10 +49,6 @@ export function toggleSidebar() {
|
|
|
49
49
|
shellStore.set({ collapsed });
|
|
50
50
|
try { localStorage.setItem("agend_sidebar", collapsed ? "hidden" : "shown"); } catch { /* this page only */ }
|
|
51
51
|
}
|
|
52
|
-
/** A panel module adds a control to the sidebar footer (the chat adds the preview opt-in and the tour). */
|
|
53
|
-
export function addFooterItem(key, Component) {
|
|
54
|
-
shellStore.set(s => ({ ...s, footer: [...s.footer.filter(x => x.key !== key), { key, Component }] }));
|
|
55
|
-
}
|
|
56
52
|
|
|
57
53
|
// ── Keys ── One document listener for the page. Esc closes what is on top first (the drawer); then the panel's own
|
|
58
54
|
// handlers run — only for the mounted panel, and only when the focus is inside it or on nothing (the body).
|
|
@@ -125,8 +121,8 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
|
|
|
125
121
|
<nav class="side-nav" aria-label=${t("app.menu")}>${navLink("view", "/view", "view", t("app.view"))}</nav>
|
|
126
122
|
${shell.side ? html`<${shell.side.Component} />` : html`<${ViewOnlyNav} route=${route} />`}
|
|
127
123
|
<div class="side-foot">
|
|
128
|
-
<${Prefs} />
|
|
129
124
|
<a class="side-row" href=${signInHref()}><${Icon} name="user" /><span>${t("app.signIn")}</span></a>
|
|
125
|
+
<${PrefsPopover} />
|
|
130
126
|
</div>
|
|
131
127
|
</aside>`;
|
|
132
128
|
}
|
|
@@ -145,8 +141,6 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
|
|
|
145
141
|
exec=${app.exec} awaiting=${app.awaiting} onPick=${closeDrawer} />`}
|
|
146
142
|
<div class="side-foot">
|
|
147
143
|
${navLink("settings", settingsPath(), "settings", t("app.settings"))}
|
|
148
|
-
${shell.footer.map(({ key, Component }) => html`<${Component} key=${key} />`)}
|
|
149
|
-
<${Prefs} />
|
|
150
144
|
<${SessionMenu} />
|
|
151
145
|
</div>
|
|
152
146
|
</aside>`;
|
|
@@ -157,19 +151,31 @@ function ViewOnlyNav({ route }) {
|
|
|
157
151
|
return html`<${RosterNav} route=${route} viewOnly=${true} onPick=${closeDrawer} />`;
|
|
158
152
|
}
|
|
159
153
|
|
|
160
|
-
|
|
154
|
+
/**
|
|
155
|
+
* This browser's theme and language (#1604: Settings → This device; for the anonymous View reader, the footer's
|
|
156
|
+
* popover). The same stores as ever: AgendTheme (theme.js) and setLang (app-i18n.js) — only where they are shown moved.
|
|
157
|
+
*/
|
|
158
|
+
export function DevicePrefs({ labels = false } = {}) {
|
|
161
159
|
const [theme, setTheme] = useState(typeof AgendTheme !== "undefined" ? AgendTheme.get() : "system");
|
|
162
160
|
const [l, setL] = useState(lang());
|
|
163
|
-
return html`<div class
|
|
164
|
-
<label class="pref"><${Icon} name="sun" size=${16} /><span class
|
|
161
|
+
return html`<div class=${`prefs${labels ? " labelled" : ""}`}>
|
|
162
|
+
<label class="pref"><${Icon} name="sun" size=${16} /><span class=${labels ? "pref-label" : "sr-only"}>${t("app.theme")}</span>
|
|
165
163
|
<select value=${theme} aria-label=${t("app.theme")} onChange=${(e) => { setTheme(e.target.value); if (typeof AgendTheme !== "undefined") AgendTheme.set(e.target.value); }}>
|
|
166
164
|
<option value="system">${t("app.themeSystem")}</option><option value="light">${t("app.themeLight")}</option><option value="dark">${t("app.themeDark")}</option></select></label>
|
|
167
|
-
<label class="pref"><${Icon} name="globe" size=${16} /><span class
|
|
165
|
+
<label class="pref"><${Icon} name="globe" size=${16} /><span class=${labels ? "pref-label" : "sr-only"}>${t("app.language")}</span>
|
|
168
166
|
<select value=${l} aria-label=${t("app.language")} onChange=${(e) => { setL(e.target.value); setLang(e.target.value); }}>
|
|
169
167
|
<option value="en">English</option><option value="zh-TW">中文</option></select></label>
|
|
170
168
|
</div>`;
|
|
171
169
|
}
|
|
172
170
|
|
|
171
|
+
/** #1604: the anonymous View reader has no Settings: theme and language from one compact control beside Sign in. */
|
|
172
|
+
function PrefsPopover() {
|
|
173
|
+
return html`<details class="prefs-pop">
|
|
174
|
+
<summary class="icon-btn" aria-label=${t("app.devicePrefs")} title=${t("app.devicePrefs")}><${Icon} name="sun" /></summary>
|
|
175
|
+
<div class="pop" role="group" aria-label=${t("app.devicePrefs")}><${DevicePrefs} labels=${true} /></div>
|
|
176
|
+
</details>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
173
179
|
/**
|
|
174
180
|
* The header every panel shows: ☰ (phone, or a collapsed sidebar), its title and status, then its actions. `nav`
|
|
175
181
|
* (#1523 N2): an instance page's view switch — beside the title on a wide screen, a row of tabs under it on a phone.
|
|
@@ -227,9 +233,18 @@ function ConnectionLine() {
|
|
|
227
233
|
return html`<div class="conn" role="status">${hydration === "failed" ? t("app.hydrateFailed") : t("app.hydrating")}
|
|
228
234
|
${hydration === "failed" && retryHydration ? html` <button type="button" class="btn btn-sm" onClick=${() => retryHydration()}>${t("app.retry")}</button>` : null}</div>`;
|
|
229
235
|
}
|
|
230
|
-
if (connection === "
|
|
231
|
-
|
|
232
|
-
|
|
236
|
+
if (connection === "reconnecting") return html`<${Reconnecting} />`;
|
|
237
|
+
// #1603: polling is a working transport (the public link polls by design; a buffering proxy makes a page poll), not
|
|
238
|
+
// a fault — messages still arrive, at most ~5 s late, and there is nothing to do: no strip. Live, none, and ended
|
|
239
|
+
// (agend-auth's banner says that) draw nothing either; only reconnecting and a failed catch-up are shown.
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
/** #1580: the fleet is not answering (a restart): said, with when the next try is — never a silent stale page. */
|
|
243
|
+
function Reconnecting() {
|
|
244
|
+
const { retryAt } = useStore(appStore);
|
|
245
|
+
useTick(true);
|
|
246
|
+
const s = typeof retryAt === "number" ? Math.max(0, Math.ceil((retryAt - NOW()) / 1000)) : 0;
|
|
247
|
+
return html`<div class="conn reconnecting" role="status">${s > 0 ? t("app.connReconnectingIn", s) : t("app.connReconnecting")}</div>`;
|
|
233
248
|
}
|
|
234
249
|
|
|
235
250
|
const NOW = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|