@songsid/agend 2.2.0-alpha.4 → 2.2.0-alpha.6
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/auth-api.d.ts +22 -0
- package/dist/auth-api.js +69 -1
- package/dist/auth-api.js.map +1 -1
- package/dist/channel/adapters/discord.js +9 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/command-table.d.ts +8 -1
- package/dist/command-table.js +13 -4
- package/dist/command-table.js.map +1 -1
- package/dist/fleet-manager.d.ts +47 -0
- package/dist/fleet-manager.js +139 -5
- package/dist/fleet-manager.js.map +1 -1
- package/dist/locale.js +10 -0
- package/dist/locale.js.map +1 -1
- package/dist/public-web-gateway.js +2 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/topic-commands.js +3 -2
- package/dist/topic-commands.js.map +1 -1
- package/dist/ui/chat-render.js +6 -0
- package/dist/ui/chat-strings.js +17 -0
- package/dist/ui/chat-thread.js +22 -3
- package/dist/ui/image-lightbox.js +104 -0
- package/dist/ui/panel-chat.js +20 -52
- package/dist/ui/panel-details.js +146 -0
- package/dist/ui/panel-fleet.js +2 -1
- package/dist/ui/panel-settings.js +18 -0
- package/dist/ui/preview-panel.js +2 -1
- package/dist/ui/preview.js +49 -0
- package/dist/ui/settings-request.js +9 -0
- package/dist/ui/shared/agend-auth.js +15 -7
- package/dist/ui/shared/app-i18n.js +4 -2
- package/dist/ui/shared/app-route.js +7 -2
- package/dist/ui/shared/app-shell.js +22 -8
- package/dist/ui/shared/app.css +51 -1
- package/dist/ui/shared/app.js +6 -0
- package/dist/ui/shared/header-tools.js +93 -0
- package/dist/ui/shared/instance-nav.js +7 -4
- package/dist/ui/shared/instance-switch.js +16 -0
- package/dist/ui/shared/panel-view.js +14 -153
- package/dist/ui/shared/read-stream.js +26 -0
- package/dist/ui/shared/signin.js +38 -1
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/usage-dialog.js +125 -0
- package/dist/ui/shared/view-strings.js +2 -2
- package/dist/ui/signin.html +3 -0
- package/dist/update-install.js +3 -1
- package/dist/update-install.js.map +1 -1
- package/dist/web-api.d.ts +16 -0
- package/dist/web-api.js +29 -4
- package/dist/web-api.js.map +1 -1
- package/dist/web-chat-history.d.ts +97 -6
- package/dist/web-chat-history.js +189 -12
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-chat-store.d.ts +45 -0
- package/dist/web-chat-store.js +192 -0
- package/dist/web-chat-store.js.map +1 -0
- package/dist/web-login.d.ts +2 -0
- package/dist/web-login.js +4 -0
- package/dist/web-login.js.map +1 -1
- package/dist/web-returning-device.d.ts +84 -0
- package/dist/web-returning-device.js +204 -0
- package/dist/web-returning-device.js.map +1 -0
- package/dist/web-shell-routes.d.ts +4 -0
- package/dist/web-shell-routes.js +9 -0
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-upload.d.ts +21 -0
- package/dist/web-upload.js +50 -1
- package/dist/web-upload.js.map +1 -1
- package/launcher/old-updater-note.cjs +48 -0
- package/launcher/postinstall.cjs +8 -1
- package/package.json +1 -1
- package/scripts/preinstall-guard.cjs +5 -2
|
@@ -8,7 +8,7 @@ import { ConfirmHost } from "./ui-confirm.js";
|
|
|
8
8
|
import { t, lang, setLang, onLang } from "./app-i18n.js";
|
|
9
9
|
import { appStore, createStore, useStore } from "./app-store.js";
|
|
10
10
|
import { navStore } from "./app-nav.js";
|
|
11
|
-
import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH } from "./app-route.js";
|
|
11
|
+
import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, detailsPath } from "./app-route.js";
|
|
12
12
|
import { SessionMenu } from "./app-session.js";
|
|
13
13
|
import { ErrorState, Skeleton } from "./ui-states.js";
|
|
14
14
|
import { InstanceNav, RosterNav } from "./instance-nav.js";
|
|
@@ -108,7 +108,8 @@ export function signInHref() {
|
|
|
108
108
|
function Sidebar({ route, onNewInstance, viewOnly }) {
|
|
109
109
|
const app = useStore(appStore);
|
|
110
110
|
const shell = useStore(shellStore);
|
|
111
|
-
const on = (panel) => !!route && route.panel === panel;
|
|
111
|
+
const on = (panel) => !!route && (route.panel === panel || (panel === "fleet" && route.panel === "details"));
|
|
112
|
+
const openInstance = openInstanceOf(route);
|
|
112
113
|
// A count badge is hidden at 0; its number is also in the link's accessible name.
|
|
113
114
|
const navLink = (panel, href, icon, label, count = 0) => html`<a class=${`side-row${on(panel) ? " active" : ""}`} href=${href}
|
|
114
115
|
aria-current=${on(panel) ? "page" : undefined} aria-label=${count ? `${label} (${t("app.needsCount", count)})` : undefined} onClick=${closeDrawer}>
|
|
@@ -137,7 +138,7 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
|
|
|
137
138
|
</div>
|
|
138
139
|
<nav class="side-nav" aria-label=${t("app.menu")}>
|
|
139
140
|
${navLink("needs", NEEDS_PATH, "inbox", t("app.needsNav"), needsCount)}
|
|
140
|
-
${navLink("fleet", "/ui/fleet", "fleet", t("app.fleet"))}
|
|
141
|
+
${navLink("fleet", openInstance ? detailsPath(openInstance) : "/ui/fleet", "fleet", t("app.fleet"))}
|
|
141
142
|
${navLink("view", "/view", "view", t("app.view"))}
|
|
142
143
|
</nav>
|
|
143
144
|
${shell.side ? html`<${shell.side.Component} />` : html`<${InstanceNav} route=${route} items=${app.instances} loaded=${app.ready !== false}
|
|
@@ -169,28 +170,41 @@ function Prefs() {
|
|
|
169
170
|
</div>`;
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
173
|
+
/**
|
|
174
|
+
* The header every panel shows: ☰ (phone, or a collapsed sidebar), its title and status, then its actions. `nav`
|
|
175
|
+
* (#1523 N2): an instance page's view switch — beside the title on a wide screen, a row of tabs under it on a phone.
|
|
176
|
+
*/
|
|
177
|
+
export function PanelHeader({ title, sub, children, headingRef, nav }) {
|
|
174
178
|
const shell = useStore(shellStore);
|
|
175
179
|
return html`<header class=${`panel-head${shell.collapsed ? " collapsed" : ""}`}>
|
|
176
180
|
<button type="button" id="sbOpen" class="icon-btn sb-open" onClick=${() => (narrow() ? openDrawer() : toggleSidebar())}
|
|
177
181
|
aria-label=${narrow() ? t("app.openMenu") : t("app.expand")} title=${narrow() ? t("app.openMenu") : t("app.expand")} aria-controls="sidebar" aria-expanded=${shell.drawer ? "true" : "false"}><${Icon} name="menu" /></button>
|
|
178
182
|
<div class="panel-title"><h1 ref=${headingRef} tabindex="-1">${title}</h1>${sub ? html`<div class="panel-sub">${sub}</div>` : null}</div>
|
|
183
|
+
${nav || null}
|
|
179
184
|
<div class="panel-actions">${children}</div>
|
|
180
185
|
</header>`;
|
|
181
186
|
}
|
|
182
187
|
|
|
188
|
+
/**
|
|
189
|
+
* #1523 N2: the instance a page is about — Chat, View or Details of it — or null. With one open, Fleet goes to its
|
|
190
|
+
* Details (the user's complaint: Fleet always landed on Tasks), and Chat and View keep it; with none, as before.
|
|
191
|
+
*/
|
|
192
|
+
export function openInstanceOf(route) {
|
|
193
|
+
return route && (route.panel === "chat" || route.panel === "view" || route.panel === "details") && route.instance ? route.instance : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
183
196
|
function BottomTabs({ route, viewOnly }) {
|
|
184
197
|
const app = useStore(appStore);
|
|
185
198
|
let last = null;
|
|
186
199
|
try { last = localStorage.getItem("agend_last_instance"); } catch { /* none */ }
|
|
187
|
-
const
|
|
200
|
+
const open = openInstanceOf(route);
|
|
201
|
+
const chatHref = open && route.panel !== "view" ? chatPath(open) : route && route.panel === "chat" && route.instance ? chatPath(route.instance) : last ? chatPath(last) : "/ui";
|
|
188
202
|
const anyAwaiting = Object.keys(app.awaiting).length;
|
|
189
203
|
const tab = (href, icon, label, active, badge, badgeLabel) => html`<a class=${`tab${active ? " active" : ""}`} href=${href} aria-current=${active ? "page" : undefined}>
|
|
190
204
|
<${Icon} name=${icon} size=${20} /><span>${label}</span>${badge ? html`<span class="tab-badge" aria-label=${badgeLabel || t("app.needsYou")}>${badge}</span>` : null}</a>`;
|
|
191
205
|
let lastView = null;
|
|
192
206
|
try { lastView = localStorage.getItem("agend_last_view"); } catch { /* none */ }
|
|
193
|
-
const viewHref = route && route.panel === "view" && route.instance ? viewPath(route.instance) : viewPath(lastView);
|
|
207
|
+
const viewHref = route && (route.panel === "view" || route.panel === "details") && route.instance ? viewPath(route.instance) : viewPath(lastView);
|
|
194
208
|
if (viewOnly) {
|
|
195
209
|
return html`<nav class="tabs" aria-label=${t("app.menu")}>
|
|
196
210
|
${tab(viewHref, "view", t("app.view"), route && route.panel === "view")}
|
|
@@ -200,7 +214,7 @@ function BottomTabs({ route, viewOnly }) {
|
|
|
200
214
|
return html`<nav class="tabs" aria-label=${t("app.menu")}>
|
|
201
215
|
${tab(chatHref, "chat", t("app.chat"), route && route.panel === "chat", anyAwaiting)}
|
|
202
216
|
${tab(NEEDS_PATH, "inbox", t("app.needsTab"), route && route.panel === "needs", (app.needs || []).length, t("app.needsCount", (app.needs || []).length))}
|
|
203
|
-
${tab("/ui/fleet", "fleet", t("app.fleet"), route && route.panel === "fleet")}
|
|
217
|
+
${tab(open ? detailsPath(open) : "/ui/fleet", "fleet", t("app.fleet"), route && (route.panel === "fleet" || route.panel === "details"))}
|
|
204
218
|
${tab(viewHref, "view", t("app.view"), route && route.panel === "view")}
|
|
205
219
|
${tab(settingsPath(), "settings", t("app.settings"), route && route.panel === "settings")}
|
|
206
220
|
</nav>`;
|
package/dist/ui/shared/app.css
CHANGED
|
@@ -152,6 +152,29 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
|
|
|
152
152
|
.panel-sub { color: var(--text-2); font-size: var(--fs-sm); white-space: nowrap; }
|
|
153
153
|
.status { display: inline-flex; align-items: center; gap: 6px; }
|
|
154
154
|
.panel-actions { display: flex; align-items: center; gap: var(--s-1); }
|
|
155
|
+
/* #1523 N2: the instance view switch — beside the title on a wide screen; on a phone, a row of tabs under the header. */
|
|
156
|
+
.inst-switch.seg { padding: 0; margin: 0; width: auto; max-width: none; flex-shrink: 0; }
|
|
157
|
+
.inst-switch .seg-item { min-height: 30px; }
|
|
158
|
+
/* #1523 N3 (Q2 = A): the text size, one custom property from the root's data-text-size (header-tools.js). The chat's
|
|
159
|
+
messages, Details and the Fleet tabs scale the type tokens by it; View's terminal scales itself (VIEW_SCALE). */
|
|
160
|
+
:root { --text-scale: 1; }
|
|
161
|
+
:root[data-text-size="s"] { --text-scale: 0.9; }
|
|
162
|
+
:root[data-text-size="l"] { --text-scale: 1.15; }
|
|
163
|
+
.thread, .p-details .panel-body, .p-fleet .panel-body {
|
|
164
|
+
--fs-xs: calc(12px * var(--text-scale)); --lh-xs: calc(16px * var(--text-scale));
|
|
165
|
+
--fs-sm: calc(13px * var(--text-scale)); --lh-sm: calc(18px * var(--text-scale));
|
|
166
|
+
--fs-md: calc(15px * var(--text-scale)); --lh-md: calc(24px * var(--text-scale));
|
|
167
|
+
--fs-lg: calc(17px * var(--text-scale)); --lh-lg: calc(24px * var(--text-scale));
|
|
168
|
+
font-size: var(--fs-md); line-height: var(--lh-md);
|
|
169
|
+
}
|
|
170
|
+
/* #1523 N2: Details. */
|
|
171
|
+
.p-details .d-tags { display: inline-flex; flex-wrap: wrap; gap: var(--s-1); }
|
|
172
|
+
.p-details .d-edit { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); flex-wrap: wrap; margin-top: var(--s-2); }
|
|
173
|
+
@media (max-width: 899px) {
|
|
174
|
+
.panel-head:has(.inst-switch) { flex-wrap: wrap; }
|
|
175
|
+
.inst-switch.seg { order: 3; flex-basis: 100%; display: grid; grid-template-columns: repeat(3, 1fr); padding: 0 0 var(--s-1); }
|
|
176
|
+
.inst-switch .seg-item { justify-content: center; border-radius: var(--r-sm); }
|
|
177
|
+
}
|
|
155
178
|
.panel-body { flex: 1; min-height: 0; overflow-y: auto; }
|
|
156
179
|
.panel-body.center { display: flex; align-items: center; justify-content: center; padding: var(--s-6); }
|
|
157
180
|
.col { max-width: var(--col); margin: 0 auto; padding: var(--s-4) var(--s-5) var(--s-8); }
|
|
@@ -266,6 +289,28 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
|
|
|
266
289
|
.msg .atts { display: flex; flex-wrap: wrap; gap: var(--s-2); margin-top: var(--s-2); }
|
|
267
290
|
.msg.user .atts { justify-content: flex-end; }
|
|
268
291
|
.msg .att-img img { max-width: 260px; max-height: 220px; border-radius: var(--r-sm); display: block; }
|
|
292
|
+
.msg .att-img { cursor: zoom-in; }
|
|
293
|
+
/* #1565: a file from before the fleet restarted that could not be served again — its name only, nothing to fetch. */
|
|
294
|
+
.att-file.att-gone { opacity: 0.6; cursor: default; text-decoration: none; }
|
|
295
|
+
/* A chat image, full size, in the page (image-lightbox.js): the whole viewport, dark, the image fitted to it. */
|
|
296
|
+
html.lb-open .scroller { overflow: hidden; }
|
|
297
|
+
.lb { position: fixed; inset: 0; width: 100vw; height: 100vh; height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; border: 0;
|
|
298
|
+
background: rgba(0, 0, 0, 0.95); color: #fff; overflow: hidden; overscroll-behavior: contain; }
|
|
299
|
+
.lb::backdrop { background: rgba(0, 0, 0, 0.6); }
|
|
300
|
+
.lb-stage { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 56px 16px 16px; box-sizing: border-box; }
|
|
301
|
+
.lb-img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
|
|
302
|
+
.lb-bar { position: absolute; top: 0; left: 0; right: 0; z-index: 1; display: flex; align-items: center; gap: var(--s-2); padding: var(--s-2) var(--s-3);
|
|
303
|
+
background: rgba(0, 0, 0, 0.75); }
|
|
304
|
+
.lb-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-sm); }
|
|
305
|
+
.lb-count { font-size: var(--fs-sm); opacity: 0.85; }
|
|
306
|
+
.lb .icon-btn { color: #fff; }
|
|
307
|
+
.lb .icon-btn:hover { background: rgba(255, 255, 255, 0.15); }
|
|
308
|
+
.lb .lb-orig { color: #fff; background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.3); }
|
|
309
|
+
.lb-nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 1; background: rgba(0, 0, 0, 0.4); }
|
|
310
|
+
.lb-prev { left: var(--s-2); }
|
|
311
|
+
.lb-next { right: var(--s-2); }
|
|
312
|
+
/* A phone: the arrows sit below the image's middle band, out of its way, still reachable with a thumb. */
|
|
313
|
+
@media (max-width: 480px) { .lb .lb-orig .lbl { display: none; } .lb-nav { top: auto; bottom: var(--s-4); transform: none; } .lb-stage { padding-bottom: 72px; } }
|
|
269
314
|
.msg .att-file { font-size: var(--fs-sm); color: var(--accent); }
|
|
270
315
|
.msg .att-size { color: var(--text-3); }
|
|
271
316
|
.msg .md p { margin: 0 0 var(--s-3); } .msg .md p:last-child { margin-bottom: 0; }
|
|
@@ -299,7 +344,12 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
|
|
|
299
344
|
.pv-label { font-size: var(--fs-xs); font-weight: var(--fw-bold); color: var(--text-2); margin-right: auto; font-family: var(--font-mono); }
|
|
300
345
|
.pv-note { font-size: var(--fs-xs); color: var(--text-3); margin-top: 6px; }
|
|
301
346
|
.pv-note:empty { display: none; }
|
|
302
|
-
.pv-banner { margin-top: var(--s-2); padding: var(--s-2) var(--s-3); border-radius: var(--r-sm); font-size: var(--fs-xs); background: var(--warn-bg); border: 1px solid var(--warn-line);
|
|
347
|
+
.pv-banner { margin-top: var(--s-2); padding: var(--s-2) var(--s-3); border-radius: var(--r-sm); font-size: var(--fs-xs); background: var(--warn-bg); border: 1px solid var(--warn-line);
|
|
348
|
+
display: flex; align-items: flex-start; gap: var(--s-2); }
|
|
349
|
+
.pv-banner[hidden] { display: none; }
|
|
350
|
+
.pv-banner-text { flex: 1; min-width: 0; }
|
|
351
|
+
/* #1568: "I understand" hides the banner until the next sign-in; .preview-frame's border below never depends on it. */
|
|
352
|
+
.pv-banner .pv-ack { flex: none; }
|
|
303
353
|
.pv-holder:empty { display: none; }
|
|
304
354
|
.pv-holder { margin-top: var(--s-2); }
|
|
305
355
|
.preview-frame { display: block; width: 100%; border: 2px solid var(--warn-line); border-radius: var(--r-sm); background: #ffffff; }
|
package/dist/ui/shared/app.js
CHANGED
|
@@ -14,8 +14,11 @@ import { chatPath } from "./app-route.js";
|
|
|
14
14
|
import { startNeedsNotifier } from "./app-needs.js";
|
|
15
15
|
import { toast } from "./ui-toast.js";
|
|
16
16
|
import { t } from "./app-i18n.js";
|
|
17
|
+
import { initTextSize } from "./header-tools.js";
|
|
17
18
|
|
|
18
19
|
const boot = document.body.dataset;
|
|
20
|
+
// #1523 N3: this device's text size, on the root before the first paint of any panel.
|
|
21
|
+
initTextSize();
|
|
19
22
|
// "full": a signed-in page. "view-only": an anonymous reader of /view under web.view_access: open — no stream, no
|
|
20
23
|
// session-only panel, nothing under /ui/ is ever imported or read (#1408 §3).
|
|
21
24
|
const mode = boot.mode === "view-only" ? "view-only" : "full";
|
|
@@ -53,6 +56,7 @@ if (mode === "full") {
|
|
|
53
56
|
const loadFleet = retryable((a) => import(retryUrl("/ui/js/panel-fleet.js", a)));
|
|
54
57
|
const loadSettings = retryable((a) => import(retryUrl("/ui/js/panel-settings.js", a)));
|
|
55
58
|
const loadNeeds = retryable((a) => import(retryUrl("/ui/js/panel-needs.js", a)));
|
|
59
|
+
const loadDetails = retryable((a) => import(retryUrl("/ui/js/panel-details.js", a)));
|
|
56
60
|
// The stream opens once the chat listens, so the frames sent on connect (status, open prompts, ticks) reach it too.
|
|
57
61
|
// If the chat cannot load (a session that just ended), the stream still opens for the sidebar.
|
|
58
62
|
const chatBoot = loadChat();
|
|
@@ -64,6 +68,8 @@ if (mode === "full") {
|
|
|
64
68
|
panels.set("fleet", { load: () => loadFleet().then((m) => m.FleetPanel) });
|
|
65
69
|
panels.set("settings", { load: () => loadSettings().then((m) => m.SettingsPanel) });
|
|
66
70
|
panels.set("needs", { load: () => loadNeeds().then((m) => m.NeedsPanel) });
|
|
71
|
+
// #1523 N2: one instance's Details (/ui/fleet/agent/<name>).
|
|
72
|
+
panels.set("details", { load: () => loadDetails().then((m) => m.DetailsPanel) });
|
|
67
73
|
// #1386 §6.3: desktop notifications for new "Needs you" items, in any panel, while this signed-in page is open.
|
|
68
74
|
startNeedsNotifier({ open: (instance) => navigate(chatPath(instance)) });
|
|
69
75
|
onNewInstance = async () => {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// #1523 N3 (§3.4, Q2 = A): the controls every page's header has, in one order on Chat, View, Details and Fleet —
|
|
2
|
+
// Aa text size, shared per device: S / M / L (View also keeps Fit, its own extra choice);
|
|
3
|
+
// ◔ AI usage, unless the fleet turned it off (web.usage_panel: false — said by the page itself, no probe read).
|
|
4
|
+
// The size is one attribute on the root (data-text-size); CSS turns it into one custom property the chat, Details and
|
|
5
|
+
// Fleet read (#1300: no style attribute). View's terminal multiplies its fitted size by VIEW_SCALE, as its density did.
|
|
6
|
+
import { html, useState } from "./app-html.js";
|
|
7
|
+
import { t } from "./app-i18n.js";
|
|
8
|
+
import { createStore, useStore } from "./app-store.js";
|
|
9
|
+
import { Icon } from "./ui-icons.js";
|
|
10
|
+
import { UsageDialog } from "./usage-dialog.js";
|
|
11
|
+
|
|
12
|
+
export const TEXT_SIZE_KEY = "agend_text_size";
|
|
13
|
+
export const VIEW_FIT_KEY = "agend_view_fit";
|
|
14
|
+
const LEGACY_DENSITY_KEY = "agend_view_density";
|
|
15
|
+
export const TEXT_SIZES = ["s", "m", "l"];
|
|
16
|
+
/** View's terminal: its fitted size times this (S / M / L are View's old compact / – / comfortable). */
|
|
17
|
+
export const VIEW_SCALE = { s: 0.8, m: 1, l: 1.25 };
|
|
18
|
+
|
|
19
|
+
const read = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
|
|
20
|
+
const write = (k, v) => { try { localStorage.setItem(k, v); } catch { /* this page only */ } };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* This device's choice: `agend_text_size` (+ View's Fit), else — once — from View's `agend_view_density`:
|
|
24
|
+
* comfortable → L, compact → S (Fit off); fit, or nothing → M with View on Fit (View's default, as before).
|
|
25
|
+
*/
|
|
26
|
+
export function loadTextSize() {
|
|
27
|
+
let size = read(TEXT_SIZE_KEY), fit = read(VIEW_FIT_KEY);
|
|
28
|
+
if (!TEXT_SIZES.includes(size)) {
|
|
29
|
+
const legacy = read(LEGACY_DENSITY_KEY);
|
|
30
|
+
size = legacy === "comfortable" ? "l" : legacy === "compact" ? "s" : "m";
|
|
31
|
+
fit = legacy === "comfortable" || legacy === "compact" ? "0" : "1";
|
|
32
|
+
write(TEXT_SIZE_KEY, size); write(VIEW_FIT_KEY, fit);
|
|
33
|
+
}
|
|
34
|
+
return { size, viewFit: fit !== "0" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const textSizeStore = createStore({ size: "m", viewFit: true });
|
|
38
|
+
function apply(st) { try { document.documentElement.dataset.textSize = st.size; } catch { /* no document */ } }
|
|
39
|
+
textSizeStore.subscribe(apply);
|
|
40
|
+
let listeningOn = null; // the window whose storage events it follows (one page, one window)
|
|
41
|
+
/** A page load: this device's choice, on the root; another tab's choice follows (the storage event). */
|
|
42
|
+
export function initTextSize() {
|
|
43
|
+
const st = loadTextSize();
|
|
44
|
+
textSizeStore.set(st);
|
|
45
|
+
apply(st);
|
|
46
|
+
if (typeof window !== "undefined" && window !== listeningOn && typeof window.addEventListener === "function") {
|
|
47
|
+
listeningOn = window;
|
|
48
|
+
window.addEventListener("storage", (e) => { if (!e || e.key === TEXT_SIZE_KEY || e.key === VIEW_FIT_KEY || e.key === null) textSizeStore.set(loadTextSize()); });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** A size chosen anywhere is the size everywhere (View's Fit included: the last choice wins). */
|
|
52
|
+
export function setTextSize(size) {
|
|
53
|
+
if (!TEXT_SIZES.includes(size)) return;
|
|
54
|
+
write(TEXT_SIZE_KEY, size); write(VIEW_FIT_KEY, "0");
|
|
55
|
+
textSizeStore.set({ size, viewFit: false });
|
|
56
|
+
}
|
|
57
|
+
export function setViewFit() { write(VIEW_FIT_KEY, "1"); textSizeStore.set((s) => ({ ...s, viewFit: true })); }
|
|
58
|
+
|
|
59
|
+
/** Aa: S → M → L → S; on View, Fit → S → M → L → Fit. */
|
|
60
|
+
export function TextSizeButton({ view = false }) {
|
|
61
|
+
const st = useStore(textSizeStore);
|
|
62
|
+
const cur = view && st.viewFit ? "fit" : st.size;
|
|
63
|
+
const order = view ? ["fit", ...TEXT_SIZES] : TEXT_SIZES;
|
|
64
|
+
const next = order[(order.indexOf(cur) + 1) % order.length];
|
|
65
|
+
const name = t(`app.textSize_${cur}`);
|
|
66
|
+
const label = t("app.textSize", name);
|
|
67
|
+
return html`<button type="button" class="btn btn-ghost btn-sm hd-text" onClick=${() => (next === "fit" ? setViewFit() : setTextSize(next))}
|
|
68
|
+
title=${label} aria-label=${label}><${Icon} name="type" size=${16} /><span class="hide-narrow" aria-hidden="true">${name}</span></button>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether the fleet serves the usage panel: the page says so (data-usage-panel); "0" only when turned off. */
|
|
72
|
+
export function usageOffered() {
|
|
73
|
+
try { return document.body && document.body.dataset ? document.body.dataset.usagePanel !== "0" : true; } catch { return true; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* ◔: the usage dialog (usage-dialog.js). It belongs to the navigation that opened it (`navKey`, #1408 §4; #1563
|
|
78
|
+
* review): another route, the same route again, another tab of the panel or a language switch closes it, and its
|
|
79
|
+
* reads and timer end with it — the dialog's lease is that navigation's.
|
|
80
|
+
*/
|
|
81
|
+
export function UsageButton({ navKey }) {
|
|
82
|
+
const [openFor, setOpenFor] = useState(null);
|
|
83
|
+
if (!usageOffered()) return null;
|
|
84
|
+
const open = openFor !== null && openFor === navKey;
|
|
85
|
+
return html`<button type="button" class="btn btn-ghost btn-sm hd-usage" onClick=${() => setOpenFor(navKey)} aria-label=${t("app.usage")} title=${t("app.usage")}>
|
|
86
|
+
<${Icon} name="chart" size=${16} /><span class="hide-narrow" aria-hidden="true">${t("app.usage")}</span></button>
|
|
87
|
+
${open ? html`<${UsageDialog} key=${navKey} owner=${navKey} onClose=${() => setOpenFor(null)} />` : null}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The shared controls, in the one order every page uses. `navKey`: the page's navigation (the usage dialog's owner). */
|
|
91
|
+
export function HeaderTools({ view = false, navKey }) {
|
|
92
|
+
return html`<${TextSizeButton} view=${view} /><${UsageButton} navKey=${navKey} />`;
|
|
93
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { html, useLayoutEffect, useRef } from "./app-html.js";
|
|
11
11
|
import { t } from "./app-i18n.js";
|
|
12
12
|
import { appStore, createStore, useStore } from "./app-store.js";
|
|
13
|
-
import { chatPath, viewPath } from "./app-route.js";
|
|
13
|
+
import { chatPath, detailsPath, viewPath } from "./app-route.js";
|
|
14
14
|
import { Icon } from "./ui-icons.js";
|
|
15
15
|
import { Skeleton } from "./ui-states.js";
|
|
16
16
|
import { viewStore } from "./view-roster-store.js";
|
|
@@ -194,8 +194,11 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
|
|
|
194
194
|
const filterRef = useRef(null);
|
|
195
195
|
const list = useRef(null);
|
|
196
196
|
const drag = useRef(null);
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
// Where a row goes: the view the page is in (#1523 §3.1). View → View; Details, or a Fleet tab (Q4 = A), → Details;
|
|
198
|
+
// everywhere else → Chat. The anonymous reader only ever has View.
|
|
199
|
+
const rowPath = viewOnly || (route && route.panel === "view") ? viewPath
|
|
200
|
+
: route && (route.panel === "details" || route.panel === "fleet") ? detailsPath : chatPath;
|
|
201
|
+
const active = route && (route.panel === "chat" || route.panel === "view" || route.panel === "details") ? route.instance : null;
|
|
199
202
|
useLayoutEffect(() => { keepActiveInView(list.current); }, [active, items.length, loaded]);
|
|
200
203
|
const own = (map, name) => Object.prototype.hasOwnProperty.call(map, name);
|
|
201
204
|
const byName = new Map(items.map((it) => [nameOf(it), it]));
|
|
@@ -258,7 +261,7 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
|
|
|
258
261
|
const facet = facetOf(name);
|
|
259
262
|
const waiting = own(awaiting, name) ? awaiting[name] : null;
|
|
260
263
|
const on = name === active;
|
|
261
|
-
return html`<li key=${name}><a class=${`inst v-inst${on ? " active" : ""}`} href=${
|
|
264
|
+
return html`<li key=${name}><a class=${`inst v-inst${on ? " active" : ""}`} href=${rowPath(name)} title=${instanceTooltip(it)}
|
|
262
265
|
aria-current=${on ? "page" : undefined} onClick=${onPick} ...${dragProps({ type: "instance", name, group: g })}>
|
|
263
266
|
<span class=${`dot ${dotClass(facet, own(exec, name) ? exec[name] : null)}`} aria-hidden="true"></span>
|
|
264
267
|
<span class="inst-text"><span class="inst-name">${name}</span>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// #1523 N2 (§3.2): the instance view switch — Chat | View | Details of ONE instance. Each segment is a link to that
|
|
2
|
+
// view's URL for the same instance, so switching keeps the instance (and the sidebar, which is never remounted).
|
|
3
|
+
// Desktop: links in the header; a phone: the same three as top tabs under it (app.css). The anonymous View reader
|
|
4
|
+
// gets none (Chat and Details need a session): the caller does not render it there.
|
|
5
|
+
import { html } from "./app-html.js";
|
|
6
|
+
import { t } from "./app-i18n.js";
|
|
7
|
+
import { chatPath, detailsPath, viewPath } from "./app-route.js";
|
|
8
|
+
|
|
9
|
+
/** current: "chat" | "view" | "details". */
|
|
10
|
+
export function InstanceSwitch({ name, current }) {
|
|
11
|
+
const seg = (key, href, label) => html`<a key=${key} class=${`seg-item${current === key ? " active" : ""}`} href=${href}
|
|
12
|
+
aria-current=${current === key ? "page" : undefined}>${label}</a>`;
|
|
13
|
+
return html`<nav class="inst-switch seg" aria-label=${t("app.instanceViews", name)}>
|
|
14
|
+
${seg("chat", chatPath(name), t("app.chat"))}${seg("view", viewPath(name), t("app.view"))}${seg("details", detailsPath(name), t("app.details"))}
|
|
15
|
+
</nav>`;
|
|
16
|
+
}
|
|
@@ -11,6 +11,11 @@ import { t } from "./app-i18n.js";
|
|
|
11
11
|
import { appStore, createStore, useStore } from "./app-store.js";
|
|
12
12
|
import { useLease } from "./app-ctx.js";
|
|
13
13
|
import { PanelHeader, setTitle, onPanelKey, signInHref } from "./app-shell.js";
|
|
14
|
+
import { InstanceSwitch } from "./instance-switch.js";
|
|
15
|
+
import { HeaderTools, VIEW_SCALE, textSizeStore } from "./header-tools.js";
|
|
16
|
+
import { readStream } from "./read-stream.js";
|
|
17
|
+
// The usage dialog's metric row lives with the dialog now (usage-dialog.js); re-exported for what imported it here.
|
|
18
|
+
export { UsageMetric } from "./usage-dialog.js";
|
|
14
19
|
import { backendLabel, STATUS_KEYS } from "./instance-nav.js";
|
|
15
20
|
import { viewStore } from "./view-roster-store.js";
|
|
16
21
|
import { navigate } from "./app-nav.js";
|
|
@@ -97,32 +102,7 @@ export { viewStore };
|
|
|
97
102
|
|
|
98
103
|
// ── The panel ──
|
|
99
104
|
|
|
100
|
-
|
|
101
|
-
* One stream of reads under a lease (the pane, the roster, usage): one at a time, and only the newest may land. A tick
|
|
102
|
-
* while a read is on its way is skipped; a forced read (Refresh), or one started after the last has hung STUCK_MS,
|
|
103
|
-
* supersedes it — and a superseded read's answer is dropped at every step: its response, its body, its commit
|
|
104
|
-
* (#1448 review: an interval alone starts overlapping reads, and lease.current() only tells navigations apart).
|
|
105
|
-
*/
|
|
106
|
-
const STUCK_MS = 10_000;
|
|
107
|
-
const clock = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
108
|
-
export function readStream(lease) {
|
|
109
|
-
let gen = 0, busy = false, since = 0;
|
|
110
|
-
return {
|
|
111
|
-
/** A token for a new read, or 0 when this one should not start. */
|
|
112
|
-
begin(force = false) {
|
|
113
|
-
const at = clock();
|
|
114
|
-
if (busy && !force && at - since < STUCK_MS) return 0;
|
|
115
|
-
busy = true; since = at;
|
|
116
|
-
return ++gen;
|
|
117
|
-
},
|
|
118
|
-
/** May this read still land? */
|
|
119
|
-
live: (token) => token === gen && lease.current(),
|
|
120
|
-
end(token) { if (token === gen) busy = false; },
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const DENSITY = { fit: 1, comfortable: 1.25, compact: 0.8 };
|
|
125
|
-
const DENSITY_ORDER = ["fit", "comfortable", "compact"];
|
|
105
|
+
// readStream: one stream of reads under a lease (read-stream.js; View's pane and roster use it).
|
|
126
106
|
const MIN_PX = 12, MAX_PX = 22;
|
|
127
107
|
|
|
128
108
|
export function ViewPanel({ route, navKey }) {
|
|
@@ -134,8 +114,9 @@ export function ViewPanel({ route, navKey }) {
|
|
|
134
114
|
const [dialog, setDialogState] = useState(null);
|
|
135
115
|
const setDialog = (kind) => setDialogState(kind ? { kind, key: navKey } : null);
|
|
136
116
|
const open = dialog && dialog.key === navKey ? dialog.kind : null;
|
|
137
|
-
|
|
138
|
-
const
|
|
117
|
+
// #1523 N3: the text size is the page's one setting (header-tools.js); View's own extra choice is Fit.
|
|
118
|
+
const ts = useStore(textSizeStore);
|
|
119
|
+
const density = ts.viewFit ? "fit" : ts.size;
|
|
139
120
|
const name = route.instance;
|
|
140
121
|
const it = name ? v.roster.find((r) => r.instance_name === name) : null;
|
|
141
122
|
|
|
@@ -164,13 +145,6 @@ export function ViewPanel({ route, navKey }) {
|
|
|
164
145
|
lease.interval(load, 5000);
|
|
165
146
|
lease.on(document, "visibilitychange", () => { if (!document.hidden) load(); });
|
|
166
147
|
}, [lease]);
|
|
167
|
-
// Usage: whether the fleet offers it (a 404 means off). The read also warms the server's 5-minute cache.
|
|
168
|
-
useEffect(() => {
|
|
169
|
-
(async () => {
|
|
170
|
-
try { const r = await lease.fetch("/api/ai-usage"); if (lease.current()) setUsageAvailable(r.status !== 404); }
|
|
171
|
-
catch { /* stays hidden */ }
|
|
172
|
-
})();
|
|
173
|
-
}, [lease]);
|
|
174
148
|
// /view on its own: the instance this browser looked at last, else the first one.
|
|
175
149
|
useEffect(() => {
|
|
176
150
|
if (name || !v.loaded || !v.roster.length) return;
|
|
@@ -187,15 +161,8 @@ export function ViewPanel({ route, navKey }) {
|
|
|
187
161
|
if (f) { e.preventDefault(); f.focus(); }
|
|
188
162
|
})), [lease]);
|
|
189
163
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
store("agend_view_density", next);
|
|
193
|
-
setDensity(next);
|
|
194
|
-
};
|
|
195
|
-
const densityName = tn(`density${density.charAt(0).toUpperCase()}${density.slice(1)}`);
|
|
196
|
-
const actions = html`
|
|
197
|
-
<button type="button" class="btn btn-ghost btn-sm" onClick=${cycleDensity} title=${tn("textSize", { size: densityName })} aria-label=${tn("textSize", { size: densityName })}><${Icon} name="type" size=${16} /><span class="hide-narrow" aria-hidden="true">${densityName}</span></button>
|
|
198
|
-
${usageAvailable ? html`<button type="button" class="btn btn-ghost btn-sm" onClick=${() => setDialog("usage")} aria-label=${tn("usageButton")} title=${tn("usageButton")}><${Icon} name="chart" size=${16} /><span class="hide-narrow" aria-hidden="true">${tn("usageButton")}</span></button>` : null}
|
|
164
|
+
// Aa and ◔ are every page's (HeaderTools); Help is View's own.
|
|
165
|
+
const actions = html`<${HeaderTools} view=${true} navKey=${navKey} />
|
|
199
166
|
<button type="button" class="icon-btn" onClick=${() => setDialog("help")} aria-label=${tn("helpButton")} title=${tn("helpButton")}><${Icon} name="info" /></button>`;
|
|
200
167
|
|
|
201
168
|
let body;
|
|
@@ -209,10 +176,10 @@ export function ViewPanel({ route, navKey }) {
|
|
|
209
176
|
|
|
210
177
|
return html`<div class="panel p-view">
|
|
211
178
|
<${PanelHeader} title=${it ? (it.display_name || it.instance_name) : tn("title")}
|
|
212
|
-
sub=${it ? html`<span class="status"><span class=${`dot ${it.status === "running" ? "ok" : it.status === "crashed" ? "bad" : "off"}`} aria-hidden="true"></span>${tn(STATUS_KEYS[it.status] || "statusUnknown")}</span>` : null}
|
|
179
|
+
sub=${it ? html`<span class="status"><span class=${`dot ${it.status === "running" ? "ok" : it.status === "crashed" ? "bad" : "off"}`} aria-hidden="true"></span>${tn(STATUS_KEYS[it.status] || "statusUnknown")}</span>` : null}
|
|
180
|
+
nav=${it && !viewOnly ? html`<${InstanceSwitch} name=${name} current="view" />` : null}>${actions}</${PanelHeader}>
|
|
213
181
|
${body}
|
|
214
182
|
${open === "edit" && it ? html`<${EditProfile} key=${navKey} it=${it} onClose=${() => setDialog(null)} />` : null}
|
|
215
|
-
${open === "usage" ? html`<${UsageDialog} onClose=${() => setDialog(null)} />` : null}
|
|
216
183
|
${open === "help" ? html`<${HelpDialog} onClose=${() => setDialog(null)} />` : null}
|
|
217
184
|
</div>`;
|
|
218
185
|
}
|
|
@@ -234,7 +201,7 @@ function Terminal({ name, lease, density }) {
|
|
|
234
201
|
const lh = parseFloat(pcs.lineHeight) / parseFloat(pcs.fontSize);
|
|
235
202
|
const lineH = Number.isFinite(lh) && lh > 0 ? lh : 1.2;
|
|
236
203
|
let size = Math.min(innerW / (g.cols * glyphRatio(p)), innerH / (g.rows * lineH)) * 0.995;
|
|
237
|
-
size = Math.max(MIN_PX, Math.min(size, MAX_PX)) * (
|
|
204
|
+
size = Math.max(MIN_PX, Math.min(size, MAX_PX)) * (density === "fit" ? 1 : VIEW_SCALE[density] || 1);
|
|
238
205
|
size = Math.max(8, Math.min(size, 32));
|
|
239
206
|
g.key = key;
|
|
240
207
|
if (Math.abs(size - g.applied) < 0.75) return;
|
|
@@ -369,109 +336,3 @@ function HelpDialog({ onClose }) {
|
|
|
369
336
|
<ul class="help-list">${["help1", "help2", "help3", "help4", "help5", "help6"].map((k) => html`<li key=${k}>${tn(k)}</li>`)}</ul>
|
|
370
337
|
</${Dialog}>`;
|
|
371
338
|
}
|
|
372
|
-
|
|
373
|
-
// ── AI usage ── Every provider the fleet's logins report; refreshed every 60 s while this dialog is open (passive).
|
|
374
|
-
const USAGE_ORDER_KEY = "agend_view_usage_order";
|
|
375
|
-
function usageText(fallback, ref) {
|
|
376
|
-
if (!ref || typeof ref.key !== "string") return fallback == null ? "" : String(fallback);
|
|
377
|
-
return (Array.isArray(ref.args) ? ref.args : []).reduce((text, value, i) => text.split(`{${i}}`).join(String(value)), t(`view.${ref.key}`));
|
|
378
|
-
}
|
|
379
|
-
function usageDuration(ms) {
|
|
380
|
-
const total = Math.max(1, Math.ceil(ms / 60000));
|
|
381
|
-
const d = Math.floor(total / 1440), h = Math.floor((total % 1440) / 60), m = total % 60;
|
|
382
|
-
if (total >= 2880) return usageText("", { key: "usage.duration.days_hours", args: [d, h] });
|
|
383
|
-
if (total >= 60) return usageText("", { key: "usage.duration.hours_minutes", args: [Math.floor(total / 60), m] });
|
|
384
|
-
return usageText("", { key: "usage.duration.minutes", args: [total] });
|
|
385
|
-
}
|
|
386
|
-
function resetText(iso) {
|
|
387
|
-
if (!iso) return "";
|
|
388
|
-
const ms = new Date(iso).getTime() - Date.now();
|
|
389
|
-
if (Number.isNaN(ms)) return "";
|
|
390
|
-
if (ms <= 0) return tn("usage.reset.soon");
|
|
391
|
-
return usageText("", { key: "usage.reset.in", args: [usageDuration(ms)] });
|
|
392
|
-
}
|
|
393
|
-
function expiryText(iso) {
|
|
394
|
-
if (!iso) return "";
|
|
395
|
-
const at = new Date(iso), ms = at.getTime() - Date.now();
|
|
396
|
-
if (!(ms > 0)) return "";
|
|
397
|
-
return usageText("", { key: "usage.ticket_expiry", args: [`${at.getMonth() + 1}/${at.getDate()}`, usageDuration(ms)] });
|
|
398
|
-
}
|
|
399
|
-
export function UsageMetric({ m }) {
|
|
400
|
-
const bar = useRef(null);
|
|
401
|
-
const label = usageText(m.label, m.labelI18n);
|
|
402
|
-
const note = m.note ? usageText(m.note, m.noteI18n) : "";
|
|
403
|
-
const pct = m.type === "percent" ? Math.min(100, Math.max(0, m.used ?? 0)) : 0;
|
|
404
|
-
useLayoutEffect(() => { if (bar.current) bar.current.style.width = `${pct}%`; }, [pct]); // CSSOM (#1300)
|
|
405
|
-
if (m.type === "percent") {
|
|
406
|
-
const cls = pct >= 90 ? "crit" : pct >= 70 ? "warn" : "";
|
|
407
|
-
const word = pct >= 90 ? tn("usageNear") : pct >= 70 ? tn("usageHigh") : "";
|
|
408
|
-
const sub = [note, resetText(m.resetsAt)].filter(Boolean).join(" · ");
|
|
409
|
-
return html`<div class="u-metric"><div class="u-row"><span class="u-label">${label}</span>${word ? html`<span class=${`u-status ${cls}`}>${word}</span>` : null}
|
|
410
|
-
<span class=${`u-pct ${cls}`}>${pct.toFixed(0)}<span class="u-unit">%</span></span></div>
|
|
411
|
-
<div class="u-meter"><div ref=${bar} class=${`u-fill ${cls}`}></div></div>${sub ? html`<div class="u-sub">${sub}</div>` : null}</div>`;
|
|
412
|
-
}
|
|
413
|
-
if (m.type === "dollars") {
|
|
414
|
-
const val = m.limit ? `$${(m.used ?? 0).toFixed(2)} / $${m.limit.toFixed(2)}` : `$${(m.used ?? 0).toFixed(2)}`;
|
|
415
|
-
return html`<div class="u-metric"><div class="u-row"><span class="u-label">${label}</span><span class="u-val">${val}</span></div>${note ? html`<div class="u-sub">${note}</div>` : null}</div>`;
|
|
416
|
-
}
|
|
417
|
-
const val = `${usageText(m.value ?? "", m.valueI18n)} ${usageText(m.unit ?? "", m.unitI18n)}`.trim();
|
|
418
|
-
const sub = [note, expiryText(m.expiresAt)].filter(Boolean).join(" · ");
|
|
419
|
-
return html`<div class="u-metric"><div class="u-row"><span class="u-label">${label}</span><span class="u-val">${val}</span></div>${sub ? html`<div class="u-sub">${sub}</div>` : null}</div>`;
|
|
420
|
-
}
|
|
421
|
-
function UsageDialog({ onClose }) {
|
|
422
|
-
const lease = useLease("usage-dialog");
|
|
423
|
-
const [data, setData] = useState(undefined); // undefined: loading; null: failed
|
|
424
|
-
const [order, setOrder] = useState(() => { try { const a = JSON.parse(stored(USAGE_ORDER_KEY) || "[]"); return Array.isArray(a) ? a.filter((x) => typeof x === "string") : []; } catch { return []; } });
|
|
425
|
-
const reads = useMemo(() => readStream(lease), [lease]);
|
|
426
|
-
// Refresh (force) supersedes a read on its way; the minute's tick waits for it.
|
|
427
|
-
const load = async (force) => {
|
|
428
|
-
const token = reads.begin(force);
|
|
429
|
-
if (!token) return;
|
|
430
|
-
try {
|
|
431
|
-
const r = await lease.fetch(`/api/ai-usage${force ? "?force=1" : ""}`);
|
|
432
|
-
if (!reads.live(token)) return;
|
|
433
|
-
const d = await r.json();
|
|
434
|
-
if (reads.live(token)) setData(d);
|
|
435
|
-
} catch { if (reads.live(token)) setData(null); }
|
|
436
|
-
finally { reads.end(token); }
|
|
437
|
-
};
|
|
438
|
-
useEffect(() => { load(false); lease.interval(() => load(false), 60_000); }, [lease]);
|
|
439
|
-
const key = (p) => String(p.id || p.name || "");
|
|
440
|
-
const ordered = (providers) => {
|
|
441
|
-
const rank = new Map(order.map((id, i) => [id, i]));
|
|
442
|
-
return providers.map((p, i) => ({ p, i })).sort((a, b) => {
|
|
443
|
-
const ar = rank.has(key(a.p)) ? rank.get(key(a.p)) : Infinity, br = rank.has(key(b.p)) ? rank.get(key(b.p)) : Infinity;
|
|
444
|
-
return ar === br ? a.i - b.i : ar - br;
|
|
445
|
-
}).map((e) => e.p);
|
|
446
|
-
};
|
|
447
|
-
const move = (id, delta, providers) => {
|
|
448
|
-
const ids = ordered(providers).map(key);
|
|
449
|
-
const from = ids.indexOf(id), to = from + delta;
|
|
450
|
-
if (from < 0 || to < 0 || to >= ids.length) return;
|
|
451
|
-
[ids[from], ids[to]] = [ids[to], ids[from]];
|
|
452
|
-
setOrder(ids); store(USAGE_ORDER_KEY, JSON.stringify(ids));
|
|
453
|
-
};
|
|
454
|
-
let content;
|
|
455
|
-
if (data === undefined) content = html`<${Skeleton} lines=${4} />`;
|
|
456
|
-
else if (!data || !Array.isArray(data.providers)) content = html`<${ErrorState} message=${tn("usageFail")} onRetry=${() => load(true)} />`;
|
|
457
|
-
else {
|
|
458
|
-
const providers = ordered(data.providers);
|
|
459
|
-
content = html`${!providers.length ? html`<p class="note">${tn("usage.empty_active")}</p>` : null}
|
|
460
|
-
${providers.map((p, i) => html`<section key=${key(p)} class="u-provider">
|
|
461
|
-
<div class="u-head"><strong>${p.name}</strong>${p.plan ? html`<span class="pill">${p.plan}</span>` : null}
|
|
462
|
-
<span class="u-order">
|
|
463
|
-
<button type="button" class="icon-btn" disabled=${i === 0} aria-label=${tn("usageMoveUp")} title=${tn("usageMoveUp")} onClick=${() => move(key(p), -1, data.providers)}><${Icon} name="up" size=${14} /></button>
|
|
464
|
-
<button type="button" class="icon-btn" disabled=${i === providers.length - 1} aria-label=${tn("usageMoveDown")} title=${tn("usageMoveDown")} onClick=${() => move(key(p), 1, data.providers)}><${Icon} name="down" size=${14} /></button>
|
|
465
|
-
</span></div>
|
|
466
|
-
${p.stale ? html`<p class="note">${usageText(p.hint || "", p.hintI18n)}</p>` : null}
|
|
467
|
-
${p.status === "error" && !p.transient ? html`<p class="u-err">${usageText(p.error || tn("usage.error_fallback"), p.errorI18n)}</p>`
|
|
468
|
-
: p.status === "error" && p.transient ? html`<p class="note">${usageText(p.error || tn("usage.error_fallback"), p.errorI18n)}</p>`
|
|
469
|
-
: p.status === "no-credentials" ? html`<p class="note">${tn("usage.not_logged_in")}</p>${p.hint ? html`<p class="note">${usageText(p.hint, p.hintI18n)}</p>` : null}`
|
|
470
|
-
: html`${(p.metrics || []).map((m, j) => html`<${UsageMetric} key=${j} m=${m} />`)}
|
|
471
|
-
${!p.stale && p.hint ? html`<p class="note">${usageText(p.hint, p.hintI18n)}</p>` : !(p.metrics || []).length ? html`<p class="note">${tn("usage.no_data")}</p>` : null}`}
|
|
472
|
-
</section>`)}
|
|
473
|
-
<div class="u-foot"><span class="note">${tn("usageUpdated")} ${new Date(data.fetchedAt).toLocaleTimeString()}</span>
|
|
474
|
-
<button type="button" class="btn btn-sm" onClick=${() => load(true)}>${tn("usageRefresh")}</button></div>`;
|
|
475
|
-
}
|
|
476
|
-
return html`<${Dialog} title=${tn("usage.title")} onClose=${onClose} wide=${true}>${content}</${Dialog}>`;
|
|
477
|
-
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// One stream of reads under a lease (moved from panel-view.js for #1523 N3: View's pane and roster, and the usage
|
|
2
|
+
// dialog every page's header opens, share it).
|
|
3
|
+
/**
|
|
4
|
+
* One stream of reads under a lease (the pane, the roster, usage): one at a time, and only the newest may land. A tick
|
|
5
|
+
* while a read is on its way is skipped; a forced read (Refresh), or one started after the last has hung STUCK_MS,
|
|
6
|
+
* supersedes it — and a superseded read's answer is dropped at every step: its response, its body, its commit
|
|
7
|
+
* (#1448 review: an interval alone starts overlapping reads, and lease.current() only tells navigations apart).
|
|
8
|
+
*/
|
|
9
|
+
const STUCK_MS = 10_000;
|
|
10
|
+
const clock = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
11
|
+
export function readStream(lease) {
|
|
12
|
+
let gen = 0, busy = false, since = 0;
|
|
13
|
+
return {
|
|
14
|
+
/** A token for a new read, or 0 when this one should not start. */
|
|
15
|
+
begin(force = false) {
|
|
16
|
+
const at = clock();
|
|
17
|
+
if (busy && !force && at - since < STUCK_MS) return 0;
|
|
18
|
+
busy = true; since = at;
|
|
19
|
+
return ++gen;
|
|
20
|
+
},
|
|
21
|
+
/** May this read still land? */
|
|
22
|
+
live: (token) => token === gen && lease.current(),
|
|
23
|
+
end(token) { if (token === gen) busy = false; },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|